Compare commits

...

12 commits

Author SHA1 Message Date
Charles
32e41a8ea7
Merge b85f2889b0 into 8448b1105c 2026-05-31 11:54:44 +08:00
mehmet turac
8448b1105c
fix: preserve OpenClaude fallback credentials (#3361)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
2026-05-31 03:49:25 +00:00
Jane
d66a463d62
feat(landing-page): 301 legacy /skills /systems /templates to /plugins (#3352)
The 2026-05 plugins library rebuild introduced /plugins/skills/,
/plugins/systems/, /plugins/templates/ and a unified detail route
/plugins/<manifest-slug>/, but the old /skills/, /systems/, /templates/
catalogs were left live in parallel. Two equivalent page trees split SEO
equity, and the homepage, footer, quickstart, agents, official and blog
pages all still linked to the old routes.

Retire the legacy generators and 301 every old URL to its new plugins
equivalent so inbound links and search equity are preserved:

- Remove the /skills, /systems, /templates page generators (English +
  [locale] wrappers) and the now-orphaned skill-row component, and prune
  the skills/systems/templates branches from the [locale]/[...path]
  catch-all (it now renders only craft + blog).
- Add the migration block to public/_redirects. Detail slugs differ from
  the old folder names (new slugs are manifest-name based, e.g.
  design-system-<x>, example-<x>), so systems/templates use a prefixed
  splat plus a short degrade list, and skills map the 27 with a template
  equivalent explicitly while the ~110 instruction-only skills and all
  mode/scenario/category facet pages degrade to the section landing.
  'replicate' is forced to the section to avoid colliding with the
  design-system of the same name. Locale variants (zh, zh-tw, ja, ko)
  strip to the section.
- Repoint in-site links to /plugins/* across page.tsx (footer, work,
  labs pills), info-page-i18n.ts (en + zh + sourceNames), official,
  quickstart, agents, blog and html-anything, and update the sitemap
  serialize priority list. The system-card keeps linking through
  /systems/<slug>/ so the 8 systems without a detail page ride the
  redirect's degrade rather than pointing at a missing page.

Verified with a full astro build: old routes no longer emit any HTML,
the new section pages exist, _redirects is copied verbatim, and no
in-site link targets a removed route (the remaining /systems/<slug>/
hrefs are the system cards that 301 by design). astro check passes.

Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
2026-05-31 01:04:20 +00:00
estelledc
1a6face04c
fix(web): prune draft tokens when the plugin chip strip clears (#2881) (#3356)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
ChatComposer tracks the `@…` tokens this surface authored via the
@-mention popover plugin-pick path. When PluginsSection's chip strip
clears, we wire its `onCleared` and prune *only* those tracked
insertions from the draft so the textarea no longer holds orphaned
styled mentions whose chips just unmounted.

Architecture summary (rounds 1–9 collapsed; round 10 detailed below):

  - `Array<{token, start, pluginId, insertionId?}>` tracking with
    start offsets reconciled across each keystroke via an LCS+LCP
    edit-range diff in
    `apps/web/src/utils/pluginInsertionTracking.ts` (round 3-4).
    `insertionId` is forwarded by `reconcileInsertions` so the
    producer can locate its own entry across reconciles
    (round 10).
  - All draft mutations route through a single `updateDraft`
    chokepoint that runs `reconcileInsertions` outside the
    `setDraft` updater so React StrictMode's double-invoke is
    harmless (round 4-5).
  - Boundaries delegate to the shared
    `inlineMentions.isMentionBoundary` /
    `inlineMentions.isMentionRightBoundary` helpers so the
    tracker can never diverge from the parser (round 5).
  - `setActivePlugin` is a chokepoint for every applyById path,
    filtering tracked entries to those matching the new active
    plugin so a replace-plugin flow can never let stale entries
    survive (round 6).
  - Picker rollback double-snapshots draft + tracker so apply-
    failure restores the tracker but only rewrites the draft
    when no user keystrokes arrived during the await
    (round 7-8).
  - `stripPluginInsertedTokens` collapses whitespace seam-local
    so user-authored multi-space spans elsewhere are preserved
    (round 8).
  - `setActivePlugin` is deferred past `await applyById` on
    every path, and `onCleared` filters by
    `pluginsSectionRef.current?.getActiveRecord()?.id` so a
    pending-window clear scopes to the actually-mounted
    plugin's tokens (round 9).

race in the picker rollback:

  Round 9 made `onCleared` mutate the tracker and the draft when
  it ran during a pending replace, and added the `getActiveRecord`
  filter so the strip targets the still-mounted plugin's entries
  only. The picker's failure-path rollback, however, still
  restored `prevEntries` / `prevActiveId` wholesale — assuming
  nothing else had touched the tracker during the await. If the
  user clicked the still-mounted original chip's × during the
  pending replace AND the deferred `applyById` then resolved
  with a 500, the wholesale restore (a) resurrected entries that
  `onCleared` had legitimately stripped (now stale offsets) and
  (b) left the optimistic `@<target>` orphaned in the draft with
  no chip ever having mounted — the original #2881 symptom
  recurring inside the failure window.

  Fix splits the failure rollback into two paths:

  1. **Detect "intervening clear" via `activePluginIdRef.current
     === null && prevActiveId !== null`.** `onCleared` always
     nulls the active id as its last action; our deferred
     `setActivePlugin` never ran in the failure branch. So the
     null-while-prev-not-null state is the smoking gun for an
     intervening clear during the await.

  2. **On detection, surgically remove only our optimistic
     entry and only its `@<target>`.** Locate the entry by
     `insertionId` (added to `TrackedInsertion` as an optional
     field, forwarded by `reconcileInsertions` so the id
     survives offset shifts) — this disambiguates the case
     where the user picked the same plugin from the @-popover
     more than once during the await window. Splice that entry
     out and run `updateDraft((d) => stripPluginInsertedTokens(
     d, [ourEntry]))` so the draft loses `@<target>` and any
     remaining tracked entries (the in-flight target would have
     no others, but a co-pending second pick could) get their
     offsets reconciled. `activePluginIdRef` stays at `null` —
     `onCleared`'s truth, since no chip is mounted.

  The "no intervening clear" branch is the round 7/8 path:
  restore `prevEntries`/`prevActiveId` wholesale and rewrite
  the draft only if `draftRef.current === postInsertDraft`
  (no user keystrokes during the await).

Regression coverage (additions):

  - `apps/web/tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx`
    — 18 integration specs total (17 prior + 1 new round-10):
    * `@-popover pick A → @-popover pick B (apply pending) →
      clear A's chip → resolve B with 500 → assert no orphan
      @<target>, no orphan @A, no chip mounted, no stale
      tracker entries`. Uses a deferred `Promise<Response>` so
      the apply stays in flight while the chip-clear is fired,
      then resolves with a 500 to drive the failure path. Pre-
      fix this would resurrect Airbnb's stale entry AND leave
      `@SecondPlugin` orphaned in the draft.

PluginsSection.tsx is unchanged. The host-local tracking +
draft-update chokepoint + parser-aligned boundaries + deferred
active-plugin scoping + transactional applyById + intervening-
clear-aware rollback + filtered `onCleared` keep the cross-
component contract identical to main — only ChatComposer touches
behavior, plus the utils module and two `inlineMentions` exports.

Validation:
  - pnpm exec vitest run tests/utils/pluginInsertionTracking.test.ts → 36/36 passed
  - pnpm exec vitest run tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx → 18/18 passed
  - pnpm exec vitest run -c vitest.config.ts (full apps/web suite, 228 files) → 2202/2202 passed
  - pnpm --filter @open-design/web typecheck → green
  - pnpm guard → green
2026-05-30 17:16:24 +00:00
Denis Redozubov
f4c5d22f22
fix(daemon): confine sandbox project roots and host discovery (#3243)
* fix(daemon): confine sandbox project and host discovery

* fix(daemon): resolve sandbox data dir for toolchain discovery

* fix(daemon): resolve sandbox data dir for agent env

* fix(daemon): fail fast for sandbox imported folders

* test(daemon): assert sandbox imported folder rejection

* fix(daemon): keep sandbox import guard at run start

* fix(daemon): reject sandbox imported project file roots

* fix(daemon): preserve imported project detail roots

* test(daemon): expect sandbox profiles to stay scoped

* fix(daemon): bypass proxies for agent tool callbacks

* test(daemon): isolate media policy route memory extraction

* fix(daemon): keep loopback no-proxy scoped to sandbox
2026-05-30 16:57:04 +00:00
Denis Redozubov
9a3424d68c
feat(daemon): add sandbox runtime foundation (#3242)
* feat(daemon): add sandbox runtime foundation

* fix(daemon): preserve sandbox roots after agent env overrides

* fix(daemon): keep readiness probes pathless

* fix(daemon): harden headless run fallbacks

* fix(daemon): bootstrap sandbox runtime discovery

* fix(daemon): preserve explicit sandbox agent profile mounts

* fix(daemon): keep sandbox profile lookup run scoped

* fix(daemon): normalize sandbox data dir input

* fix(daemon): pin sandbox env roots to base data dir
2026-05-30 15:06:05 +00:00
open-design-bot[bot]
b9f0b69cf1
docs(readme): refresh contributors wall (#3339)
Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
2026-05-30 14:02:17 +00:00
Charles
b85f2889b0 fix(i18n): point Korean license-exception link to design-templates/guizang-ppt
The bundled MIT-licensed guizang-ppt files live under design-templates/guizang-ppt/, not skills/guizang-ppt/. Correct the Korean CONTRIBUTING license-exception reference so readers reach a valid path.
2026-05-30 10:12:36 +09:00
Charles
e091d1790f docs(i18n): link Korean core docs from README switcher and mark ko Core Docs complete 2026-05-30 09:55:17 +09:00
Charles
a4d63edecd feat(i18n): add Korean translation for MAINTAINERS 2026-05-30 09:55:17 +09:00
Charles
4024b8dd98 feat(i18n): add Korean translation for CONTRIBUTING 2026-05-30 09:55:17 +09:00
Charles
766a680a34 feat(i18n): add Korean translation for QUICKSTART 2026-05-30 09:55:17 +09:00
83 changed files with 5015 additions and 1965 deletions

316
CONTRIBUTING.ko.md Normal file
View file

@ -0,0 +1,316 @@
# 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> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a> · <a href="CONTRIBUTING.ja-JP.md">日本語</a> · <b>한국어</b></p>
---
## 오후 한나절이면 끝나는 세 가지 기여
| 하고 싶은 일 | 실제로 추가하는 것 | 위치 | 규모 |
|---|---|---|---|
| OD가 새로운 종류의 artifact를 렌더링하게 만들기 (청구서, iOS 설정 화면, 한 장짜리 문서 등) | **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줄 |
| 기능 추가, 버그 수정, [`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 한 건 |
어느 쪽에 해당하는지 모르겠다면 [먼저 discussion이나 issue를 열어주세요](https://github.com/nexu-io/open-design/issues/new). 적절한 위치를 안내해 드리겠습니다.
---
## 로컬 환경 설정
한 페이지짜리 전체 설정 안내는 [`QUICKSTART.ko.md`](QUICKSTART.ko.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`은 선택 사항입니다. Node를 그렇게 관리하는 게 편하다면 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요. macOS, Linux, WSL2가 주요 지원 환경입니다. Windows 네이티브도 지원합니다. 흔히 겪는 설정 문제는 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)를 참고하세요.
## Docker 설정
Node.js나 pnpm을 설치하지 않고도 Open Design을 실행할 수 있습니다.
### 사전 준비
Compose v2가 포함된 Docker Desktop이 설치되어 있는지 확인하세요.
```bash
docker compose version
```
### Open Design 실행
```bash
cd deploy
docker compose up -d
```
브라우저에서 다음 주소를 엽니다.
```text
http://localhost:7456
```
### 자주 쓰는 명령어
```bash
# 로그 보기
docker compose logs -f
# 컨테이너 재시작
docker compose restart
# 컨테이너 중지
docker compose down
# 최신 이미지 받기
docker compose pull
docker compose up -d
```
### 선택적 환경 변수 재정의
`deploy/.env` 파일을 만듭니다.
```env
OPEN_DESIGN_PORT=7456
OPEN_DESIGN_MEM_LIMIT=384m
OPEN_DESIGN_ALLOWED_ORIGINS=https://yourdomain.com
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest
```
> 프로젝트와 데이터베이스 데이터는 Docker 볼륨에 자동으로 보존됩니다.
전체 Docker 가이드와 고급 설정은 [`QUICKSTART.ko.md`](QUICKSTART.ko.md)를 참고하세요.
---
## 새 Skill 추가하기
skill은 [`skills/`](skills/) 아래에 두는 폴더로, 루트에 `SKILL.md`를 두고 Claude Code의 [`SKILL.md` 규약][skill]에 우리의 선택적 `od:` 확장을 더한 형태입니다. **등록 절차는 없습니다.** 폴더를 넣고 daemon을 재시작하면 picker에 바로 나타납니다.
### → 전체 가이드는 [`docs/skills-contributing.md`](docs/skills-contributing.md)를 보세요
이 문서가 다음 내용을 단계별로 안내합니다.
- **빠른 시작** — 저장소 클론 → 가장 비슷한 기존 skill 복사 → `pnpm tools-dev run web` 실행 → picker 확인 → PR 열기.
- **skill이란 무엇이고 무엇이 아닌가** — 당신의 아이디어가 사실은 기능이나 vendor 연동이었다면, 일주일을 아껴줍니다.
- **skill 구조** — 최소한의 폴더 구성과 `SKILL.md` frontmatter 치트시트.
- **로컬 실행** — 실제로 중요한 네 가지 명령어.
- **머지 기준** — 리뷰어가 확인할 항목을 그대로 복사해 쓸 수 있는 체크리스트.
- **PR 설명 템플릿** — PR 본문에 붙여넣고 채우면 됩니다.
- **자주 거절되는 패턴** — 최근 실제로 사용한 거절 사유와 구체적인 예시.
프로토콜 명세(전체 frontmatter 문법 — 타입이 지정된 입력, 슬라이더 파라미터, craft 참조, 테스트 프리미티브)는 [`docs/skills-protocol.md`](docs/skills-protocol.md)에 별도로 정리되어 있습니다.
---
## 새 Design System 추가하기
design system은 `design-systems/<slug>/` 아래에 두는 [`DESIGN.md`](design-systems/README.md) 파일 하나입니다. **파일 하나뿐, 코드는 없습니다.** 넣고 daemon을 재시작하면 picker에 카테고리별로 묶여 나타납니다.
### design system 폴더 구성
```text
design-systems/your-brand/
└── DESIGN.md
```
### `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
```
9개 섹션 구조는 고정입니다. skill 본문이 이 구조를 grep으로 찾기 때문입니다. 첫 H1이 picker 라벨이 되고(`Design System Inspired by` 접두사는 자동으로 제거됩니다), `> Category: …` 줄이 어느 그룹에 들어갈지 결정합니다. 기존 카테고리는 [`design-systems/README.md`](design-systems/README.md)에 정리되어 있습니다. 브랜드가 정말 어디에도 안 맞으면 새 카테고리를 만들 수 있지만, **먼저 기존 카테고리부터 검토하세요**.
### 새 design system 머지 기준
1. **9개 섹션이 모두 있어야 합니다.** 찾기 어려운 데이터(예: motion 토큰)는 섹션 본문이 비어 있어도 괜찮지만, 제목은 반드시 있어야 합니다. 없으면 프롬프트의 grep이 깨집니다.
2. **hex 코드는 실제 값이어야 합니다.** 기억이나 AI 추측이 아니라 브랜드의 사이트나 제품에서 직접 추출하세요. README의 "brand-spec extraction" 5단계 프로토콜은 maintainer에게도 똑같이 적용됩니다.
3. **강조 색상의 OKLch 값**은 있으면 좋습니다. 라이트/다크 모드에서 팔레트가 예측 가능하게 보간됩니다.
4. **마케팅 문구는 빼세요.** 브랜드 슬로건은 design 토큰이 아닙니다. 잘라내세요.
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]에서 가져온 것입니다. 브랜드가 그 upstream에 속한다면 **그쪽에 먼저 PR을 보내세요.** 다음 동기화 때 자동으로 반영됩니다. `design-systems/` 폴더는 upstream에 맞지 않는 시스템과, 우리가 직접 작성한 스타터 2개를 위한 곳입니다.
---
## 새 coding-agent CLI 추가하기
새 agent(예: 어느 신생 업체의 `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)에 파서를 연결하고 `streamFormat: 'claude-stream-json'`으로 설정하세요.
머지 기준:
1. 새 agent로 **실제 세션이 처음부터 끝까지 동작**해야 합니다. artifact가 스트리밍으로 통과한 것을 보여주는 daemon 로그를 PR 설명에 붙여넣으세요.
2. CLI의 특이사항을 **`docs/agent-adapters.md`**에 정리하세요(키 파일이 필요한가? 이미지 입력을 지원하는가? 비대화형 플래그는 무엇인가?).
3. **README의 "Supported coding agents" 표**에 한 줄을 추가하세요.
---
## 모델 `max_tokens` 메타데이터 갱신하기
API 모드 채팅은 매 요청마다 upstream provider에 `max_tokens`를 보냅니다. web 클라이언트는 [`apps/web/src/state/maxTokens.ts`](apps/web/src/state/maxTokens.ts)의 3단계 조회로 이 값을 정합니다.
1. 설정에서 사용자가 직접 지정한 값(있는 경우).
2. 없으면 [`apps/web/src/state/litellm-models.json`](apps/web/src/state/litellm-models.json)의 모델별 기본값. 이는 [BerriAI/litellm][litellm]의 `model_prices_and_context_window.json`(MIT)에서 잘라온 vendored 데이터로, Anthropic, OpenAI, DeepSeek, Groq, Together, Mistral, Gemini, Bedrock, Vertex, OpenRouter 등 약 2천 개의 채팅 모델을 포함합니다.
3. 그래도 없으면 `FALLBACK_MAX_TOKENS = 8192`.
새로 출시된 모델을 반영하려면 vendored JSON을 다시 생성하세요.
```bash
node --experimental-strip-types scripts/sync-litellm-models.ts
```
이 스크립트는 LiteLLM의 카탈로그를 가져와 `mode: 'chat'` 항목만 걸러내고, 각 항목을 `max_output_tokens`(없으면 `max_tokens`)로 매핑한 뒤 정렬된 스냅샷을 씁니다. 갱신을 유발한 PR과 함께 다시 생성된 `litellm-models.json`을 커밋하세요.
`maxTokens.ts`의 OVERRIDES 표는 실제로 쓰는 모델 id에 대해 LiteLLM에 값이 없거나 틀린 드문 경우를 위한 것입니다. 예를 들어 `mimo-v2.5-pro`가 그렇습니다(LiteLLM은 MiMo를 `openrouter/xiaomi/...``novita/xiaomimimo/...` 별칭으로만 제공하는데, 둘 다 Xiaomi 직접 API가 쓰는 정식 id와 맞지 않습니다). 이 표는 작게 유지하세요. LiteLLM이 제대로 다루는 것은 모두 upstream에 두는 게 맞습니다.
[litellm]: https://github.com/BerriAI/litellm
---
## 현지화 유지보수
독일어는 격식 있는 `Sie`를 씁니다. OD는 1인 창작자, 에이전시, 엔지니어링 팀이 뒤섞인 사용자층에 말을 걸기 때문입니다. 비격식 `du` 어조가 더 잘 맞는다는 프로젝트 피드백이 나오기 전까지는, 격식 독일어가 가장 무난한 기본값입니다. 로케일 PR은 UI 요소, 핵심 문서, 그리고 `apps/web/src/i18n/content.ts`의 표시 전용 갤러리 메타데이터를 번역해야 하지만, `skills/``design-systems/`, 또는 agent가 실행하는 프롬프트 본문은 번역하면 안 됩니다. 이런 원본 프롬프트는 워크플로우 입력으로 관리되며, 원본 언어를 하나로 유지해야 로케일마다 프롬프트 QA가 늘어나는 일을 막을 수 있습니다. skill, design system, 프롬프트 템플릿을 추가하거나 이름을 바꿀 때는 독일어 표시 메타데이터를 갱신하고 `pnpm --filter @open-design/web test`를 실행하세요. 독일어 표시 항목이 누락되면 `content.test.ts`가 실패합니다. daemon 오류, export 파일명, agent가 생성한 artifact 텍스트는 PR이 명시적으로 범위에 넣지 않는 한 알려진 한계로 둡니다.
새 로케일을 추가하는 단계별 안내(UI 사전, README, 언어 전환기, 지역별 용어)는 [`TRANSLATIONS.md`](TRANSLATIONS.md)를 참고하세요.
---
## 코드 스타일
포매팅에 까다롭게 굴지는 않지만(저장 시 Prettier로 충분합니다), 두 가지 규칙은 양보할 수 없습니다. 프롬프트 스택과 사용자에게 노출되는 API에 그대로 드러나기 때문입니다.
1. **JS/TS는 작은따옴표.** escape 때문에 보기 흉해지는 경우가 아니라면 문자열은 작은따옴표로 감쌉니다. 코드베이스는 이미 일관되니 맞춰주세요.
2. **주석은 영어로.** PR이 Deutsch나 中文로 번역하는 작업이더라도 코드 주석은 영어로 둡니다. grep으로 찾을 수 있는 참조를 한 벌로 유지하기 위해서입니다.
그 외에:
- **설명조 주석은 쓰지 마세요.** `// import the module`이나 `// loop through items` 같은 것 말입니다. 코드만 봐도 명백하다면 그 주석은 잡음입니다. 주석은 코드로 표현할 수 없는 의도나 제약에만 쓰세요.
- **`apps/web/src/`는 TypeScript를 씁니다.** daemon(`apps/daemon/`)은 타입이 중요한 곳에 JSDoc을 붙인 순수 ESM JavaScript입니다. 그대로 유지하세요.
- **새 최상위 의존성을 추가하지 마세요.** 추가한다면 얻는 것과 늘어나는 번들 크기를 PR 설명에 한 단락으로 적으세요. [`package.json`](package.json)의 의존성 목록은 일부러 작게 둡니다.
- **푸시 전에 `pnpm typecheck`를 실행하세요.** CI에서도 돌립니다. 실패하면 "고쳐주세요" 코멘트를 받게 됩니다.
---
## 커밋과 pull request
- **PR 하나에 관심사 하나.** skill 추가 + 파서 리팩터링 + 의존성 버전 업은 PR 세 개입니다.
- **제목은 명령형 + 범위.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`처럼 씁니다.
- **PR 템플릿을 사용하세요.** [`.github/pull_request_template.md`](.github/pull_request_template.md)의 모든 섹션(Why, What users will see, Surface area, Screenshots(UI인 경우), Bug fix verification(버그 수정인 경우), Validation)을 채우세요. 빈 섹션에는 "채워주세요" 답변이 달립니다.
- **본문에는 이유를 적으세요.** "이게 뭘 하는지"는 보통 diff만 봐도 알 수 있습니다. 정작 드러나지 않는 것은 "이게 왜 있어야 하는지"입니다.
- **issue가 있다면 연결하세요.** issue가 없고 PR이 사소하지 않다면 먼저 issue를 열어주세요. 시간을 쏟기 전에 그 변경을 원하는지 합의할 수 있습니다.
- **리뷰 중에는 squash하지 마세요.** fixup 커밋을 푸시하면 머지할 때 우리가 squash합니다.
- **공유 브랜치에 force-push하지 마세요.** 리뷰어가 요청한 경우는 예외입니다.
CLA는 요구하지 않습니다. Apache-2.0으로 충분하며, 당신의 기여도 같은 라이선스를 따릅니다.
---
## 버그 신고하기
다음 내용을 담아 issue를 열어주세요.
- 무엇을 실행했는지(정확한 `pnpm tools-dev ...` 호출).
- 어떤 agent CLI를 선택했는지(또는 BYOK 경로였는지).
- 이를 유발한 skill + design system 조합.
- 관련된 **daemon stderr 끝부분**. "artifact가 렌더링되지 않았다"는 신고 대부분은 `spawn ENOENT`나 CLI의 실제 오류가 보이면 30초 만에 원인이 잡힙니다.
- UI 문제라면 스크린샷.
프롬프트 스택 버그("agent가 보라색 그라데이션 hero를 내보냈는데, slop 블랙리스트가 그걸 막았어야 했다")라면 **assistant 메시지 전문**을 함께 넣어주세요. 위반이 모델 탓인지 프롬프트 탓인지 볼 수 있습니다.
---
## 질문하기
- 아키텍처 질문, 설계 질문, "이게 버그인지 오용인지" → [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가 기준선입니다. maintainer가 명시적으로 그 마이그레이션을 원하지 않는 한 Astro, Solid, Svelte 같은 다른 프레임워크로의 재작성은 받지 않습니다.
- **daemon을 serverless 함수로 대체.** daemon의 존재 이유는 실제 `cwd`를 소유하고 실제 CLI를 spawn하는 것입니다. SPA를 Vercel에 배포하는 것은 괜찮지만, daemon은 daemon으로 남습니다.
- **텔레메트리 / 분석 / phone-home 추가.** OD는 local-first입니다. 외부로 나가는 호출은 사용자가 명시적으로 설정한 provider로 향하는 것뿐입니다.
- **바이너리 번들링** 시 라이선스 파일과 저작자 표기를 옆에 두지 않는 경우.
아이디어가 적합한지 모르겠다면 코드를 작성하기 전에 discussion을 열어주세요.
---
## Maintainer 되기
꾸준히 기여해 왔고 Maintainer가 되는 길이 궁금하다면, 규칙은 **[`MAINTAINERS.ko.md`](MAINTAINERS.ko.md)**에 있습니다. 요약하면 이렇습니다.
- Maintainer는 issue를 리뷰하고 승인하고 닫을 수 있습니다. 머지 버튼은 Core Team이 쥐고 있지만, 당신의 승인은 머지에 필요한 승인으로 그대로 인정됩니다.
- 기준은 **머지된 PR 20건 이상**에 더해, 공개된 계정 품질 검증(봇 방지, sock-puppet 방지)과 기여 품질에 대한 Core Team의 판단입니다. 지원서 양식은 없습니다. Core Team이 내부적으로 후보를 올리고 직접 연락합니다.
- **할당량도, SLA도, 정해진 임기도 없습니다.** 물러나는 일은 쉽고 되돌릴 수 있습니다(Emeritus → 여유가 생기면 복귀).
- 모든 기준, 추천 절차, 물러나는 규칙, 초기 프로젝트 면제 조항은 [`MAINTAINERS.ko.md`](MAINTAINERS.ko.md)에 있습니다. 위 내용 중 관심 가는 게 있다면 그 문서를 읽어보세요.
요컨대 좋은 PR을 내고, 사려 깊게 리뷰하고, [Discussions][discussions]와 [Discord][discord]에서 어울리다 보면 나머지는 알아서 따라옵니다.
[discussions]: https://github.com/nexu-io/open-design/discussions
[discord]: https://discord.gg/qhbcCH8Am4
---
## 라이선스
기여하면, 당신의 기여가 이 저장소의 [Apache-2.0 License](LICENSE)를 따른다는 데 동의하는 것입니다. 단 [`design-templates/guizang-ppt/`](design-templates/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

191
MAINTAINERS.ko.md Normal file
View file

@ -0,0 +1,191 @@
# Maintainers
<p align="center"><a href="MAINTAINERS.md">English</a> · <a href="MAINTAINERS.pt-BR.md">Português (Brasil)</a> · <a href="MAINTAINERS.de.md">Deutsch</a> · <a href="MAINTAINERS.fr.md">Français</a> · <a href="MAINTAINERS.zh-CN.md">简体中文</a> · <a href="MAINTAINERS.ja-JP.md">日本語</a> · <b>한국어</b></p>
이 문서는 `nexu-io/open-design`의 Maintainer가 되고, 그 역할을 맡고, 물러나는 규칙을 정합니다. Core Team의 개별 명단은 내부에서 관리하므로 여기에 적지 않습니다. 공개적으로 중요한 건 모두가 따르는 규칙 그 자체입니다.
> **상태**: v1, 2026-05-11 초안 작성. [`CONTRIBUTING.ko.md`](CONTRIBUTING.ko.md#maintainer-되기)와 짝을 이루는 문서입니다. CONTRIBUTING.ko.md는 전체 규칙을 확인하려는 기여자를 이 문서로 안내합니다.
---
## 역할
| 역할 | 권한 |
|---|---|
| **Contributor** | 머지된 PR이 1건 이상인 모든 사람. 특별한 권한은 없습니다. |
| **External Maintainer** | 아래 규칙에 따라 승격된 커뮤니티 기여자. 리뷰, 승인, issue 닫기/다시 열기, issue 셀프 배정을 할 수 있습니다. **머지 버튼은 누를 수 없습니다** — 이 권한은 Core Team이 갖습니다. |
| **Core Team** | Open Design의 내부 팀. 저장소 전체 쓰기 권한을 갖고, 거버넌스 결정의 최종 권한을 가집니다. 명단은 내부에서 관리합니다. |
별도 언급이 없으면 이 문서의 나머지는 모두 **External Maintainer**에 관한 내용입니다.
---
## Maintainer만 할 수 있고 Contributor는 할 수 없는 것
| 동작 | Contributor | Maintainer |
|---|:---:|:---:|
| PR 승인 | ⚠️ 단순 코멘트로 처리되며, 머지에 필요한 승인으로 **인정되지 않음** | ✓ 머지에 필요한 승인으로 인정됨 |
| issue 닫기 / 다시 열기 | 본인이 연 issue만 | ✓ 모든 issue |
| 열려 있고 배정되지 않은 issue 셀프 배정(P0 우선) | ✗ | ✓ |
### 머지 조건
PR은 누가 작성했든 다음 **세 가지를 모두** 만족해야 합니다.
1. 코드 충돌이 없을 것.
2. CI가 완전히 통과(green)할 것.
3. Maintainer 또는 Core Team 멤버의 승인이 최소 1건 있을 것.
대부분의 PR은 Maintainer의 승인을 거쳐 머지됩니다. Maintainer의 신뢰가 프로젝트의 일상에 가장 직접적으로 드러나는 방식입니다.
---
## Maintainer가 되는 방법
진입 조건은 **세 가지**이며, 모두 충족해야 합니다.
### 1. 기여량
- `nexu-io/open-design`에 머지된 PR **20건 이상**.
이 수치는 자동 통과 기준이 아니라 최소선입니다. PR 20건을 넘기면 검토 대상에 오를 뿐, 역할이 보장되지는 않습니다.
### 2. 계정 신뢰도(다중 계정·봇 방지)
후보의 GitHub 프로필을 일곱 가지 항목으로 점검합니다. **7개 중 5개 이상의 통과 기준을 만족하고, 거부 기준은 하나도 건드리지 않아야 합니다.**
| # | 항목 | 통과 기준 | 거부 기준 |
|---|---|---|---|
| 1 | GitHub 계정 나이 | 1년 이상 | 90일 미만 |
| 2 | 공개 저장소 | 3개 이상 | 0개 |
| 3 | 팔로워 | 10명 이상 | 3명 미만 |
| 4 | 팔로워 / 팔로잉 비율 | 0.30 초과 | 0.05 미만(전형적인 팔로우 농장 패턴) |
| 5 | 프로필 완성도 | 커스텀 아바타 **그리고** bio / company / blog / twitter 중 하나 이상 | 기본 아바타 **그리고** bio·company·blog가 모두 비어 있음 |
| 6 | 다른 프로젝트 활동 | **다른** 공개 저장소에 머지된 PR이 하나 이상 있거나, issue/star 활동이 꾸준함 | 머지된 PR이 이 저장소에만 있음 |
| 7 | 계정 상태 | GitHub 플랫폼 제재 이력 없음(스팸/차단/복구) | 위 항목 중 하나라도 해당 |
#### 초기 프로젝트 면제(저장소가 6개월이 되면 자동 만료)
`nexu-io/open-design`이 최초 커밋으로부터 6개월 미만인 동안에는, 다음 조건에서 **다른 프로젝트 활동**(#6) 거부 기준을 Core Team 합의로 면제할 수 있습니다.
- 항목 1, 2, 3, 5가 통과 기준을 명확히 넘을 것, **그리고**
- 이 저장소에서의 PR 품질이 Core Team의 직접 리뷰로 높게 평가될 것.
면제할 때는 후보 이름과 날짜를 Core Team 내부 기록에 함께 남깁니다. 저장소가 6개월에 도달하면 이 면제 조항은 더 이상 적용되지 않습니다.
### 3. 기여 품질(Core Team 판단)
정성적인 평가이며 공식에 따르지 않습니다. Core Team은 다음을 봅니다.
- 머지된 PR의 **코드 품질**(정확성, 작업 범위 준수, 저장소 경계 존중).
- 다른 사람의 PR에 남긴 리뷰 코멘트의 **리뷰 품질**.
- **커뮤니티 참여** — Discussions, issue 분류, Discord 활동.
- **협업 신호** — 피드백에 대한 반응, 기꺼이 수정하려는 자세.
앞의 두 조건을 통과하면 후보군에 들어갑니다. 이 세 번째 기준을 넘어서야 지명을 받습니다.
### 선정 절차
1. Core Team 멤버가 내부에서 후보를 제안합니다.
2. Core Team이 합의에 이릅니다.
3. Core Team 멤버가 비공개로 연락해 후보의 의사를 확인합니다.
4. 온보딩.
5. 공개 발표.
지명 PR도, 공개 투표도, 정해진 임기도 없습니다. 이는 의도적으로 **K8s/Apache의 승인자 투표 모델을 뒤집은** 방식입니다. 프로젝트 초기에는 가벼운 Core Team 합의가 더 빠르게 움직이면서도 같은 수준의 결과를 냅니다. Maintainer 인원이 External Maintainer 다섯 명을 넘어 늘어나면 이 부분을 다시 검토합니다.
---
## 책임과 기대
**엄격한 할당량은 없습니다.** 주간 PR 리뷰 횟수도, 최소 issue 분류 비율도, 응답 시간 SLA도 없습니다. Maintainer는 신뢰의 인정이지, 보수 없는 업무가 아닙니다.
다만 원칙적으로 부탁드리는 것은 다음과 같습니다.
- 맥락을 아는 PR은 승인하고, 모르는 PR은 판단을 보류하세요.
- 머지 조건(§"머지 조건")을 지키세요. 당신의 승인은 형식적인 도장이 아니라 실질적인 신호입니다.
- 오랜 기간 자리를 비울 때는 `#maintainers`에 알려 주세요.
- `#maintainers`에서 공유되는 미공개 로드맵은 기밀로 다뤄 주세요.
Core Team이 나쁜 행태(형식적 승인, 악의적 issue 닫기, 미공개 로드맵 유출 등)의 패턴을 확인하면, §"사유 기반 사임"에 따라 권한을 회수합니다.
---
## Maintainer 전용 접근 권한
위에 적은 저장소 권한 외에도, Maintainer는 일반 커뮤니티가 받지 못하는 몇 가지를 받습니다.
- **Discord `#maintainers` 채널** — Core Team과 공유하는 비공개 작업 공간. 디자인 미리보기, RFC 초안, 그리고 아직 공개되지 않은 로드맵에 대한 내부 조율에 씁니다.
- **기밀 로드맵** — 아직 발표되지 않은 작업을 미리 볼 수 있습니다. Maintainer는 Core Team 멤버가 공개로 발표하기 전까지 그 내용을 기밀로 다루기로 합의합니다.
- **Core Team과의 직통 라인**`#maintainers` 메시지는 공개 Discussions보다 더 빠르고 충실한 답을 받습니다. Core Team은 아키텍처와 로드맵 결정에 Maintainer의 의견을 진심으로 구합니다.
- **Maintainer 배지** — GitHub 프로필과 MAINTAINERS 관련 저장소 화면에 표시되는 공개 신뢰 표식(GitHub 배지 기능이 준비되는 대로 적용).
- **승격 시 공개 인정** — 합류할 때 Twitter, GitHub Discussions, Discord에서 발표합니다.
---
## 사임
Maintainer는 종신직이 아닙니다. 세 가지 퇴장 경로가 있습니다.
### 자발적 사임(스스로)
- Maintainer가 Core Team에 메시지를 보내거나 `#maintainers`에 글을 올립니다.
- 권한은 24시간 안에 회수됩니다.
- 해당 Maintainer는 **Emeritus** 상태로 전환됩니다.
- 공개적인 사유는 필요 없습니다.
### 비활동 전환
다음 중 **하나라도** 해당하면 비활동 전환 대상으로 검토합니다.
- 활동 신호(머지된 PR, 리뷰 코멘트, issue 분류, 비중 있는 Discussion이나 Discord 참여)가 90일 연속 없거나, **또는**
- @ 멘션(PR 리뷰 요청, issue 배정)에 60일 연속 응답이 없음.
절차:
1. Core Team이 `#maintainers`에서 해당 Maintainer를 비공개로 @ 멘션하고, **14일의 응답 기간**을 줍니다.
2. 14일 안에 의미 있는 응답이 없으면 Emeritus로 전환되고 권한이 회수됩니다.
3. GitHub Discussions에 짧고 따뜻한 공개 안내를 올립니다: "그동안의 기여에 감사합니다. Emeritus로 전환되었으며, 언제든 다시 돌아오셔도 좋습니다."
4. 복귀는 쉽습니다 — 아래 "Emeritus"를 참고하세요.
### 사유 기반 사임
다음에 의해 발동됩니다.
- 반복되는 나쁜 행태(예: 함량 미달 PR에 대한 형식적 승인, 악의적 issue 닫기, 권한 남용).
- 프로젝트 [행동 강령][coc] 위반.
- 보안 등급의 사고(계정 탈취를 즉시 보고하지 않음, 미공개 로드맵 고의 유출 등).
절차:
1. Core Team 멤버는 누구나 논의를 시작할 수 있습니다.
2. 조치를 취하기 전에 **Core Team 멤버 3명 이상**이 동의해야 합니다(Core Team 전원 합의까지는 필요하지 않습니다).
3. 결정 후 24시간 안에: 권한 회수, `#maintainers`에서 제외, 모든 Maintainer 명단에서 제거(Emeritus로 전환되지 **않습니다**).
4. 당사자에게 결정과 사유를 알리며, 한 차례 이의를 제기할 수 있습니다.
원칙은 **Maintainer를 가급적 유지하는 쪽**입니다. 작은 실수 한 번은 강제 사임의 사유가 되지 않습니다. 사유 기반 경로는 반복되는 패턴이나 심각한 일회성 사고에만 적용합니다.
[coc]: https://www.contributor-covenant.org/
---
## Emeritus
자발적으로 사임하거나 비활동으로 전환된 Maintainer는 **Emeritus**가 됩니다. Emeritus 상태는 다음과 같습니다.
- 쓰기/승인/닫기 권한을 회수합니다.
- (내부) 명단의 Emeritus 항목에 이름을 남겨 공로를 기립니다.
- Discord `#maintainers` 접근 권한을 유지합니다(읽기든 글쓰기든 Maintainer가 선택).
- 이어지는 책임은 없습니다.
### Emeritus에서 복귀
가장 간단한 복귀 경로: 최근 30일 안에 머지된 PR 3건. 그러면 Core Team이 권한을 복원합니다. 다시 지명받을 필요는 없습니다.
Emeritus의 취지는 인생에는 늘 일이 생긴다는 점을 인정하는 것입니다. 안식년, 이직, 육아 같은 일을 양쪽 모두에게 아무런 부담이나 사회적 비용 없이 받아들이려는 것입니다.
---
## 이 문서의 변경
이 문서의 규칙은 Core Team 합의로 개정할 수 있습니다. 중대한 변경(진입 조건, 사임 기준)은 활동 중인 후보에게 적용되기 전에 GitHub Discussions에 발표합니다. 편집상의 명확화는 곧바로 반영할 수 있습니다.

391
QUICKSTART.ko.md Normal file
View file

@ -0,0 +1,391 @@
# 빠른 시작
<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> · <a href="QUICKSTART.zh-CN.md">简体中文</a> · <a href="QUICKSTART.zh-TW.md">繁體中文</a> · <b>한국어</b></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 네이티브도 지원합니다. 자주 겪는 설치 문제는 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)를 참고하세요.
- **선택: 로컬 에이전트 CLI:** Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, Qoder CLI, GitHub Copilot CLI 등. 설치된 CLI가 없으면 Settings에서 BYOK API 모드를 쓰면 됩니다.
### 로컬 에이전트 CLI와 PATH
daemon은 **`PATH`**(여기에 더해 자주 쓰이는 사용자 툴체인 디렉터리)를 스캔합니다. **`npm install -g`**나 **Homebrew**로 CLI를 설치했는데도 Open Design이 *not installed*로 표시한다면, GUI가 최소한의 `PATH`로 시작하면서 전역 npm이나 Homebrew의 `bin` 디렉터리를 포함하지 못한 경우입니다(앱이 전체 로그인 셸에서 실행되지 않은 macOS에서 흔히 발생). daemon을 실행하는 프로세스의 `PATH`에 실행 파일 디렉터리가 들어 있는지 확인한 뒤, **Settings → Execution mode**에서 **Rescan**을 누르세요.
[`nvm`](https://github.com/nvm-sh/nvm) / [`fnm`](https://github.com/Schniz/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가 출력되어야 합니다
```
## Docker 설정
Node.js나 pnpm을 로컬에 설치하지 않고도, 완전히 컨테이너화된 환경에서 Open Design을 실행할 수 있습니다.
### 요구사항
* Docker Desktop
* Docker Compose v2
Docker가 제대로 설치됐는지 확인하세요.
```bash
docker compose version
```
---
## Open Design 시작하기
리포지토리 루트에서 진행합니다.
1. deploy 디렉터리로 이동한 뒤 환경 변수 템플릿을 복사합니다.
```bash
cd deploy
cp .env.example .env
```
2. 안전한 토큰을 생성합니다.
```bash
openssl rand -hex 32
```
3. 에디터에서 `.env`를 열고 `OD_API_TOKEN=`을 찾아, 방금 생성한 토큰을 붙여 넣습니다.
이제 서비스를 시작합니다.
```bash
docker compose up -d
```
브라우저에서 앱을 엽니다.
```text
http://localhost:7456
```
처음 시작할 때는 Docker가 최신 이미지를 받아오느라 몇 초 걸릴 수 있습니다.
---
## 자주 쓰는 Docker 명령어
### 로그 보기
```bash
docker compose logs -f
```
### 컨테이너 재시작
```bash
docker compose restart
```
### 컨테이너 중지
```bash
docker compose down
```
### 최신 이미지 받아오기
```bash
docker compose pull
docker compose up -d
```
### 로컬 앱 데이터 전부 삭제
```bash
docker compose down -v
```
---
## 환경 설정
기본 설정을 덮어쓰려면 `deploy/.env` 파일을 만드세요. 제공된 예시에서 시작하면 됩니다.
```bash
cp deploy/.env.example deploy/.env
```
`deploy/.env`를 편집해 직접 만든 토큰을 넣고, 나머지 값도 필요에 맞게 조정합니다.
```env
# 호스트에 노출할 포트
OPEN_DESIGN_PORT=7456
# 컨테이너 메모리 제한
OPEN_DESIGN_MEM_LIMIT=384m
# 허용할 CORS origin
OPEN_DESIGN_ALLOWED_ORIGINS=https://yourdomain.com
# Docker 이미지 태그
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest
# daemon 보안에 필요한 API 토큰
# 생성 방법: openssl rand -hex 32
OD_API_TOKEN=
```
---
## 영구 저장소
Open Design은 프로젝트와 SQLite 데이터를 Docker 볼륨 안에 저장합니다.
```text
open_design_data
```
이 볼륨은 다음 경로에 마운트됩니다.
```text
/app/.od
```
데이터는 컨테이너를 재시작하거나 이미지를 업데이트해도 그대로 유지됩니다.
볼륨을 확인하려면:
```bash
docker volume inspect open-design_open_design_data
```
---
## 참고
* Docker 모드는 로컬에 Node.js나 pnpm을 설치하고 싶지 않은 기여자에게 적합합니다.
* 컨테이너는 프로덕션 daemon 빌드를 `7456` 포트에 직접 노출합니다.
* 개발 워크플로우와 더 깊은 로컬 설정은 이 빠른 시작 가이드의 나머지 부분을 참고하세요.
---
## 한 번에 실행하기 (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을 백그라운드로 시작합니다
```
처음 로드하면 앱은 설치된 코드 에이전트 CLI(Claude Code / Codex / Devin for Terminal / Gemini / OpenCode / Cursor Agent / Qwen / Qoder CLI)를 감지해 자동으로 선택하고, 기본값으로 `web-prototype` skill과 `Neutral Modern` 디자인 시스템을 씁니다. 프롬프트를 입력하고 **Send**를 누르세요. 에이전트가 왼쪽 패널에 스트리밍되고, `<artifact>` 태그가 파싱되면서 HTML이 오른쪽에 실시간으로 렌더링됩니다. 작업이 끝나면 **Save to disk**를 눌러 artifact를 `./.od/artifacts/<timestamp>-<slug>/index.html`에 저장하세요.
**Design system** 드롭다운에는 71개의 내장 시스템이 들어 있습니다. 직접 작성한 스타터 2개(Neutral Modern, Warm Editorial)와 [`awesome-design-md`](https://github.com/VoltAgent/awesome-design-md)에서 가져온 69개의 제품 시스템으로, 카테고리별(AI & LLM, Developer Tools, Productivity, Backend, Design Tools, Fintech, E-Commerce, Media, Automotive)로 묶여 있습니다. 하나를 고르면 모든 프로토타입이 그 브랜드의 미감으로 입혀집니다. 여기에 [`awesome-design-skills`](https://github.com/bergside/awesome-design-skills)에서 가져온 57개의 디자인 skill도 함께 제공됩니다.
**Skill** 드롭다운은 모드별(Prototype / Deck / Template / Design system)로 묶이며, 각 모드의 기본 skill에는 `· default` 접미사가 붙습니다. 함께 제공되는 skill은 다음과 같습니다.
- **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에는 "Skill root (absolute)" 머리말이 자동으로 붙어, 에이전트가 `assets/template.html`이나 `references/*.md`를 CWD가 아니라 실제 디스크 경로 기준으로 해석할 수 있습니다.
skill과 디자인 시스템을 짝지으면, 프롬프트 하나로 선택한 시각 언어에 맞는 프로토타입이나 deck이 레이아웃에 맞게 생성됩니다.
## 그 밖의 스크립트
```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 # 상태 + 최근 로그 + 일반 진단
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 # 워크스페이스 타입 체크
```
로컬 라이프사이클의 진입점은 `pnpm tools-dev` 하나뿐입니다. 삭제된 옛 루트 alias(`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 프로세스와 통신할 수 있습니다.
## 미디어 생성 / 에이전트 dispatcher 점검
이미지, 비디오, 오디오, HyperFrames skill은 daemon이 에이전트를 spawn할 때 주입하는 환경 변수를 통해 로컬 `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이 spawn한 에이전트라면 다음과 같은 값이 보여야 합니다.
```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:7457`처럼 실제 daemon 포트여야 하며, `http://127.0.0.1:0`이어서는 안 됩니다. `:0` 값은 "빈 포트를 골라라"라는 내부 실행 힌트일 뿐이라, 에이전트 세션으로 새어 나가면 안 됩니다.
daemon 단독 프로덕션 모드에서는 daemon이 정적 Next.js export를 `http://localhost:7456`에서 직접 서빙하므로, reverse proxy가 끼어들지 않습니다.
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;
}
```
## 두 가지 실행 모드
| 모드 | 선택값 | 요청이 흐르는 경로 |
|---|---|---|
| **Local CLI** (daemon이 에이전트를 감지하면 기본값) | "Local CLI" | 프론트엔드 → daemon `/api/chat``spawn(<agent>, ...)` → stdout → SSE → artifact 파서 → 미리보기 |
| **API 모드** (대체 수단 / CLI 없음) | "Anthropic API" / "OpenAI API" / "Azure OpenAI" / "Google Gemini" | 프론트엔드 → daemon `/api/proxy/{provider}/stream` → 프로바이더 SSE를 `delta/end/error`로 정규화 → artifact 파서 → 미리보기 |
두 모드 모두 **같은** `<artifact>` 파서와 **같은** 샌드박스 iframe으로 들어갑니다. 다른 점은 전송 방식과 시스템 프롬프트 전달 방식뿐입니다(로컬 CLI는 별도의 시스템 채널이 없어서, 조합된 프롬프트를 사용자 메시지 안에 접어 넣습니다).
## 프롬프트 구성
전송할 때마다 앱은 세 개의 레이어로 시스템 프롬프트를 만들어 프로바이더에 보냅니다.
```
BASE_SYSTEM_PROMPT (출력 규약: <artifact>로 감싸고, 코드 펜스는 쓰지 않는다)
+ 활성 디자인 시스템 본문 (DESIGN.md — 팔레트/타입/레이아웃)
+ 활성 skill 본문 (SKILL.md — 워크플로우와 출력 규칙)
```
상단 바에서 skill이나 디자인 시스템을 바꾸면, 다음 전송부터 새 조합이 적용됩니다. 본문은 세션별로 메모리에 캐시되므로, 한 번 선택할 때 daemon에 한 번만 요청합니다.
## 파일 맵
```
open-design/
├── apps/
│ ├── daemon/ # Node/Express — 로컬 에이전트 spawn + API 서빙
│ │ └── src/
│ │ ├── cli.ts # `od` bin 진입점
│ │ ├── server.ts # /api/* + 정적 서빙
│ │ ├── agents.ts # claude/codex/devin/gemini/opencode/cursor-agent/qwen/qoder/copilot용 PATH 스캐너
│ │ ├── skills.ts # SKILL.md 로더 (frontmatter 파서)
│ │ └── 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 # 모드 / skill / DS 선택기 + 전송 조율
│ │ ├── providers/ # daemon + BYOK API 전송 계층
│ │ ├── prompts/ # system, discovery, directions, deck framework
│ │ ├── artifacts/ # 스트리밍 <artifact> 파서 + manifest
│ │ ├── runtime/ # iframe srcdoc, markdown, export 헬퍼
│ │ └── state/ # localStorage + daemon 기반 프로젝트 상태
│ ├── sidecar/ # tools-dev web sidecar 래퍼
│ └── next.config.ts # tools-dev rewrite + prod apps/web/out export 설정
│ └── desktop/ # tools-dev가 실행/점검하는 Electron 런타임
├── packages/
│ ├── contracts/ # 공유 web/daemon 앱 contract
│ ├── sidecar-proto/ # Open Design sidecar 프로토콜 contract
│ ├── sidecar/ # 범용 sidecar 런타임 프리미티브
│ └── platform/ # 범용 process/platform 프리미티브
├── tools/dev/ # `pnpm tools-dev` 라이프사이클 및 inspect CLI
├── e2e/ # Playwright UI + 외부 통합/Vitest 하네스
├── skills/ # SKILL.md — 어떤 Claude Code skill 리포지토리에서든 그대로 넣을 수 있음
│ ├── web-prototype/ # 범용 단일 화면 프로토타입 (prototype 모드 기본값)
│ ├── saas-landing/ # 마케팅 페이지 (hero / features / pricing / CTA)
│ ├── dashboard/ # 관리자 / 분석 대시보드
│ ├── pricing-page/ # 단독 가격 + 비교
│ ├── docs-page/ # 3단 문서 레이아웃
│ ├── 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개 섹션 스키마 (awesome-claude-design)
│ ├── default/ # Neutral Modern (스타터)
│ ├── warm-editorial/ # Warm Editorial (스타터)
│ ├── README.md # 카탈로그 개요
│ └── …129개 시스템 # 스타터 2개 · 제품 시스템 70개 · 디자인 skill 57개
├── scripts/sync-design-systems.ts # 업스트림 getdesign tarball에서 다시 import
├── docs/ # 제품 비전 + 스펙
├── .od/ # 런타임 데이터 (gitignore 처리, 자동 생성)
│ ├── app.sqlite # 프로젝트 / 대화 / 메시지 / 탭
│ ├── artifacts/ # 일회성 "Save to disk" 렌더링
│ └── projects/<id>/ # 프로젝트별 작업 디렉터리 + 에이전트 cwd
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # 루트 품질 스크립트 + `od` bin
```
## 문제 해결
- **Node.js 버전을 바꾼 뒤 `better-sqlite3`가 로드되지 않거나 ABI가 맞지 않을 때**`pnpm install``postinstall`을 자동으로 다시 돌려 현재 Node.js에 맞게 네이티브 애드온을 리빌드합니다. 직접 리빌드하거나 수정을 확인하려면 `pnpm --filter @open-design/daemon rebuild better-sqlite3`를 실행한 뒤 `pnpm --filter @open-design/daemon exec node -e "require('better-sqlite3')"`를 돌리세요. 빌드 도구 `python3`, `make`, `g++`(또는 `clang++`)가 필요합니다. `.npmrc``ignore-scripts=true`가 있다면, `pnpm install``node scripts/postinstall.mjs`를 실행하세요.
- **"no agents found on PATH"** — `claude`, `codex`, `devin`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `qodercli`, `copilot` 중 하나를 설치하세요. 아니면 Settings에서 API 모드로 바꾸고 프로바이더 키를 붙여 넣으세요.
- **Claude Code가 코드 1로 종료될 때** — Open Design이 `claude`를 시작하긴 했지만, spawn된 비대화형 실행이 응답을 내기 전에 실패한 경우입니다. Open Design을 시작하는 셸이나 앱 환경과 같은 곳에서 다음을 확인하세요.
```bash
claude --version
claude auth status --text
printf 'hello' | claude -p --output-format stream-json --verbose --permission-mode bypassPermissions
```
스모크 테스트가 커스텀 엔드포인트 없이 `401`, `apiKeySource: "none"`, 또는 다른 인증 오류를 낸다면, `claude`를 실행해 `/login`한 뒤 Claude를 종료하고 Open Design을 다시 시도하세요. Claude 프로필을 여러 개 쓴다면 **Settings -> Execution mode -> Claude Code config directory**를 `~/.claude-2` 같은 프로필 경로로 지정하세요. `ANTHROPIC_BASE_URL`이나 프록시가 설정돼 있다면 엔드포인트 URL, 프록시 자격 증명, 엔드포인트 인증 환경, 모델 접근 권한을 확인하세요. 표준 Claude Code 인증으로 다시 시도하려는 게 아니라면 커스텀 엔드포인트는 그대로 두세요. Windows에서는 네이티브 PowerShell과 WSL이 서로 다른 Claude 설치본과 자격 증명 저장소를 쓰므로, Open Design이 쓰는 것과 같은 환경에서 다시 인증하고, `/login`으로도 네이티브 Windows 자격 증명이 복구되지 않으면 Windows 자격 증명 관리자를 확인하세요.
- **`/api/chat`에서 daemon 500** — daemon 터미널의 stderr 끝부분을 확인하세요. 대개 CLI가 자신의 인자를 거부한 경우입니다. CLI마다 받는 argv 형태가 다릅니다. 손봐야 한다면 `apps/daemon/src/agents.ts``buildArgs`를 보세요.
- **미디어 생성이 `OD_BIN` 누락 또는 daemon URL이 `:0`이라고 할 때** — 위의 미디어 dispatcher 점검을 실행하세요. 옛 CLI 세션을 이어 가지 말고, Open Design 앱에서 프로젝트를 다시 열어 daemon이 새 `OD_*` 변수를 주입하게 하세요.
- **Codex가 플러그인 컨텍스트를 너무 많이 로드할 때**`OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev`로 Open Design을 시작하면, daemon이 spawn하는 Codex 프로세스가 `--disable plugins`로 실행됩니다.
- **artifact가 끝내 렌더링되지 않을 때** — 모델이 `<artifact>`로 감싸지 않은 텍스트를 만든 경우입니다. 시스템 프롬프트가 제대로 전달되는지 확인하고(daemon 로그 확인), 더 강력한 모델이나 더 엄격한 skill로 바꿔 보세요.
## 비전과 다시 잇기
이 빠른 시작은 [`docs/`](docs/)에 담긴 스펙을 실제로 돌릴 수 있게 만든 씨앗입니다. 스펙은 이것이 어디로 자라날지 설명합니다([`docs/roadmap.md`](docs/roadmap.md) 참고). 핵심만 짚으면 다음과 같습니다.
- `docs/architecture.md`는 출시된 스택을 설명합니다. 앞단의 Next.js 16 App Router, 그 뒤의 로컬 daemon, 그리고 dev에서 `apps/web/next.config.ts`의 rewrite가 브라우저를 같은 `/api` 표면과 계속 통신하게 유지하는 구조입니다.
- `docs/skills-protocol.md``od:` frontmatter 전체(타입 입력, 슬라이더, 기능 게이팅)를 설명합니다. 이 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`는 네 가지 모드를 나열합니다. prototype / deck / template / design-system. 우리는 앞의 두 가지를 위한 skill을 제공하며, 선택기는 이미 `mode`로 필터링합니다.

View file

@ -800,7 +800,7 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
</a>
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
@ -817,9 +817,9 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -726,7 +726,7 @@ Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehm
Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
</a>
Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt.
@ -743,9 +743,9 @@ Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflo
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -787,7 +787,7 @@ Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [
Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contribuidores de Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contribuidores de Open Design" />
</a>
Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada.
@ -804,9 +804,9 @@ El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<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-30" />
</picture>
</a>

View file

@ -733,7 +733,7 @@ Guide complet, critères de merge, style de code et refus fréquents → [`CONTR
Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contributeurs Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contributeurs Open Design" />
</a>
Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point dentrée.
@ -750,9 +750,9 @@ Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.ym
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<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-30" />
</picture>
</a>

View file

@ -723,7 +723,7 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design コントリビューター" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design コントリビューター" />
</a>
初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。
@ -740,9 +740,9 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -33,7 +33,7 @@
<a href="#디자인-시스템"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-149-orange?style=flat-square" /></a>
<a href="#내장-skills"><img alt="Skills" src="https://img.shields.io/badge/skills-131-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>
<a href="QUICKSTART.ko.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> · <a href="README.tr.md">Türkçe</a></p>
@ -332,7 +332,7 @@ pnpm tools-dev run web
Windows 사용자는 네이티브 설치 경로와 작은 더블 클릭 런처에 대해서는 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)를 참고하세요.
데스크톱/백그라운드 시작, 고정 포트 재시작, 미디어 생성 dispatcher 확인(`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`)은 [`QUICKSTART.md`](QUICKSTART.md)를 참고하세요.
데스크톱/백그라운드 시작, 고정 포트 재시작, 미디어 생성 dispatcher 확인(`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`)은 [`QUICKSTART.ko.md`](QUICKSTART.ko.md)를 참고하세요.
첫 번째 로드 시:
@ -360,7 +360,7 @@ Daemon은 저장소 루트에 하나의 숨겨진 폴더를 소유합니다. 그
| 초기 상태로 재설정 | `pnpm tools-dev stop`, `rm -rf .od`, `pnpm tools-dev run web` 재실행 |
| 다른 위치로 이동 | 아직 지원되지 않음 — 경로가 저장소 상대 경로로 하드코딩됨 |
전체 파일 맵, 스크립트, 트러블슈팅 → [`QUICKSTART.md`](QUICKSTART.md).
전체 파일 맵, 스크립트, 트러블슈팅 → [`QUICKSTART.ko.md`](QUICKSTART.ko.md).
## 저장소 구조
@ -719,14 +719,14 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
- **디자인 시스템 추가** — 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)).
전체 설명, 병합 기준, 코드 스타일, 받지 않는 것 → [`CONTRIBUTING.ko.md`](CONTRIBUTING.ko.md) ([English](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-29" alt="Open Design 컨트리뷰터" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 컨트리뷰터" />
</a>
첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다.
@ -743,9 +743,9 @@ Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -1040,7 +1040,7 @@ Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CO
Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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.
@ -1057,9 +1057,9 @@ The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -730,7 +730,7 @@ Walkthrough completo, barra para mergear, estilo de código e o que não aceitam
Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contribuidoras e contribuidores do Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contribuidoras e contribuidores do Open Design" />
</a>
Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada.
@ -747,9 +747,9 @@ O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.git
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<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-30" />
</picture>
</a>

View file

@ -729,7 +729,7 @@ Issues, PR, новые skills и новые design systems приветству
Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contributors Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contributors Open Design" />
</a>
Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа.
@ -746,9 +746,9 @@ SVG выше ежедневно пересобирается workflow [`.github/
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -887,7 +887,7 @@ Tam walkthrough, merge çıtası, code style ve kabul etmediklerimiz → [`CONTR
Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
</a>
İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır.
@ -904,9 +904,9 @@ Yukarıdaki SVG [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -729,7 +729,7 @@ OD не зупиняється на коді. Та сама поверхня ч
Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Контриб'ютори Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Контриб'ютори Open Design" />
</a>
Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу.
@ -746,9 +746,9 @@ SVG вище перегенерується щодня [`.github/workflows/metri
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -722,7 +722,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 贡献者" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 贡献者" />
</a>
第一次提 PR欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。
@ -739,9 +739,9 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -1006,7 +1006,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 貢獻者" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" 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) 標籤起步。
@ -1023,9 +1023,9 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<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-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -118,7 +118,7 @@ Open Design currently supports **19 languages** across different surfaces:
| Bahasa Indonesia | `id` | — | ✅ | — | active |
| Italiano | `it` | — | ✅ | — | active |
| 日本語 (Japanese) | `ja` | ✅ | ✅ | ✅ | active |
| 한국어 (Korean) | `ko` | ✅ | ✅ | | active |
| 한국어 (Korean) | `ko` | ✅ | ✅ | | active |
| Polski (Polish) | `pl` | — | ✅ | — | active |
| Português (Brasil) | `pt-BR` | ✅ | ✅ | ✅ | active |
| Русский (Russian) | `ru` | ✅ | ✅ | — | active |

View file

@ -1,3 +1,5 @@
import path from 'node:path';
import { redactSecrets } from './redact.js';
export interface ClaudeCliDiagnosticInput {
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
stderrTail?: string | null;
stdoutTail?: string | null;
env?: Record<string, unknown> | null;
resolvedBin?: string | null;
}
export interface ClaudeCliDiagnostic {
@ -51,6 +54,15 @@ function withContext(
};
}
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
const base = path
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude' ? 'openclaude' : 'claude';
}
export function diagnoseClaudeCliFailure(
input: ClaudeCliDiagnosticInput,
): ClaudeCliDiagnostic | null {
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
const normalized = text.toLowerCase();
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
const runtime = selectedClaudeCompatibleRuntime(input);
const isOpenClaude = runtime === 'openclaude';
const customEndpointConnectionFailure =
hasCustomBaseUrl &&
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
);
}
if (authFailure) {
if (isOpenClaude) {
return withContext(
'OpenClaude could not authenticate with its configured endpoint.',
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const configHint = hasConfigDir
? 'The configured Claude config directory may contain stale or expired auth state.'
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
}
if (!text.trim() && input.exitCode === 1) {
if (isOpenClaude) {
return withContext(
'OpenClaude exited before producing diagnostics.',
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const message = hasConfigDir
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
: 'Claude Code exited before producing diagnostics.';

View file

@ -1862,6 +1862,8 @@ async function testAgentConnectionInternal(
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: executableResolution.selectedPath },
);
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
@ -2026,6 +2028,7 @@ async function testAgentConnectionInternal(
stderrTail,
stdoutTail: rawStdoutTail || buffered,
env,
resolvedBin: executableResolution.selectedPath,
});
if (claudeDiagnostic) {
console.warn(

View file

@ -6,6 +6,7 @@ import {
inlineRelativeAssets,
type InlineAssetReader,
} from './inline-assets.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
@ -28,6 +29,11 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
const { insertConversation } = ctx.conversations;
const { setTabs } = ctx.projectFiles;
const { validateProjectDesignSystemId } = ctx.validation;
const rejectSandboxFolderImport = () =>
isSandboxModeEnabled(process.env)
? 'folder imports are disabled when OD_SANDBOX_MODE is enabled'
: null;
app.post(
'/api/import/claude-design',
importUpload.single('file'),
@ -107,6 +113,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
const sandboxReason = rejectSandboxFolderImport();
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
let trustedPickerImport = false;
if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret();
@ -204,6 +214,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
const sandboxReason = rejectSandboxFolderImport();
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
let trustedPickerImport = false;
if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret();

View file

@ -41,6 +41,7 @@ import path from 'node:path';
import { MEDIA_PROVIDERS } from './media-models.js';
import { expandHomePrefix } from './home-expansion.js';
import { resolveXAIBearer } from './xai-credentials.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
@ -291,6 +292,7 @@ function apiKeyFromCodexAuth(data: unknown): string {
}
async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> {
if (isSandboxModeEnabled(process.env)) return null;
const home = os.homedir();
const codexAuth = await readJsonIfPresent(
path.join(home, '.codex', 'auth.json'),
@ -318,6 +320,8 @@ async function resolveXAIOAuthCredential(
};
}
if (isSandboxModeEnabled(process.env)) return null;
// 2. Borrow the xAI OAuth token Hermes wrote to ~/.hermes/auth.json
// when the user ran `hermes auth add xai-oauth`. A user who has already authorized
// Hermes doesn't have to run a second OAuth dance inside OD.

View file

@ -865,7 +865,13 @@ async function callLocalCli(provider, system, user, options) {
}
const env = applyAgentLaunchEnv(
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
spawnEnvForAgent(
def.id,
{ ...process.env, ...(def.env || {}) },
configuredAgentEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);
const invocation = createCommandInvocation({

View file

@ -0,0 +1,23 @@
import path from 'node:path';
export function resolveProjectRoot(moduleDir: string): string {
const base = path.basename(moduleDir);
const daemonDir =
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
return path.resolve(daemonDir, '../..');
}
export function resolveProjectRootFromNestedModule(moduleDir: string): string {
let current = path.resolve(moduleDir);
while (true) {
const base = path.basename(current);
if (base === 'dist' || base === 'src') {
return resolveProjectRoot(current);
}
const parent = path.dirname(current);
if (parent === current) {
return resolveProjectRoot(moduleDir);
}
current = parent;
}
}

View file

@ -1,4 +1,5 @@
import type { Express } from 'express';
import path from 'node:path';
import {
defaultScenarioPluginIdForProjectMetadata,
type PluginManifest,
@ -21,6 +22,25 @@ import { auditDesignSystemPackage } from './tools-connectors-cli.js';
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
function projectDetailResolvedDir(
projectsRoot: string,
project: any,
resolveProjectDir: (
projectsRoot: string,
projectId: string,
metadata?: unknown,
opts?: { allowUnavailableSandboxImportedProject?: boolean },
) => string,
): string {
const baseDir = typeof project?.metadata?.baseDir === 'string'
? path.normalize(project.metadata.baseDir)
: null;
if (baseDir && path.isAbsolute(baseDir)) return baseDir;
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
allowUnavailableSandboxImportedProject: true,
});
}
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
(function(){
if (window.__odUrlScrollBridge) return;
@ -419,7 +439,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
const project = getProject(db, req.params.id);
if (!project)
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
/** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project, resolvedDir };
res.json(body);

View file

@ -26,6 +26,7 @@ import {
isPublicationGuardedArtifactKind,
} from './artifact-publication-guard.js';
import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
@ -40,13 +41,42 @@ export function projectDir(projectsRoot, projectId) {
return path.join(projectsRoot, projectId);
}
export class SandboxImportedProjectError extends Error {
code = 'SANDBOX_IMPORTED_PROJECT_UNAVAILABLE';
constructor() {
super(
'Imported-folder projects are not available in OD_SANDBOX_MODE until their files are mirrored into the managed project directory.',
);
this.name = 'SandboxImportedProjectError';
}
}
function hasExternalProjectRoot(metadata?) {
if (typeof metadata?.baseDir !== 'string') return false;
return path.isAbsolute(path.normalize(metadata.baseDir));
}
export function assertSandboxProjectRootAvailable(metadata?) {
if (isSandboxModeEnabled(process.env) && hasExternalProjectRoot(metadata)) {
throw new SandboxImportedProjectError();
}
}
function usesExternalProjectRoot(metadata?) {
if (isSandboxModeEnabled(process.env)) return false;
return hasExternalProjectRoot(metadata);
}
// Returns the folder a project's files live in. For git-linked projects
// (metadata.baseDir set), this is the user's own folder. Otherwise falls
// back to the standard computed path under projectsRoot.
export function resolveProjectDir(projectsRoot, projectId, metadata?) {
if (typeof metadata?.baseDir === 'string') {
const p = path.normalize(metadata.baseDir);
if (path.isAbsolute(p)) return p;
export function resolveProjectDir(projectsRoot, projectId, metadata?, opts = {}) {
if (!opts.allowUnavailableSandboxImportedProject) {
assertSandboxProjectRootAvailable(metadata);
}
if (usesExternalProjectRoot(metadata)) {
return path.normalize(metadata.baseDir);
}
if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(projectsRoot, projectId);
@ -55,7 +85,7 @@ export function resolveProjectDir(projectsRoot, projectId, metadata?) {
export async function ensureProject(projectsRoot, projectId, metadata?) {
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
// Git-linked folders already exist; skip mkdir to avoid side-effects.
if (typeof metadata?.baseDir !== 'string') {
if (!usesExternalProjectRoot(metadata)) {
await mkdir(dir, { recursive: true });
}
return dir;
@ -67,7 +97,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
const out = [];
// Skip build/install dirs for linked folders so node_modules doesn't stall
// the walk on large repos.
const skipDirs = metadata?.baseDir ? isIgnoredProjectDirName : undefined;
const skipDirs = usesExternalProjectRoot(metadata) ? isIgnoredProjectDirName : undefined;
await collectFiles(dir, '', out, skipDirs, dir);
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);

View file

@ -151,6 +151,8 @@ async function probe(
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);

View file

@ -1,11 +1,27 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
import { resolveProjectRelativePath } from '../home-expansion.js';
import { expandConfiguredEnv } from './paths.js';
import { resolveAmrOpenCodeExecutable } from './executables.js';
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
import { resolveProjectRootFromNestedModule } from '../project-root.js';
import {
applySandboxRuntimeEnv,
isSandboxModeEnabled,
resolveSandboxRuntimeConfig,
type SandboxRuntimeConfig,
} from '../sandbox-mode.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
type SpawnEnvOptions = {
resolvedBin?: string | null;
};
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)),
);
// Build the env passed to spawn() for a given agent adapter.
//
@ -38,7 +54,9 @@ export function spawnEnvForAgent(
baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
options: SpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
const env = mergeProxyAwareEnv(
process.platform,
systemProxyEnv,
@ -58,20 +76,52 @@ export function spawnEnvForAgent(
const opencodeBin = resolveAmrOpenCodeExecutable(env);
if (opencodeBin) env.VELA_OPENCODE_BIN = opencodeBin;
}
return env;
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'claude') {
if (!isOpenClaudeExecutable(options.resolvedBin)) {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
return env;
}
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'codex') {
stripUnlessCustomBaseUrl(env, 'OPENAI_BASE_URL', [
'OPENAI_API_KEY',
'CODEX_API_KEY',
]);
return env;
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
return env;
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
const base = path
.basename(resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude';
}
function sandboxRuntimeConfigForBaseEnv(
baseEnv: RuntimeEnvMap,
): SandboxRuntimeConfig | null {
if (!isSandboxModeEnabled(baseEnv)) return null;
const dataDir = baseEnv.OD_DATA_DIR?.trim();
if (!dataDir) return null;
const resolvedDataDir = resolveProjectRelativePath(
dataDir,
RUNTIME_MODULE_PROJECT_ROOT,
);
return resolveSandboxRuntimeConfig(true, resolvedDataDir);
}
function reapplySandboxRuntimeEnv(
env: NodeJS.ProcessEnv,
sandboxRuntime: SandboxRuntimeConfig | null,
): NodeJS.ProcessEnv {
if (!sandboxRuntime) return env;
return applySandboxRuntimeEnv(env, sandboxRuntime);
}
// Remove `secretKeys` from `env` unless `baseUrlKey` is set to a non-empty

View file

@ -2,10 +2,17 @@ import { accessSync, constants, existsSync, statSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { wellKnownUserToolchainBins } from '@open-design/platform';
import { resolveSandboxRuntimeConfigFromEnv } from '../sandbox-mode.js';
import { expandHomePath } from './paths.js';
import type { RuntimeAgentDef } from './types.js';
const RUNTIME_PROJECT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../..',
);
const AGENT_BIN_ENV_KEYS = new Map<string, string>([
['amr', 'VELA_BIN'],
['aider', 'AIDER_BIN'],
@ -35,7 +42,12 @@ let cachedToolchainDirs: string[] | null = null;
let cachedToolchainDirsAt = 0;
function userToolchainDirs() {
const homeOverride = process.env.OD_AGENT_HOME;
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
process.env,
RUNTIME_PROJECT_ROOT,
);
const homeOverride =
sandboxRuntime?.roots.agentHomeDir ?? process.env.OD_AGENT_HOME;
const home = homeOverride || homedir();
const now = Date.now();
if (

View file

@ -1,7 +1,13 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import path from 'node:path';
import {
isSandboxModeEnabled,
resolveSandboxRuntimeConfigFromEnv,
sandboxAgentProfilesConfigPath,
} from '../sandbox-mode.js';
import { DEFAULT_MODEL_OPTION, sanitizeCustomModel } from './models.js';
import type {
RuntimeAgentDef,
@ -9,10 +15,44 @@ import type {
RuntimeModelOption,
} from './types.js';
function localAgentProfilesFile(): string {
const RUNTIME_PROJECT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../..',
);
function isInsideDir(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
}
function localAgentProfilesFile(): string | null {
const explicit = process.env.OD_AGENT_PROFILES_CONFIG;
if (typeof explicit === 'string' && explicit.trim()) {
return explicit.trim();
const explicitPath =
typeof explicit === 'string' && explicit.trim()
? path.resolve(explicit.trim())
: null;
if (isSandboxModeEnabled(process.env)) {
if (!process.env.OD_DATA_DIR?.trim()) return null;
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
process.env,
RUNTIME_PROJECT_ROOT,
);
if (!sandboxRuntime?.enabled) return null;
if (
explicitPath &&
isInsideDir(sandboxRuntime.roots.agentHomeDir, explicitPath)
) {
return explicitPath;
}
return sandboxAgentProfilesConfigPath(sandboxRuntime);
}
if (explicitPath) {
return explicitPath;
}
return path.join(homedir(), '.open-design', 'agents.local.json');
}
@ -152,9 +192,11 @@ function createLocalAgentDef(
export function readLocalAgentProfileDefs(
baseDefs: RuntimeAgentDef[],
): RuntimeAgentDef[] {
const profilesFile = localAgentProfilesFile();
if (profilesFile == null) return [];
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(localAgentProfilesFile(), 'utf8'));
parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
} catch {
return [];
}

View file

@ -0,0 +1,134 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveProjectRelativePath } from './home-expansion.js';
export const SANDBOX_MODE_ENV = 'OD_SANDBOX_MODE';
export interface SandboxRuntimeRoots {
agentHomeDir: string;
cacheDir: string;
configDir: string;
generatedFilesDir: string;
logsDir: string;
mcpConfigDir: string;
pluginStateDir: string;
previewStateDir: string;
skillsCacheDir: string;
tempDir: string;
toolConfigDir: string;
}
export interface SandboxRuntimeConfig {
enabled: boolean;
dataDir: string;
roots: SandboxRuntimeRoots;
}
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
const FALSY_VALUES = new Set(['0', 'false', 'no', 'off', '']);
export function isSandboxModeEnabled(
env: Record<string, string | undefined> = process.env,
): boolean {
const raw = env[SANDBOX_MODE_ENV];
if (typeof raw !== 'string') return false;
const value = raw.trim().toLowerCase();
if (TRUTHY_VALUES.has(value)) return true;
if (FALSY_VALUES.has(value)) return false;
throw new Error(
`${SANDBOX_MODE_ENV} must be one of ${Array.from(TRUTHY_VALUES).join(', ')} ` +
`or ${Array.from(FALSY_VALUES).join(', ')}`,
);
}
export function resolveSandboxRuntimeConfig(
enabled: boolean,
dataDir: string,
): SandboxRuntimeConfig {
const sandboxRoot = path.join(dataDir, 'sandbox');
return {
enabled,
dataDir,
roots: {
agentHomeDir: path.join(sandboxRoot, 'agent-home'),
cacheDir: path.join(sandboxRoot, 'cache'),
configDir: path.join(sandboxRoot, 'config'),
generatedFilesDir: path.join(dataDir, 'generated-files'),
logsDir: path.join(dataDir, 'logs'),
mcpConfigDir: dataDir,
pluginStateDir: path.join(dataDir, 'plugins'),
previewStateDir: path.join(dataDir, 'previews'),
skillsCacheDir: path.join(dataDir, 'skills'),
tempDir: path.join(sandboxRoot, 'tmp'),
toolConfigDir: path.join(sandboxRoot, 'tools'),
},
};
}
export function resolveSandboxRuntimeConfigFromEnv(
env: Record<string, string | undefined>,
projectRoot: string,
): SandboxRuntimeConfig | null {
if (!isSandboxModeEnabled(env)) return null;
const rawDataDir = env.OD_DATA_DIR?.trim();
if (!rawDataDir) {
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
}
return resolveSandboxRuntimeConfig(
true,
resolveProjectRelativePath(rawDataDir, projectRoot),
);
}
export function sandboxAgentProfilesConfigPath(
config: SandboxRuntimeConfig,
): string {
return path.join(
config.roots.agentHomeDir,
'.open-design',
'agents.local.json',
);
}
export function ensureSandboxRuntimeDirs(config: SandboxRuntimeConfig): void {
if (!config.enabled) return;
for (const dir of new Set(Object.values(config.roots))) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function applySandboxRuntimeEnv(
baseEnv: NodeJS.ProcessEnv,
config: SandboxRuntimeConfig,
): NodeJS.ProcessEnv {
if (!config.enabled) return baseEnv;
const env: NodeJS.ProcessEnv = { ...baseEnv };
const { roots } = config;
const codexHome = path.join(roots.agentHomeDir, '.codex');
const claudeConfigDir = path.join(roots.configDir, 'claude');
const opencodeHome = path.join(roots.agentHomeDir, '.opencode');
const npmUserConfig = path.join(roots.toolConfigDir, 'npmrc');
env[SANDBOX_MODE_ENV] = '1';
env.OD_DATA_DIR = config.dataDir;
env.OD_AGENT_HOME = roots.agentHomeDir;
env.HOME = roots.agentHomeDir;
env.USERPROFILE = roots.agentHomeDir;
env.XDG_CONFIG_HOME = roots.configDir;
env.XDG_CACHE_HOME = roots.cacheDir;
env.XDG_DATA_HOME = path.join(roots.configDir, 'data');
env.XDG_STATE_HOME = path.join(roots.configDir, 'state');
env.TMPDIR = roots.tempDir;
env.TEMP = roots.tempDir;
env.TMP = roots.tempDir;
env.CODEX_HOME = codexHome;
env.CLAUDE_CONFIG_DIR = claudeConfigDir;
env.OPENCODE_TEST_HOME = opencodeHome;
env.OD_AGENT_PROFILES_CONFIG = sandboxAgentProfilesConfigPath(config);
env.NPM_CONFIG_USERCONFIG = npmUserConfig;
env.npm_config_userconfig = npmUserConfig;
return env;
}

View file

@ -25,7 +25,10 @@ import {
shouldRenderCodexImagegenOverride,
} from './prompts/system.js';
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
import { resolveProjectRoot } from './project-root.js';
import { userFacingAgentLabel } from './user-facing-agent-label.js';
export { resolveProjectRoot };
import { createCommandInvocation } from '@open-design/platform';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import {
@ -90,6 +93,12 @@ import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './nati
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
import {
applySandboxRuntimeEnv,
ensureSandboxRuntimeDirs,
isSandboxModeEnabled,
resolveSandboxRuntimeConfig,
} from './sandbox-mode.js';
import {
createUserDesignSystem,
deleteUserDesignSystem,
@ -252,6 +261,7 @@ import {
type ObservabilityEventRequest,
} from '@open-design/contracts/analytics';
import {
mergeNoProxyWithLoopbackDefaults,
redactSecrets,
testAgentConnection,
testProviderConnection,
@ -335,6 +345,7 @@ import {
buildBatchArchive,
decodeMultipartFilename,
deleteProjectFile,
assertSandboxProjectRootAvailable,
detectEntryFile,
ensureProject,
isSafeId,
@ -346,6 +357,7 @@ import {
renameProjectFile,
removeProjectDir,
resolveProjectDir,
SandboxImportedProjectError,
sanitizeName,
searchProjectFiles,
resolveProjectDir,
@ -476,13 +488,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_PATH';
export function resolveProjectRoot(moduleDir: string): string {
const base = path.basename(moduleDir);
const daemonDir =
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
return path.resolve(daemonDir, '../..');
}
function cleanOptionalPath(value: string | undefined): string | null {
return typeof value === 'string' && value.trim().length > 0
? path.resolve(value)
@ -1328,8 +1333,14 @@ function createMarketplaceFetcher(seedId, bundledMarketplaceEntries) {
};
}
export function resolveDataDir(raw, projectRoot) {
if (!raw) return path.join(projectRoot, '.od');
export function resolveDataDir(raw, projectRoot, options = {}) {
const value = raw?.trim();
if (!value) {
if (options.requireExplicit) {
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
}
return path.join(projectRoot, '.od');
}
// expandHomePrefix is shared with media-config.ts so OD_DATA_DIR and
// OD_MEDIA_CONFIG_DIR can never split state under a $HOME-style value.
// Some launchers (systemd unit files, NixOS modules, certain Docker
@ -1338,7 +1349,7 @@ export function resolveDataDir(raw, projectRoot) {
// expandHomePrefix turns those (and the ~ shorthand, with both / and \
// separators) into os.homedir() before path.resolve runs so launch
// surfaces stay consistent.
const resolved = resolveProjectRelativePath(raw, projectRoot);
const resolved = resolveProjectRelativePath(value, projectRoot);
try {
fs.mkdirSync(resolved, { recursive: true });
fs.accessSync(resolved, fs.constants.W_OK);
@ -1364,7 +1375,12 @@ export function resolveDataDir(raw, projectRoot) {
}
return resolved;
}
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
const SANDBOX_MODE_ENABLED = isSandboxModeEnabled(process.env);
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT, {
requireExplicit: SANDBOX_MODE_ENABLED,
});
const SANDBOX_RUNTIME = resolveSandboxRuntimeConfig(SANDBOX_MODE_ENABLED, RUNTIME_DATA_DIR);
ensureSandboxRuntimeDirs(SANDBOX_RUNTIME);
const PLUGIN_LOCKFILE_PATH = path.join(RUNTIME_DATA_DIR, 'od-plugin-lock.json');
// Canonical (realpath-resolved) form of RUNTIME_DATA_DIR for the few callers
// that compare it against a user-supplied realpath() result. On macOS, /var
@ -1623,16 +1639,26 @@ export function createAgentRuntimeEnv(
toolTokenGrant: { token?: string } | null = null,
nodeBin: string = process.execPath,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
const env: NodeJS.ProcessEnv = applySandboxRuntimeEnv(
{
...baseEnv,
OD_DATA_DIR: RUNTIME_DATA_DIR,
OD_DAEMON_URL: daemonUrl,
OD_NODE_BIN: nodeBin,
};
},
SANDBOX_RUNTIME,
);
const sidecarIpcPath = baseEnv[SIDECAR_ENV.IPC_PATH];
if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) {
env[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
}
if (SANDBOX_RUNTIME.enabled) {
const noProxy = mergeNoProxyWithLoopbackDefaults(env.NO_PROXY ?? env.no_proxy);
if (noProxy) {
env.NO_PROXY = noProxy;
if (process.platform !== 'win32') env.no_proxy = noProxy;
}
}
// Ensure the node binary directory is on PATH so agent sub-processes —
// in particular npm .cmd shims on Windows that run `"node" script.js` —
@ -3842,10 +3868,18 @@ export async function startServer({
// Active only when OD_API_TOKEN is set. Loopback origins skip the
// check (the desktop UI / local CLI never carry a bearer); every
// other request must present `Authorization: Bearer <token>` with a
// value matching `OD_API_TOKEN`. Health / version / status remain
// open so monitoring probes don't need the token.
// value matching `OD_API_TOKEN`. Health / readiness / version remain
// open so monitoring probes don't need the token. Rich daemon status
// stays authenticated because it includes local runtime paths.
if (apiToken.length > 0) {
const openProbePaths = new Set(['/api/health', '/api/version', '/api/daemon/status']);
const openProbePaths = new Set([
'/health',
'/api/health',
'/ready',
'/api/ready',
'/version',
'/api/version',
]);
app.use('/api', (req, res, next) => {
if (openProbePaths.has(req.path)) return next();
// Loopback short-circuit. We ignore the proxied X-Forwarded-For
@ -4356,6 +4390,16 @@ export async function startServer({
res.json({ ok: true, version: versionInfo.version });
});
app.get('/api/ready', async (_req, res) => {
const versionInfo = await readCurrentAppVersionInfo();
const ready = !daemonShuttingDown;
res.status(ready ? 200 : 503).json({
ok: ready,
ready,
version: versionInfo.version,
});
});
app.get('/api/version', async (_req, res) => {
const version = await readCurrentAppVersionInfo();
res.json({ version });
@ -4409,6 +4453,10 @@ export async function startServer({
port: Number(process.env.OD_PORT ?? 7456),
dataDir: RUNTIME_DATA_DIR,
mediaConfigDir: process.env.OD_MEDIA_CONFIG_DIR ?? null,
sandboxMode: SANDBOX_RUNTIME.enabled,
sandbox: SANDBOX_RUNTIME.enabled
? { enabled: true, roots: SANDBOX_RUNTIME.roots }
: { enabled: false },
pid: process.pid,
shuttingDown: daemonShuttingDown,
installedPlugins: (() => {
@ -10735,14 +10783,13 @@ export async function startServer({
try {
const chatProject = getProject(db, projectId);
const chatMeta = chatProject?.metadata;
if (chatMeta?.baseDir) {
cwd = path.normalize(chatMeta.baseDir);
assertSandboxProjectRootAvailable(chatMeta);
cwd = await ensureProject(PROJECTS_DIR, projectId, chatMeta);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
} else {
cwd = await ensureProject(PROJECTS_DIR, projectId);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return design.runs.fail(run, 'BAD_REQUEST', err.message);
}
} catch {
cwd = null;
}
}
@ -11268,6 +11315,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
),
agentLaunch,
)
@ -11650,6 +11699,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
);
if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
@ -12603,6 +12654,7 @@ export async function startServer({
stderrTail: agentStderrTail,
stdoutTail: agentStdoutTail,
env: spawnedAgentEnv,
resolvedBin: agentLaunch.selectedPath,
});
// A non-zero exit whose output reads as an auth / quota / upstream
// problem (typical of Claude Code, codex, …) gets the specific code
@ -13051,7 +13103,18 @@ export async function startServer({
) {
try {
const convs = listConversations(db, meta.projectId);
const defaultConv = Array.isArray(convs) && convs.length > 0 ? convs[0] : null;
// listConversations is ordered for the UI by recent activity; this
// fallback must bind to the seeded default conversation instead.
const defaultConv = Array.isArray(convs) && convs.length > 0
? [...convs].sort((a, b) => {
const aCreated = Number(a?.createdAt);
const bCreated = Number(b?.createdAt);
if (Number.isFinite(aCreated) && Number.isFinite(bCreated) && aCreated !== bCreated) {
return aCreated - bCreated;
}
return String(a?.id ?? '').localeCompare(String(b?.id ?? ''));
})[0]
: null;
if (defaultConv && typeof defaultConv.id === 'string' && defaultConv.id) {
meta.conversationId = defaultConv.id;
if (typeof meta.assistantMessageId !== 'string' || !meta.assistantMessageId) {
@ -13085,10 +13148,13 @@ export async function startServer({
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
if (cfgAgent) {
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}

View file

@ -87,6 +87,19 @@ describe('agent runtime tool environment', () => {
expect(env.OD_DATA_DIR).toBe(process.env.OD_DATA_DIR);
});
it('keeps non-sandbox NO_PROXY behavior unchanged', () => {
const env = createAgentRuntimeEnv(
{ PATH: '/bin', HTTP_PROXY: 'http://127.0.0.1:9', NO_PROXY: '' },
'http://127.0.0.1:7456',
{ token: 'fresh-token' },
'/opt/open-design/bin/node',
);
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:9');
expect(env.NO_PROXY).toBe('');
expect(env.no_proxy).toBeUndefined();
});
it('passes the daemon sidecar IPC path from the explicit base env into agent wrapper sessions', () => {
const env = createAgentRuntimeEnv(
{ PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' },

View file

@ -5,7 +5,7 @@
// OD_API_TOKEN is set.
// 2. When OD_API_TOKEN is set, every /api/* request from a non-loopback
// peer must carry `Authorization: Bearer <OD_API_TOKEN>`. The
// health/version/status probes stay open for monitoring.
// health/readiness/version probes stay open for monitoring.
//
// Tests force the bearer-required code path by stamping the env vars
// before startServer. The daemon listens on 127.0.0.1 throughout (so
@ -77,8 +77,8 @@ describe('bearer middleware', () => {
expect(resp.status).toBe(200);
});
it('keeps health / version / daemon-status open without a bearer', async () => {
for (const path of ['/api/health', '/api/version', '/api/daemon/status']) {
it('keeps health / readiness / version probes open without a bearer', async () => {
for (const path of ['/api/health', '/api/ready', '/api/version']) {
const resp = await fetch(`${baseUrl}${path}`);
expect(resp.status).toBe(200);
}

View file

@ -86,6 +86,42 @@ async function withFakeAgent<T>(
}
}
async function withOnlyFakeAgent<T>(
binName: string,
script: string,
run: () => Promise<T>,
): Promise<T> {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
const oldClaudeBin = process.env.CLAUDE_BIN;
try {
if (process.platform === 'win32') {
const runner = path.join(dir, `${binName}-test-runner.cjs`);
await fsp.writeFile(runner, script);
await fsp.writeFile(
path.join(dir, `${binName}.cmd`),
`@echo off\r\nnode "${runner}" %*\r\n`,
);
} else {
const bin = path.join(dir, binName);
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
await fsp.chmod(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
delete process.env.CLAUDE_BIN;
return await run();
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
else process.env.CLAUDE_BIN = oldClaudeBin;
await fsp.rm(dir, { recursive: true, force: true });
}
}
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('codex', script, run);
}
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
return withFakeAgent('claude', script, run);
}
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
return withOnlyFakeAgent('openclaude', script, run);
}
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('opencode', script, run);
}
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
);
});
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
const previousKey = process.env.ANTHROPIC_API_KEY;
try {
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
await withOnlyFakeOpenClaude(
`
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
}));
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
JSON.parse(input.trim());
console.log(JSON.stringify({
type: 'assistant',
message: {
id: 'msg_1',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
},
}));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
`,
async () => {
const result = await testAgentConnection({ agentId: 'claude' });
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'Claude Code',
});
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
);
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
},
);
} finally {
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = previousKey;
await fsp.rm(envFile, { force: true });
}
});
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
await withFakeClaude(
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,

View file

@ -38,6 +38,8 @@ describe('GET /api/daemon/status', () => {
version: unknown;
bindHost: unknown;
port: unknown;
sandboxMode: boolean;
sandbox: { enabled: boolean };
pid: unknown;
installedPlugins: unknown;
shuttingDown: boolean;
@ -49,11 +51,32 @@ describe('GET /api/daemon/status', () => {
expect(typeof body.port).toBe('number');
expect(typeof body.pid).toBe('number');
expect(typeof body.installedPlugins).toBe('number');
expect(body.sandboxMode).toBe(false);
expect(body.sandbox).toEqual({ enabled: false });
expect(body.shuttingDown).toBe(false);
expect(body).not.toHaveProperty('namespace');
});
});
describe('GET /api/ready', () => {
it('returns a readiness snapshot for headless launchers', async () => {
const resp = await fetch(`${baseUrl}/api/ready`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
ok: boolean;
ready: boolean;
version: unknown;
};
expect(body.ok).toBe(true);
expect(body.ready).toBe(true);
expect(typeof body.version === 'string' || typeof body.version === 'object').toBe(true);
expect(body).not.toHaveProperty('dataDir');
expect(body).not.toHaveProperty('sandboxMode');
expect(body).not.toHaveProperty('sandbox');
});
});
describe('POST /api/daemon/shutdown', () => {
it('only accepts requests from local-daemon-allowed origins', async () => {
// Without the local-daemon header, the route is rejected. The

View file

@ -4,7 +4,24 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { detectEntryFile, listFiles, resolveProjectDir } from '../src/projects.js';
import {
assertSandboxProjectRootAvailable,
detectEntryFile,
listFiles,
resolveProjectDir,
SandboxImportedProjectError,
} from '../src/projects.js';
function withSandboxMode<T>(run: () => T): T {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
describe('resolveProjectDir', () => {
const projectsRoot = '/var/od/projects';
@ -50,6 +67,22 @@ describe('resolveProjectDir', () => {
}),
).not.toThrow();
});
it('rejects metadata.baseDir in sandbox mode before resolving a project file root', () => {
withSandboxMode(() => {
const baseDir = '/Users/me/projects/site';
expect(
() => resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
).toThrowError(SandboxImportedProjectError);
expect(() =>
assertSandboxProjectRootAvailable({ kind: 'prototype', baseDir }),
).toThrowError(SandboxImportedProjectError);
expect(() => resolveProjectDir(projectsRoot, '../escape', {
kind: 'prototype',
baseDir,
})).toThrowError();
});
});
});
describe('detectEntryFile', () => {

View file

@ -45,6 +45,37 @@ describe('POST /api/import/folder', () => {
});
}
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
async function waitForRunStatus(
runId: string,
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
let lastStatus = 'unknown';
for (let attempt = 0; attempt < 200; attempt += 1) {
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
const statusBody = (await statusResponse.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
lastStatus = statusBody.status;
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
return statusBody;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
}
it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -62,6 +93,80 @@ describe('POST /api/import/folder', () => {
expect(body.entryFile).toBe('index.html');
});
it('rejects folder imports in sandbox mode', async () => {
await withSandboxMode(async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const resp = await importFolder({ baseDir: folder });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/OD_SANDBOX_MODE/i);
});
});
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const runResp = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: project.id,
message: 'Inspect the imported project.',
}),
});
expect(runResp.status).toBe(202);
const { runId } = (await runResp.json()) as { runId: string };
const status = await waitForRunStatus(runId);
expect(status.status).toBe('failed');
expect(status.errorCode).toBe('BAD_REQUEST');
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('still opens an imported-folder project record in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
project?: { id?: string; metadata?: { baseDir?: string } };
};
expect(body.project?.id).toBe(project.id);
expect(body.project?.metadata?.baseDir).toBeTruthy();
});
});
it('rejects imported-folder project file listing in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/files`);
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('auto-detects the entry file when present', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '');

View file

@ -0,0 +1,194 @@
import type http from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
type StartedServer = {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
describe('POST /api/runs headless fallbacks', () => {
let started: StartedServer | null = null;
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
afterEach(async () => {
await Promise.resolve(started?.shutdown?.());
if (started?.server) {
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
}
started = null;
if (oldPath === undefined) delete process.env.PATH;
else process.env.PATH = oldPath;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
});
it('binds omitted conversationId to the seeded project conversation', async () => {
started = await startTestServer();
const { projectId, conversationId: seededConversationId } = await createProject(
started.url,
'Headless default conversation project',
);
await delay(5);
const newerConversationId = await createConversation(started.url, projectId, 'Newer user chat');
const conversationsResponse = await fetch(
`${started.url}/api/projects/${encodeURIComponent(projectId)}/conversations`,
);
expect(conversationsResponse.status).toBe(200);
const conversationsBody = await conversationsResponse.json() as {
conversations: Array<{ id: string }>;
};
expect(conversationsBody.conversations[0]?.id).toBe(newerConversationId);
const runResponse = await fetch(`${started.url}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: `missing-agent-${randomUUID()}`,
projectId,
message: 'Headless prompt',
}),
});
expect(runResponse.status).toBe(202);
const runBody = await runResponse.json() as { conversationId: string | null };
expect(runBody.conversationId).toBe(seededConversationId);
});
it('falls back past a stale saved agent to the first detected available runtime', async () => {
started = await startTestServer();
const binDir = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-bin-'));
const emptyAgentHome = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-home-'));
const priorConfig = await readAppConfigFromServer(started.url);
try {
const opencodeBin = await writeFakeOpencode(binDir);
process.env.PATH = '';
process.env.OD_AGENT_HOME = emptyAgentHome;
const configResponse = await fetch(`${started.url}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
agentCliEnv: {
claude: { CLAUDE_BIN: path.join(binDir, 'missing-claude') },
opencode: { OPENCODE_BIN: opencodeBin },
},
}),
});
expect(configResponse.status).toBe(200);
const { projectId } = await createProject(started.url, 'Headless stale agent project');
const runResponse = await fetch(`${started.url}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId,
message: 'Headless prompt',
}),
});
expect(runResponse.status).toBe(202);
const runBody = await runResponse.json() as { runId: string };
const statusResponse = await fetch(
`${started.url}/api/runs/${encodeURIComponent(runBody.runId)}`,
);
expect(statusResponse.status).toBe(200);
const statusBody = await statusResponse.json() as { agentId: string | null };
expect(statusBody.agentId).toBe('opencode');
} finally {
await restoreAppConfig(started.url, priorConfig);
await rm(binDir, { recursive: true, force: true });
await rm(emptyAgentHome, { recursive: true, force: true });
}
});
});
async function startTestServer(): Promise<StartedServer> {
return await startServer({ port: 0, returnServer: true }) as StartedServer;
}
async function createProject(url: string, name: string): Promise<{
projectId: string;
conversationId: string;
}> {
const projectId = `project_${randomUUID()}`;
const response = await fetch(`${url}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name,
metadata: { kind: 'prototype' },
}),
});
expect(response.status).toBe(200);
const body = await response.json() as { conversationId: string };
return { projectId, conversationId: body.conversationId };
}
async function createConversation(
url: string,
projectId: string,
title: string,
): Promise<string> {
const response = await fetch(`${url}/api/projects/${encodeURIComponent(projectId)}/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
expect(response.status).toBe(200);
const body = await response.json() as { conversation: { id: string } };
return body.conversation.id;
}
async function readAppConfigFromServer(url: string): Promise<Record<string, unknown>> {
const response = await fetch(`${url}/api/app-config`);
expect(response.status).toBe(200);
const body = await response.json() as { config?: Record<string, unknown> };
return body.config ?? {};
}
async function restoreAppConfig(url: string, config: Record<string, unknown>): Promise<void> {
await fetch(`${url}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: Object.hasOwn(config, 'agentId') ? config.agentId : null,
agentCliEnv: Object.hasOwn(config, 'agentCliEnv') ? config.agentCliEnv : null,
}),
});
}
async function writeFakeOpencode(dir: string): Promise<string> {
const bin = path.join(dir, 'opencode');
await writeFile(bin, `#!/usr/bin/env node
if (process.argv.includes('--version')) {
console.log('opencode 0.0.0');
process.exit(0);
}
if (process.argv[2] === 'models') {
console.log('test/model');
process.exit(0);
}
if (process.argv[2] === 'run') {
process.stdin.resume();
process.stdin.on('end', () => process.exit(0));
setTimeout(() => process.exit(0), 50);
} else {
process.exit(0);
}
`, 'utf8');
await chmod(bin, 0o755);
return bin;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -30,6 +30,7 @@ describe('media-config OpenAI auth-file fallback', () => {
);
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
const originalDataDir = process.env.OD_DATA_DIR;
const originalSandboxMode = process.env.OD_SANDBOX_MODE;
let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
@ -42,6 +43,7 @@ describe('media-config OpenAI auth-file fallback', () => {
}
delete process.env.OD_MEDIA_CONFIG_DIR;
delete process.env.OD_DATA_DIR;
delete process.env.OD_SANDBOX_MODE;
});
afterEach(async () => {
@ -67,6 +69,11 @@ describe('media-config OpenAI auth-file fallback', () => {
} else {
process.env.OD_DATA_DIR = originalDataDir;
}
if (originalSandboxMode == null) {
delete process.env.OD_SANDBOX_MODE;
} else {
process.env.OD_SANDBOX_MODE = originalSandboxMode;
}
homedirSpy.mockRestore();
await rm(homeDir, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true });
@ -124,6 +131,30 @@ describe('media-config OpenAI auth-file fallback', () => {
});
});
it('does not read host OpenAI auth files in sandbox mode', async () => {
process.env.OD_SANDBOX_MODE = '1';
await writeHomeJson('.hermes/auth.json', {
providers: {
'openai-codex': {
tokens: { access_token: 'hermes-oauth-token' },
},
},
});
await writeHomeJson('.codex/auth.json', {
tokens: { access_token: 'codex-oauth-token' },
OPENAI_API_KEY: 'host-codex-api-key',
});
const resolved = await resolveProviderConfig(projectRoot, 'openai');
const masked = await readMaskedConfig(projectRoot);
expect(resolved.apiKey).toBe('');
expect(openaiProvider(masked)).toMatchObject({
configured: false,
source: 'unset',
});
});
it('uses explicit OPENAI_API_KEY from Codex auth files', async () => {
await writeHomeJson('.codex/auth.json', {
tokens: { access_token: 'codex-oauth-token' },

View file

@ -1,11 +1,12 @@
import type http from 'node:http';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
import { memoryDir, writeMemoryConfig } from '../src/memory.js';
type FakeMediaEndpoint = 'tool' | 'legacy';
@ -19,6 +20,7 @@ describe('run-scoped media policy routes', () => {
let binDir: string;
let oldPath: string | undefined;
let oldCapture: string | undefined;
let oldMemoryConfigRaw: string | null = null;
let server: http.Server | null = null;
let shutdown: (() => Promise<void> | void) | undefined;
@ -28,6 +30,12 @@ describe('run-scoped media policy routes', () => {
oldPath = process.env.PATH;
oldCapture = process.env.OD_CAPTURE_MEDIA_RESPONSE;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ''}`;
const memoryConfig = memoryConfigPath();
oldMemoryConfigRaw = await readFile(memoryConfig, 'utf8').catch(() => null);
await writeMemoryConfig(process.env.OD_DATA_DIR!, {
chatExtractionEnabled: false,
extraction: null,
});
});
afterEach(async () => {
@ -41,6 +49,14 @@ describe('run-scoped media policy routes', () => {
else process.env.PATH = oldPath;
if (oldCapture === undefined) delete process.env.OD_CAPTURE_MEDIA_RESPONSE;
else process.env.OD_CAPTURE_MEDIA_RESPONSE = oldCapture;
const memoryConfig = memoryConfigPath();
if (oldMemoryConfigRaw === null) {
await rm(memoryConfig, { force: true });
} else {
await mkdir(path.dirname(memoryConfig), { recursive: true });
await writeFile(memoryConfig, oldMemoryConfigRaw);
}
oldMemoryConfigRaw = null;
await rm(tempDir, { recursive: true, force: true });
await rm(binDir, { recursive: true, force: true });
});
@ -468,6 +484,10 @@ describe('run-scoped media policy routes', () => {
};
}
function memoryConfigPath(): string {
return path.join(memoryDir(process.env.OD_DATA_DIR!), '.config.json');
}
async function writeFakeAgent(
capturePath: string,
requestBody: unknown,

View file

@ -77,6 +77,35 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(detail.resolvedDir).toBe(baseDir);
});
it('keeps imported-folder resolvedDir stable in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseDir: folder }),
});
expect(importResp.status).toBe(200);
const importBody = (await importResp.json()) as {
project: { id: string; metadata?: { baseDir?: string } };
};
const projectId = importBody.project.id;
const baseDir = importBody.project.metadata?.baseDir;
expect(baseDir).toBeTruthy();
await withSandboxMode(async () => {
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
expect(detailResp.status).toBe(200);
const detail = (await detailResp.json()) as {
project: { id: string };
resolvedDir: string;
};
expect(detail.project.id).toBe(projectId);
expect(detail.resolvedDir).toBe(baseDir);
});
});
it('returns resolvedDir under <projects root>/<id> for a native project', async () => {
const projectId = `proj-routes-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
@ -269,3 +298,14 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(body.error?.message).toMatch(/fromTrustedPicker/i);
});
});
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}

View file

@ -37,6 +37,23 @@ describe('resolveDataDir', () => {
expect(resolveDataDir('', projectRoot)).toBe(path.join(projectRoot, '.od'));
});
it('requires an explicit OD_DATA_DIR when sandbox mode requires one', () => {
expect(() =>
resolveDataDir(undefined, projectRoot, { requireExplicit: true }),
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
expect(() => resolveDataDir('', projectRoot, { requireExplicit: true })).toThrow(
'OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled',
);
expect(() =>
resolveDataDir(' ', projectRoot, { requireExplicit: true }),
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
});
it('trims OD_DATA_DIR before resolving the storage root', () => {
const out = resolveDataDir(' rel-od ', projectRoot, { requireExplicit: true });
expect(out).toBe(path.join(projectRoot, 'rel-od'));
});
it('expands a leading ~/ against the user home directory', () => {
const out = resolveDataDir('~/od-test', projectRoot);
expect(out).toBe(path.join(fakeHome, 'od-test'));

View file

@ -1,6 +1,8 @@
import { symlinkSync } from 'node:fs';
import { test, vi } from 'vitest';
import { homedir } from 'node:os';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as platform from '@open-design/platform';
import {
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
@ -8,6 +10,7 @@ import {
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
const fsTest = process.platform === 'win32' ? test.skip : test;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
// credentials, silently billing API usage. Strip it for the claude
@ -55,6 +58,113 @@ test('spawnEnvForAgent applies configured Codex env without mutating the base en
assert.equal('CODEX_BIN' in base, false);
});
test('spawnEnvForAgent reapplies sandbox state roots after configured env overrides', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-'));
try {
const codexEnv = spawnEnvForAgent(
'codex',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CODEX_HOME: '/Users/test/.codex-host',
},
);
assert.equal(
codexEnv.CODEX_HOME,
join(dataDir, 'sandbox', 'agent-home', '.codex'),
);
assert.equal(codexEnv.HOME, join(dataDir, 'sandbox', 'agent-home'));
const claudeEnv = spawnEnvForAgent(
'claude',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CLAUDE_CONFIG_DIR: '/Users/test/.claude-host',
},
);
assert.equal(
claudeEnv.CLAUDE_CONFIG_DIR,
join(dataDir, 'sandbox', 'config', 'claude'),
);
const amrEnv = spawnEnvForAgent(
'amr',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
OPENCODE_TEST_HOME: '/Users/test/.opencode-host',
},
);
assert.equal(
amrEnv.OPENCODE_TEST_HOME,
join(dataDir, 'sandbox', 'agent-home', '.opencode'),
);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent keeps sandbox roots pinned to the base OD_DATA_DIR', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-base-'));
try {
const env = spawnEnvForAgent(
'codex',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CODEX_HOME: '/Users/test/.codex-host',
OD_DATA_DIR: '/host/path/.od',
},
);
assert.equal(env.OD_DATA_DIR, dataDir);
assert.equal(env.CODEX_HOME, join(dataDir, 'sandbox', 'agent-home', '.codex'));
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent resolves relative OD_DATA_DIR before applying sandbox roots', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-relative-'));
try {
const relativeDataDir = relative(repoRoot, dataDir);
const env = spawnEnvForAgent(
'codex',
{
OD_DATA_DIR: relativeDataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CODEX_HOME: '/Users/test/.codex-host',
},
);
assert.equal(
env.CODEX_HOME,
join(dataDir, 'sandbox', 'agent-home', '.codex'),
);
assert.equal(env.CLAUDE_CONFIG_DIR, join(dataDir, 'sandbox', 'config', 'claude'));
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => {
const env = spawnEnvForAgent(
'gemini',
@ -847,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
const env = spawnEnvForAgent(
'claude',
{
ANTHROPIC_API_KEY: 'sk-openclaude',
PATH: '/usr/bin',
},
{},
{},
{ resolvedBin: '/tools/openclaude' },
);
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, {

View file

@ -1,4 +1,5 @@
import { test } from 'vitest';
import { relative, resolve } from 'node:path';
import {
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
@ -407,6 +408,77 @@ fsTest(
},
);
fsTest(
'OD_SANDBOX_MODE scopes fallback toolchain discovery to OD_DATA_DIR',
() => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-data-'));
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
const realPrefixBin = join(realPrefix, 'bin');
try {
return withEnvSnapshot(
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => {
mkdirSync(realPrefixBin, { recursive: true });
writeFileSync(join(realPrefixBin, 'gemini'), '');
chmodSync(join(realPrefixBin, 'gemini'), 0o755);
delete process.env.OD_AGENT_HOME;
process.env.OD_DATA_DIR = dataDir;
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
process.env.NPM_CONFIG_PREFIX = realPrefix;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(
resolved,
null,
`sandbox mode must not see the host $NPM_CONFIG_PREFIX bin; got ${resolved}`,
);
},
);
} finally {
rmSync(dataDir, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
rmSync(realPrefix, { recursive: true, force: true });
}
},
);
fsTest(
'OD_SANDBOX_MODE resolves relative OD_DATA_DIR before fallback toolchain discovery',
() => {
const projectRoot = resolve(process.cwd(), '../..');
const parent = mkdtempSync(join(tmpdir(), 'od-agents-relative-data-parent-'));
const dataDir = join(parent, 'data');
const sandboxBin = join(dataDir, 'sandbox', 'agent-home', '.local', 'bin');
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
try {
return withEnvSnapshot(
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => {
mkdirSync(sandboxBin, { recursive: true });
const geminiPath = join(sandboxBin, 'gemini');
writeFileSync(geminiPath, '');
chmodSync(geminiPath, 0o755);
delete process.env.OD_AGENT_HOME;
delete process.env.NPM_CONFIG_PREFIX;
process.env.OD_DATA_DIR = relative(projectRoot, dataDir);
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, geminiPath);
},
);
} finally {
rmSync(parent, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
}
},
);
fsTest(
'OD_AGENT_HOME isolates resolution from $VP_HOME leakage',
() => {

View file

@ -102,6 +102,31 @@ test('local agent profiles skip explicit unknown baseAgent without falling back'
}
});
test('sandbox mode ignores implicit and host explicit local agent profiles', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-local-agent-profiles-sandbox-'));
try {
await withEnvSnapshot(['OD_AGENT_PROFILES_CONFIG', 'OD_SANDBOX_MODE', 'OD_DATA_DIR'], async () => {
const config = join(dir, 'agents.local.json');
writeFileSync(
config,
JSON.stringify({
agents: [{ id: 'explicit-wrapper', bin: 'explicit-wrapper' }],
}),
);
process.env.OD_SANDBOX_MODE = '1';
delete process.env.OD_DATA_DIR;
delete process.env.OD_AGENT_PROFILES_CONFIG;
assert.deepEqual(readLocalAgentProfileDefs(), []);
process.env.OD_AGENT_PROFILES_CONFIG = config;
assert.deepEqual(readLocalAgentProfileDefs(), []);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
process.env.OD_CODEX_DISABLE_PLUGINS = '1';

View file

@ -0,0 +1,98 @@
import os from 'node:os';
import path from 'node:path';
import { existsSync, mkdtempSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { afterEach, describe, expect, it } from 'vitest';
import {
applySandboxRuntimeEnv,
ensureSandboxRuntimeDirs,
isSandboxModeEnabled,
resolveSandboxRuntimeConfig,
} from '../src/sandbox-mode.js';
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
function tempDataDir(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), 'od-sandbox-mode-'));
tempDirs.push(dir);
return dir;
}
describe('sandbox mode env parsing', () => {
it('is disabled when OD_SANDBOX_MODE is unset or false-like', () => {
expect(isSandboxModeEnabled({})).toBe(false);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '0' })).toBe(false);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'false' })).toBe(false);
});
it('is enabled for explicit true-like values', () => {
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '1' })).toBe(true);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'true' })).toBe(true);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'YES' })).toBe(true);
});
it('rejects ambiguous non-empty values', () => {
expect(() => isSandboxModeEnabled({ OD_SANDBOX_MODE: 'sandbox' })).toThrow(
'OD_SANDBOX_MODE must be one of',
);
});
});
describe('sandbox runtime roots', () => {
it('keeps all run-scoped roots under OD_DATA_DIR', () => {
const dataDir = tempDataDir();
const config = resolveSandboxRuntimeConfig(true, dataDir);
expect(config.enabled).toBe(true);
for (const dir of Object.values(config.roots)) {
expect(dir === dataDir || dir.startsWith(dataDir + path.sep)).toBe(true);
}
});
it('creates scoped runtime directories only when enabled', () => {
const dataDir = tempDataDir();
const enabled = resolveSandboxRuntimeConfig(true, dataDir);
const disabled = resolveSandboxRuntimeConfig(false, dataDir);
ensureSandboxRuntimeDirs(disabled);
expect(existsSync(enabled.roots.agentHomeDir)).toBe(false);
ensureSandboxRuntimeDirs(enabled);
expect(existsSync(enabled.roots.agentHomeDir)).toBe(true);
expect(existsSync(enabled.roots.previewStateDir)).toBe(true);
expect(existsSync(enabled.roots.toolConfigDir)).toBe(true);
});
it('pins agent home and tool config env to sandbox roots', () => {
const dataDir = tempDataDir();
const config = resolveSandboxRuntimeConfig(true, dataDir);
const env = applySandboxRuntimeEnv(
{
HOME: '/real/home',
CODEX_HOME: '/real/home/.codex',
CLAUDE_CONFIG_DIR: '/real/home/.claude',
OPENCODE_TEST_HOME: '/real/home/.opencode',
NPM_CONFIG_USERCONFIG: '/real/home/.npmrc',
OD_DATA_DIR: dataDir,
PATH: '/bin',
},
config,
);
expect(env.HOME).toBe(config.roots.agentHomeDir);
expect(env.USERPROFILE).toBe(config.roots.agentHomeDir);
expect(env.OD_AGENT_HOME).toBe(config.roots.agentHomeDir);
expect(env.CODEX_HOME).toBe(path.join(config.roots.agentHomeDir, '.codex'));
expect(env.CLAUDE_CONFIG_DIR).toBe(path.join(config.roots.configDir, 'claude'));
expect(env.OPENCODE_TEST_HOME).toBe(path.join(config.roots.agentHomeDir, '.opencode'));
expect(env.NPM_CONFIG_USERCONFIG).toBe(path.join(config.roots.toolConfigDir, 'npmrc'));
expect(env.PATH).toBe('/bin');
});
});

View file

@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import {
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { test, vi } from 'vitest';
function withEnvSnapshot<T>(
keys: readonly string[],
run: () => T | Promise<T>,
): T | Promise<T> {
const snapshot = new Map(keys.map((key) => [key, process.env[key]]));
const restore = () => {
for (const key of keys) {
const value = snapshot.get(key);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
let result: T | Promise<T>;
try {
result = run();
} catch (error) {
restore();
throw error;
}
if (result instanceof Promise) {
return result.finally(restore);
}
restore();
return result;
}
test('sandbox runtime registry ignores host-local agent profiles at module load', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-'));
const dataDir = path.join(root, 'data');
const hostHome = path.join(root, 'host-home');
const hostConfigDir = path.join(hostHome, '.open-design');
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
const sandboxConfigDir = path.join(
dataDir,
'sandbox',
'agent-home',
'.open-design',
);
const sandboxConfig = path.join(sandboxConfigDir, 'agents.local.json');
try {
mkdirSync(hostConfigDir, { recursive: true });
mkdirSync(sandboxConfigDir, { recursive: true });
writeFileSync(
hostConfig,
JSON.stringify({
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
}),
);
writeFileSync(
sandboxConfig,
JSON.stringify({
agents: [
{
id: 'sandbox-wrapper',
baseAgent: 'claude',
bin: 'sandbox-wrapper',
},
],
}),
);
await withEnvSnapshot(
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
async () => {
process.env.OD_SANDBOX_MODE = '1';
process.env.OD_DATA_DIR = dataDir;
process.env.OD_AGENT_PROFILES_CONFIG = hostConfig;
vi.resetModules();
vi.doMock('node:os', async () => ({
...(await vi.importActual<typeof import('node:os')>('node:os')),
homedir: () => hostHome,
}));
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
const ids = AGENT_DEFS.map((def) => def.id);
assert.equal(ids.includes('host-wrapper'), false);
assert.equal(ids.includes('sandbox-wrapper'), true);
},
);
} finally {
vi.doUnmock('node:os');
vi.resetModules();
rmSync(root, { recursive: true, force: true });
}
});
test('sandbox runtime registry ignores implicit profiles without OD_DATA_DIR', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-missing-data-'));
const hostHome = path.join(root, 'host-home');
const hostConfigDir = path.join(hostHome, '.open-design');
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
try {
mkdirSync(hostConfigDir, { recursive: true });
writeFileSync(
hostConfig,
JSON.stringify({
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
}),
);
await withEnvSnapshot(
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
async () => {
process.env.OD_SANDBOX_MODE = '1';
delete process.env.OD_DATA_DIR;
delete process.env.OD_AGENT_PROFILES_CONFIG;
vi.resetModules();
vi.doMock('node:os', async () => ({
...(await vi.importActual<typeof import('node:os')>('node:os')),
homedir: () => hostHome,
}));
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
const ids = AGENT_DEFS.map((def) => def.id);
assert.equal(ids.includes('host-wrapper'), false);
},
);
} finally {
vi.doUnmock('node:os');
vi.resetModules();
rmSync(root, { recursive: true, force: true });
}
});

View file

@ -1,62 +0,0 @@
---
/*
* Shared skill row used on `/skills/`, `/skills/mode/<slug>/`,
* `/skills/scenario/<slug>/`, and any future faceted view.
*
* Renders a `<li class="catalog-row catalog-row-skill">` with the
* canonical 5-column grid (index, thumb, body, meta, arrow). Centralizes
* the markup so all faceted views stay visually identical to the
* unfiltered index.
*/
import type { SkillRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';
export interface Props {
skill: SkillRecord;
index: number;
}
const { skill, index } = Astro.props;
const locale = localeFromPath(Astro.url.pathname);
const href = (path: string) => localizedHref(path, locale);
// Catalog row thumbs are tiny (~130×80 rendered, single-format PNGs)
// so we deliberately bypass the precise IntersectionObserver pipeline.
// On long lists like /skills/instructions/ (96 rows) the observer's
// swap latency stranded mid-page rows on the SVG placeholder during
// fast scrolls. Native lazy loading (the browser's own 1250-3000px
// lookahead) keeps the upcoming rows pre-fetched without the
// observer round-trip; only the first three rows go eager so they
// paint immediately on first paint instead of waiting for the
// browser's lazy queue.
const eager = index < 3;
---
<li class="catalog-row catalog-row-skill">
<a href={href(`/skills/${skill.slug}/`)}>
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
<span class="row-thumb">
{skill.previewUrl ? (
<img
src={skill.previewUrl}
alt=""
loading={eager ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={eager ? 'high' : 'auto'}
/>
) : (
<span class="row-thumb-empty" aria-hidden="true" />
)}
</span>
<span class="row-body">
<span class="row-name">{skill.name}</span>
<span class="row-desc">{skill.description}</span>
</span>
<span class="row-meta">
{skill.modeLabel && <span class="meta-tag">{skill.modeLabel}</span>}
{skill.scenarioLabel && <span class="meta-tag muted">{skill.scenarioLabel}</span>}
{skill.platformLabel && <span class="meta-tag muted">{skill.platformLabel}</span>}
</span>
<span class="row-arrow" aria-hidden="true">→</span>
</a>
</li>

View file

@ -1,8 +1,14 @@
---
/*
* Shared system card used on `/systems/` and
* `/systems/category/<slug>/`. Displays palette swatches, name,
* category, and tagline as a clickable card.
* Shared system card used on `/plugins/systems/`. Displays palette
* swatches, name, category, and tagline as a clickable card.
*
* The card links to `/systems/<slug>/`, which `public/_redirects`
* 301s to the bundled-plugin detail (`/plugins/design-system-<slug>/`)
* for the 142 systems that have one, and degrades the 8 without a
* detail page to `/plugins/systems/`. Linking through the redirect
* (rather than hard-coding `design-system-<slug>`) keeps those 8 from
* pointing at a non-existent detail page.
*/
import type { SystemRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';

View file

@ -222,9 +222,9 @@ const INFO_PAGE_COPY: Partial<Record<LandingLocaleCode, InfoPageCopy>> = {
{ label: 'Community', name: 'Discord' },
{ label: 'Documentation', name: 'GitHub README' },
{ label: 'License', name: 'Apache-2.0' },
{ label: 'Skills catalog', name: '/skills/' },
{ label: 'Systems catalog', name: '/systems/' },
{ label: 'Templates catalog', name: '/templates/' },
{ label: 'Skills catalog', name: '/plugins/skills/' },
{ label: 'Systems catalog', name: '/plugins/systems/' },
{ label: 'Templates catalog', name: '/plugins/templates/' },
],
aliasesTitle: 'Naming & aliases',
aliasesLead:
@ -538,9 +538,9 @@ INFO_PAGE_COPY.zh = {
{ label: '社区', name: 'Discord' },
{ label: '文档', name: 'GitHub README' },
{ label: '许可证', name: 'Apache-2.0' },
{ label: 'Skill 目录', name: '/skills/' },
{ label: '系统目录', name: '/systems/' },
{ label: '模板目录', name: '/templates/' },
{ label: 'Skill 目录', name: '/plugins/skills/' },
{ label: '系统目录', name: '/plugins/systems/' },
{ label: '模板目录', name: '/plugins/templates/' },
],
aliasesTitle: '命名与别名',
aliasesLead: '不同工具、受众和语言环境里,这个项目会以几种方式被搜索和书写:',
@ -1027,9 +1027,9 @@ const sourceNames = [
'Discord',
'GitHub README',
'Apache-2.0',
'/skills/',
'/systems/',
'/templates/',
'/plugins/skills/',
'/plugins/systems/',
'/plugins/templates/',
] as const;
const aliasLabels = [

View file

@ -730,23 +730,23 @@ export default function Page({
</h2>
</div>
<div className='pills' data-reveal='right'>
<a className='pill active' href={href('/skills/')}>
<a className='pill active' href={href('/plugins/skills/')}>
{home.labs.pills.all}
<span className='count'>{skills}</span>
</a>
<a className='pill' href={href('/skills/mode/prototype/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.prototype}
<span className='count'>{prototypeCount}</span>
</a>
<a className='pill' href={href('/skills/mode/deck/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.deck}
<span className='count'>{deckCount}</span>
</a>
<a className='pill' href={href('/skills/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.mobile}
<span className='count'>{mobileCount}</span>
</a>
<a className='pill' href={href('/skills/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.office}
<span className='count'></span>
</a>
@ -839,7 +839,7 @@ export default function Page({
{home.labs.foot(skills)}
{NBSP}·{NBSP}
<a
href={href('/skills/')}
href={href('/plugins/skills/')}
className='library-link'
style={{ color: 'var(--coral)' }}
>
@ -953,7 +953,7 @@ export default function Page({
{home.work.titleSuffix}
<span className='dot'>.</span>
</h2>
<a className='work-link' href={href('/skills/')}>
<a className='work-link' href={href('/plugins/skills/')}>
{home.work.viewAll(skills)}
</a>
</div>
@ -1325,17 +1325,17 @@ export default function Page({
<h5>{home.footer.columns.library}</h5>
<ul>
<li>
<a href={href('/skills/')}>
<a href={href('/plugins/skills/')}>
{home.footer.libraryLinks.skills(skills)}
</a>
</li>
<li>
<a href={href('/systems/')}>
<a href={href('/plugins/systems/')}>
{home.footer.libraryLinks.systems(systems)}
</a>
</li>
<li>
<a href={href('/templates/')}>
<a href={href('/plugins/templates/')}>
{home.footer.libraryLinks.templates}
</a>
</li>

View file

@ -2,17 +2,7 @@
import { getCollection } from 'astro:content';
import Layout from '../../_components/sub-page-layout.astro';
import type { HeaderProps } from '../../_components/header';
import LazyImg from '../../_components/lazy-img.astro';
import {
getCraftRecords,
getSkillModeIndex,
getSkillRecords,
getSkillScenarioIndex,
getSystemCategoryIndex,
getSystemRecords,
getTemplateRecords,
tally,
} from '../../_lib/catalog';
import { getCraftRecords } from '../../_lib/catalog';
import {
PREFIXED_LOCALES,
getCopy,
@ -23,31 +13,17 @@ import {
import '../../globals.css';
import '../../sub-pages.css';
// Localized routing only generates listing/index pages. Detail pages
// (individual skills, posts, templates, …) stay at canonical English
// URLs to keep the static build bounded; the localized chrome links
// straight to those canonical detail URLs.
// Localized routing only generates the `craft` and `blog` listing pages.
// Detail pages (individual posts, craft items, …) stay at canonical
// English URLs to keep the static build bounded; the localized chrome
// links straight to those canonical detail URLs.
export async function getStaticPaths() {
const skillModes = await getSkillModeIndex();
const skillScenarios = await getSkillScenarioIndex();
const systemCategories = await getSystemCategoryIndex();
const paths = [
'skills',
'systems',
'craft',
'templates',
'blog',
// Plugins library is generated via short-code wrappers under
// `app/pages/[locale]/plugins/` (mirroring the `[locale]/skills/`,
// `[locale]/systems/`, etc. pattern), so it does NOT participate
// in this long-code catch-all. Both surfaces co-exist in `out/`
// because `_redirects` maps `/zh-CN/*` → `/zh/*` for the long-form
// routes; plugins lives under the short-form path only.
...skillModes.map((item) => `skills/mode/${item.slug}`),
...skillScenarios.map((item) => `skills/scenario/${item.slug}`),
...systemCategories.map((item) => `systems/category/${item.slug}`),
];
// The skills / systems / templates catalogs moved under `/plugins/*`.
// Their old localized listings are now 301'd by `public/_redirects`,
// so this catch-all only renders the localized `craft` and `blog`
// listings. Plugins itself is generated via short-code wrappers under
// `app/pages/[locale]/plugins/`, so it does NOT participate here.
const paths = ['craft', 'blog'];
return PREFIXED_LOCALES.flatMap((locale) =>
paths.map((path) => ({
@ -62,34 +38,18 @@ const copy = getCopy(locale);
const pathParam = Astro.params.path ?? '';
const segments = pathParam.split('/').filter(Boolean);
const [skills, systems, craft, templates, posts] = await Promise.all([
getSkillRecords(),
getSystemRecords(),
const [craft, posts] = await Promise.all([
getCraftRecords(),
getTemplateRecords(),
getCollection('blog'),
]);
// All cross-locale subpage links resolve to canonical (English) URLs.
const href = (path: string) => path;
const titleSuffix = 'Open Design';
const routeRoot = segments[0] ?? '';
const routeSecond = segments[1] ?? '';
const routeThird = segments[2] ?? '';
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const modeTags = await getSkillModeIndex();
const scenarioTags = await getSkillScenarioIndex();
const systemCategories = await getSystemCategoryIndex();
const platformTally = tally(skills.map((skill) => skill.platform).filter((item): item is string => Boolean(item)));
const pageTitle = routeRoot === 'skills'
? `${copy.skillsTitle} — ${skills.length} | ${titleSuffix}`
: routeRoot === 'systems'
? `${copy.systemsTitle} — ${systems.length} | ${titleSuffix}`
: routeRoot === 'templates'
? `${copy.templatesTitle} — ${templates.length} | ${titleSuffix}`
: routeRoot === 'craft'
const pageTitle = routeRoot === 'craft'
? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
: `${copy.blog} — ${titleSuffix}`;
@ -123,61 +83,6 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
</>
)}
{routeRoot === 'skills' && (
<>
<header class='catalog-head'>
<span class='label'>{copy.catalog} · Nº 01</span>
<h1 class='display'><em>{copy.skillsTitle}</em> — {skills.length} composable design capabilities<span class='dot'>.</span></h1>
<p class='lead'>Each skill is a folder with one <code>SKILL.md</code>. Drop it in, restart the daemon, and the picker shows it.</p>
</header>
{routeSecond === '' && (
<section class='filter-strip' aria-label='Skill filters'>
<div class='filter-group'>
<span class='filter-label'>{copy.mode}</span>
<ul>{modeTags.map((tag) => <li><a class='chip chip-link' href={href(`/skills/mode/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
</div>
<div class='filter-group'>
<span class='filter-label'>{copy.scenario}</span>
<ul>{scenarioTags.slice(0, 12).map((tag) => <li><a class='chip chip-link' href={href(`/skills/scenario/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
</div>
<div class='filter-group'>
<span class='filter-label'>{copy.platform}</span>
<ul>{platformTally.map(([key, count]) => <li><span class='chip'>{key}<span class='chip-num'>{count}</span></span></li>)}</ul>
</div>
</section>
)}
<section class='catalog-grid catalog-grid-skills'>
<ol>
{skills
.filter((skill) => routeSecond === 'mode' ? skill.mode === routeThird : routeSecond === 'scenario' ? skill.scenario === routeThird : true)
.map((skill, index) => (
<li class='catalog-row'>
<a href={href(`/skills/${skill.slug}/`)}>
<span class='row-index'>{String(index + 1).padStart(2, '0')}</span>
<span class='row-body'><span class='row-name'>{skill.name}</span><span class='row-desc'>{skill.description}</span></span>
{skill.mode && <span class='meta-tag'>{skill.mode}</span>}
</a>
</li>
))}
</ol>
</section>
</>
)}
{routeRoot === 'systems' && (
<>
<header class='catalog-head'>
<span class='label'>{copy.catalog} · Nº 02</span>
<h1 class='display'><em>{copy.systemsTitle}</em> — {systems.length} portable visual systems<span class='dot'>.</span></h1>
<p class='lead'>Each system is a single <code>DESIGN.md</code> token spec that keeps colors, type, spacing, and components consistent.</p>
</header>
{routeSecond === '' && <section class='filter-strip'><div class='filter-group'><span class='filter-label'>{copy.category}</span><ul>{systemCategories.map((tag) => <li><a class='chip chip-link' href={href(`/systems/category/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul></div></section>}
<section class='catalog-grid systems-grid'>
<ul>{systems.filter((system) => routeSecond === 'category' ? system.category === routeThird : true).map((system) => <li class='system-card'><a href={href(`/systems/${system.slug}/`)}><span class='system-name'>{system.name}</span><p>{system.tagline}</p><span class='meta-tag'>{system.category}</span></a></li>)}</ul>
</section>
</>
)}
{routeRoot === 'craft' && (
<>
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 03</span><h1 class='display'><em>{copy.craftTitle}</em> — {craft.length} rendering principles<span class='dot'>.</span></h1><p class='lead'>Quality rules for accessibility, motion, color, type, and state coverage.</p></header>
@ -185,11 +90,4 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
</>
)}
{routeRoot === 'templates' && (
<>
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 04</span><h1 class='display'><em>{copy.templatesTitle}</em> — {templates.length} ready-to-fork artifacts<span class='dot'>.</span></h1><p class='lead'>Pre-wired artifact bundles with examples, visual language, and agent instructions.</p></header>
<section class='template-grid'><ul>{templates.map((template, index) => <li class='template-card'><a href={href(template.detailHref)}>{template.previewUrl && <span class='template-thumb'><LazyImg src={template.previewUrl} alt='' loading={index < 4 ? 'eager' : 'precise'} /></span>}<span class='template-name'>{template.name}</span><p class='template-summary'>{template.summary}</p></a></li>)}</ul></section>
</>
)}
</Layout>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -207,8 +207,8 @@ const jsonLd = [
<h2>{page.nextTitle}</h2>
<ul>
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul>
</section>

View file

@ -81,7 +81,7 @@ const bottomCta =
? {
title: ui.blog.cta.skillsTitle,
body: ui.blog.cta.skillsBody,
href: '/skills/',
href: '/plugins/skills/',
label: ui.blog.cta.skillsLabel,
external: false,
}

View file

@ -1058,7 +1058,7 @@ pnpm -F @html-anything/next dev
</p>
<p>
<a class="ha-btn" href={href('/')}>{copy.visitOpenDesign}</a>
<a class="ha-btn" href={href('/skills/')} rel="noopener">{copy.browseSkills}</a>
<a class="ha-btn" href={href('/plugins/skills/')} rel="noopener">{copy.browseSkills}</a>
<a class="ha-btn" href={HA_URL} rel="noopener">{copy.githubLink}</a>
</p>
</section>

View file

@ -45,9 +45,9 @@ const sources = [
{ ...page.sources[4], href: DISCORD },
{ ...page.sources[5], href: DOCS },
{ ...page.sources[6], href: REPO_LICENSE },
{ ...page.sources[7], href: href('/skills/') },
{ ...page.sources[8], href: href('/systems/') },
{ ...page.sources[9], href: href('/templates/') },
{ ...page.sources[7], href: href('/plugins/skills/') },
{ ...page.sources[8], href: href('/plugins/systems/') },
{ ...page.sources[9], href: href('/plugins/templates/') },
];
const jsonLd = [
@ -140,8 +140,8 @@ const jsonLd = [
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/agents/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
</ul>
</section>
</article>

View file

@ -12,10 +12,7 @@
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SystemCard from '../../../_components/system-card.astro';
import {
getSystemRecords,
getSystemCategoryIndex,
} from '../../../_lib/catalog';
import { getSystemRecords } from '../../../_lib/catalog';
import { getPluginsCopy } from '../../../_lib/plugins-i18n';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
@ -24,7 +21,6 @@ const ui = getLandingUiCopy(locale);
const pcopy = getPluginsCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const systems = await getSystemRecords(locale);
const categoryTags = await getSystemCategoryIndex(locale);
const title = `${pcopy.tileSystems} · ${systems.length} · Open Design`;
const description = pcopy.systemsLead;
@ -54,21 +50,6 @@ const jsonLd = {
<p class="lead">{pcopy.systemsLead}</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.systems.category}</span>
<ul>
{categoryTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
</section>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{systems.map((s) => <SystemCard system={s} />)}

View file

@ -142,8 +142,8 @@ const jsonLd = [
<section class="info-section" id="next">
<h2>{page.nextTitle}</h2>
<ul>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/compare/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={REPO_RELEASES} target="_blank" rel="noreferrer noopener">{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul>

View file

@ -1,472 +0,0 @@
---
/*
* /skills/<slug>/ — a detail page per skill.
*
* Two flavours render slightly differently:
* - `template` skills get a click-to-expand iframe of their
* `example.html` demo and stay deliberately brief — the demo is the
* content, the README is one click away on GitHub.
* - `instruction` skills (no runnable demo) instead render the full
* SKILL.md body inline, so the page reads like a brief: what the
* skill does, when it triggers, how to use it. Otherwise the page
* would be a one-line description and a row of CTAs.
*/
import { getEntry, render } from 'astro:content';
import Layout from '../../../_components/sub-page-layout.astro';
import LazyImg from '../../../_components/lazy-img.astro';
import { getSkillRecords, type SkillRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/*
* Localized share-copy template, keyed by landing locale. The brand
* keyword "open-source Claude Design alternative" stays in English
* because that's the canonical search query Google associates with
* the domain — translating it would split the entity claim. The
* surrounding sentence ("I'm using X from @opendesignai") translates
* per locale so the message reads as one coherent voice instead of
* mixing two scripts in a single share post.
*
* `{name}` and `{description}` are interpolated at render time.
* `{url}` is replaced with the canonical detail-page URL.
*/
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just discovered ${name} on @opendesignai — the open-source Claude Design alternative.
✨ Local-first · BYOK · your agent does the design.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 安利一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 本地优先 · 自带模型 · 让你自己的 agent 做设计。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 推薦一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 本地優先 · 自帶模型 · 讓你自己的 agent 做設計。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai で ${name} を発見 —— オープンソースの Claude Design 代替。
✨ ローカル優先 · BYOK · あなたのエージェントが設計する。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai에서 ${name} 발견 —— 오픈 소스 Claude Design 대안.
✨ 로컬 우선 · BYOK · 에이전트가 디자인합니다.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade entdeckt: ${name} auf @opendesignai — die Open-Source-Alternative zu Claude Design.
✨ Local-first · BYOK · dein Agent designt.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Découvert : ${name} sur @opendesignai — l'alternative open-source à Claude Design.
✨ Local-first · BYOK · votre agent fait le design.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Нашёл ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Локально · BYOK · агент сам делает дизайн.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de descubrir ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Local-first · BYOK · tu agente diseña.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de descobrir ${name} no @opendesignai — a alternativa open-source ao Claude Design.
✨ Local-first · BYOK · seu agente faz o design.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena scoperto ${name} su @opendesignai — l'alternativa open-source a Claude Design.
✨ Local-first · BYOK · il tuo agente progetta.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa khám phá ${name} trên @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Ưu tiên local · BYOK · agent của bạn thiết kế.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie odkryłem ${name} na @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Local-first · BYOK · twój agent projektuje.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru nemu ${name} di @opendesignai — alternatif open-source untuk Claude Design.
✨ Local-first · BYOK · agent kamu yang nge-desain.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ontdekt: ${name} op @opendesignai — het open-source alternatief voor Claude Design.
✨ Local-first · BYOK · jouw agent ontwerpt.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 اكتشفت للتو ${name} على @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ محلي أولًا · BYOK · وكيلك يصمّم.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 Yeni keşfettim: ${name} (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Local-first · BYOK · ajanın tasarlıyor.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Щойно знайшов ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Local-first · BYOK · ваш агент робить дизайн.
→ ${url}`,
};
export async function getStaticPaths() {
const skills = await getSkillRecords();
return skills.map((skill) => ({
params: { slug: skill.slug },
props: { skill, all: skills },
}));
}
interface Props {
skill: SkillRecord;
all: ReadonlyArray<SkillRecord>;
}
const { skill: routeSkill, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSkillRecords(locale);
const skill = all.find((item) => item.slug === routeSkill.slug) ?? routeSkill;
const title = ui.catalog.skills.detailTitle(skill.name);
const description = skill.description.length > 0
? skill.description
: ui.catalog.skills.detailFallbackDescription(skill.name);
const skillUrl = `https://open-design.ai/skills/${skill.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: skill.name,
description,
url: skillUrl,
});
// Share-dialog UI strings localized inline. Keeping them next to the
// page that uses them avoids growing the global UI bundle for what's
// effectively four short labels per locale.
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this skill', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个 skill', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個 skill', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'この skill を共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 skill 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diesen Skill teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce skill', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться скиллом', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir este skill', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar skill', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi lo skill', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ skill', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij ten skill', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan skill ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze skill', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذه المهارة', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu skilli paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись скілом', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
const related = all
.filter((s) => s.slug !== skill.slug)
.filter((s) => s.mode === skill.mode || s.scenario === skill.scenario)
.slice(0, 4);
/*
* Instruction skills don't have a runnable demo to iframe — to avoid
* a near-empty detail page, render the SKILL.md prose inline so the
* page reads like a brief. Template skills keep the page deliberately
* brief because their demo is the content; their full SKILL.md is one
* "Find on GitHub" click away.
*
* Astro 6 exposes the markdown pipeline through a top-level
* `render(entry)` helper rather than the legacy `entry.render()`
* method. The output (heading anchors, smart-typography, GFM
* tables) styles cleanly with the existing `.detail-md` rules.
*/
const skillEntry =
skill.kind === 'instruction' ? await getEntry('skills', `${skill.slug}/SKILL`) : null;
const SkillBody = skillEntry ? (await render(skillEntry)).Content : null;
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.skills.detailLabel, item: new URL('/skills/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: skill.name, item: new URL(`/skills/${skill.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareSourceCode',
name: skill.name,
description,
codeRepository: skill.source,
programmingLanguage: 'Markdown',
keywords: skill.triggers.join(', '),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
},
];
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span>/</span>
<span aria-current="page">{skill.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.skills.detailLabel}
{typeof skill.featured === 'number' && (
<span class="ix">{ui.catalog.skills.featuredNumber(String(skill.featured).padStart(2, '0'))}</span>
)}
</span>
<h1 class="display">{skill.name}<span class="dot">.</span></h1>
<p class="lead">{description}</p>
<div class="detail-actions">
{/*
Two primary CTAs. "Use this skill" v1 sends users to the OD
desktop release page — install the app first, then run the
skill. Routing here rather than to /quickstart/ keeps the
flow concrete (download a binary now) instead of asking
users to read an install doc. Once the desktop client
exposes a registered URL scheme, this anchor flips to a
JS-driven `od://skill/<slug>` try + fallback without
changing the page surface.
*/}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this skill →
</a>
<a
class="btn btn-ghost"
href={skill.source}
target="_blank"
rel="noopener"
>
Find on GitHub →
</a>
{skill.upstream && (
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
{ui.catalog.skills.upstream}
</a>
)}
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`skill:${skill.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{skill.kind === 'template' && skill.previewUrl && (
<figure class="detail-preview">
{/*
Click-to-expand interactive preview. Only template-kind skills
ship a runnable example.html, so this block is gated on kind
rather than just `previewUrl` — instruction skills now have a
synthesized cover thumbnail too, but no iframe target. The
thumb is the summary of a `<details>` element: clicking opens
the live iframe, replacing the thumb with the canonical
`<slug>/example.html` rendered inside a sandboxed frame.
*/}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${skill.name}`}>
<LazyImg
src={skill.previewUrl}
alt={`${skill.name} example output`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={`/skills/${skill.slug}/example.html`}
title={`${skill.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={`/skills/${skill.slug}/example.html`}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>
{ui.catalog.skills.previewCaption(skill.slug)}
</figcaption>
</figure>
)}
{/*
Share modal — opens a `<dialog>` containing the canonical share
copy (with the brand keyword "open-source Claude Design
alternative" baked in), a one-click "Copy" button, and a row of
platform jump buttons. Each platform button just opens the
vendor's compose URL — the user pastes the already-copied text.
This works around a real cross-platform pain point: LinkedIn /
Facebook ignore pre-fill `text` params, X has length limits that
truncate Chinese content unpredictably, and Reddit's title param
survives but title-only is a weak signal. Copy-then-paste is
uniformly reliable.
The trigger sits inside `.detail-actions` instead of as a
separate row below `.detail-meta` so it has visual weight equal
to the primary CTAs. Joey called this out specifically.
*/}
<dialog
class="detail-share-dialog"
data-share-dialog={`skill:${skill.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={skillUrl}
>
{shareUi.copyLink}
</button>
</div>
{/*
Platform jump buttons — official brand logos rendered as
inline SVG (no third-party icon font, no client JS). Each
opens the vendor's compose surface in a new tab; the user
pastes the already-copied text. Email channel was dropped
per Joey's revision; the four channels here cover the
highest-value SEO + virality surfaces.
*/}
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<dl class="detail-meta">
{skill.mode && (
<Fragment>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{skill.modeLabel ?? skill.mode}</dd>
</Fragment>
)}
{skill.scenario && (
<Fragment>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{skill.scenarioLabel ?? skill.scenario}</dd>
</Fragment>
)}
{skill.platform && (
<Fragment>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{skill.platformLabel ?? skill.platform}</dd>
</Fragment>
)}
{skill.category && (
<Fragment>
<dt>{ui.catalog.systems.category}</dt>
<dd>{skill.categoryLabel ?? skill.category}</dd>
</Fragment>
)}
</dl>
{skill.triggers.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.triggers}</h2>
<p class="block-lead">
{ui.catalog.skills.triggersLead}
</p>
<ul class="trigger-list">
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
</ul>
</section>
)}
{skill.examplePrompt && (
<section class="detail-block">
<h2>{ui.catalog.skills.examplePrompt}</h2>
<pre class="example-prompt">{skill.examplePrompt}</pre>
</section>
)}
{SkillBody && (
<section class="detail-block detail-md">
<h2>About this skill</h2>
<SkillBody />
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.related}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/skills/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.description}</span>
<span class="related-meta">
{r.modeLabel && <span class="meta-tag">{r.modeLabel}</span>}
{r.scenarioLabel && <span class="meta-tag muted">{r.scenarioLabel}</span>}
</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -1,133 +0,0 @@
---
/*
* /skills/ — index of every shippable skill in the repo.
*
* Pulls live data from `skills/<slug>/SKILL.md` via Astro Content
* Collections so adding a skill anywhere in the monorepo
* automatically surfaces here on the next build.
*/
import Layout from '../../_components/sub-page-layout.astro';
import LazyImg from '../../_components/lazy-img.astro';
import SkillRow from '../../_components/skill-row.astro';
import {
getSkillRecords,
getSkillModeIndex,
getSkillScenarioIndex,
tally,
} from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const skills = await getSkillRecords(locale);
const modeTags = await getSkillModeIndex(locale);
const scenarioTags = await getSkillScenarioIndex(locale);
const platformTally = tally(
skills.map((s) => s.platformLabel).filter((p): p is string => Boolean(p)),
);
const featured = skills.filter((s) => typeof s.featured === 'number').slice(0, 6);
const title = ui.catalog.skills.title(skills.length);
const description = ui.catalog.skills.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/skills/', Astro.site).toString(),
isPartOf: {
'@type': 'WebSite',
name: 'Open Design',
url: Astro.site?.toString(),
},
numberOfItems: skills.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.heading(skills.length)}
</h1>
<p class="lead">
{ui.catalog.skills.lead}
</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.skills.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.mode}</span>
<ul>
{modeTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/skills/mode/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.scenario}</span>
<ul>
{scenarioTags.slice(0, 12).map((tag) => (
<li>
<a class="chip chip-link" href={href(`/skills/scenario/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
{platformTally.length > 0 && (
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.platform}</span>
<ul>
{platformTally.map(([key, count]) => (
<li>
<span class="chip">
{key}<span class="chip-num">{count}</span>
</span>
</li>
))}
</ul>
</div>
)}
</section>
{featured.length > 0 && (
<section class="featured-strip" aria-labelledby="featured-skills">
<h2 id="featured-skills" class="strip-title">{ui.catalog.skills.featured}</h2>
<ul class="featured-grid">
{featured.map((s, i) => (
<li class="featured-card">
<a href={href(`/skills/${s.slug}/`)}>
{s.previewUrl ? (
<span class="featured-thumb">
<LazyImg src={s.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
</span>
) : (
<span class="featured-thumb featured-thumb-empty" aria-hidden="true" />
)}
<span class="featured-num">Nº {String(s.featured).padStart(2, '0')}</span>
<span class="featured-name">{s.name}</span>
<p>{s.description}</p>
{s.modeLabel && <span class="meta-tag">{s.modeLabel}</span>}
</a>
</li>
))}
</ul>
</section>
)}
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{skills.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,78 +0,0 @@
---
/*
* /skills/mode/<slug>/ — every skill that emits a given artifact mode
* (deck, prototype, template, image, video, audio, design-system, utility).
*
* One static page per distinct `od.mode` value. Mode is the strongest
* mental-model facet ("I want a deck-builder") so this is the primary
* faceted view; scenario/category live alongside.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillModeIndex,
getSkillsForMode,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSkillModeIndex();
return tags.map((tag) => ({
params: { mode: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSkillsForMode(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.skills.filterTitle(heading, records.length);
const description = ui.catalog.skills.modeDescription(heading, records.length);
const url = new URL(`/skills/mode/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.skills.mode}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.modeHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.skills.modeLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/skills/')}>{ui.catalog.skills.allSkills(tag.count)}</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,77 +0,0 @@
---
/*
* /skills/scenario/<slug>/ — every skill targeting a given use-case
* scenario (marketing, engineering, design, research, ...).
*
* Mirrors the mode page but facets on `od.scenario`. One page per
* distinct scenario value found across all SKILL.md files.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillScenarioIndex,
getSkillsForScenario,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSkillScenarioIndex();
return tags.map((tag) => ({
params: { scenario: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSkillsForScenario(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.skills.filterTitle(heading, records.length);
const description = ui.catalog.skills.scenarioDescription(heading, records.length);
const url = new URL(`/skills/scenario/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.skills.scenario}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.scenarioHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.skills.scenarioLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/skills/')}>{ui.catalog.skills.allSkills()}</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,126 +0,0 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import { getSystemRecords, type SystemRecord } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
export async function getStaticPaths() {
const systems = await getSystemRecords();
return systems.map((system) => ({
params: { slug: system.slug },
props: { system, all: systems },
}));
}
interface Props {
system: SystemRecord;
all: ReadonlyArray<SystemRecord>;
}
const { system: routeSystem, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSystemRecords(locale);
const system = all.find((item) => item.slug === routeSystem.slug) ?? routeSystem;
const title = ui.catalog.systems.detailTitle(system.name);
const description = system.tagline
? `${system.name} (${system.categoryLabel}) — ${system.tagline}`
: ui.catalog.systems.detailFallbackDescription(system.name, system.categoryLabel);
const related = all
.filter((s) => s.slug !== system.slug && s.category === system.category)
.slice(0, 4);
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.systems.detailLabel, item: new URL('/systems/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: system.name, item: new URL(`/systems/${system.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: system.name,
description,
url: new URL(`/systems/${system.slug}/`, Astro.site).toString(),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
genre: system.categoryLabel,
},
];
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
<span>/</span>
<span aria-current="page">{system.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.systems.detailLabel}
<span class="ix">· {system.categoryLabel}</span>
</span>
<h1 class="display">{system.name}<span class="dot">.</span></h1>
{system.tagline && <p class="lead">{system.tagline}</p>}
<div class="detail-actions">
<a class="btn btn-primary" href={system.source} target="_blank" rel="noopener">
{ui.catalog.systems.viewOnGithub}
</a>
</div>
</header>
{system.palette.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.systems.paletteSample}</h2>
<p class="block-lead">
{ui.catalog.systems.paletteLead(system.palette.length)}
</p>
<div class="palette-row">
{system.palette.map((hex) => (
<div class="palette-cell">
<span class="swatch" style={`background:${hex}`} />
<code>{hex}</code>
</div>
))}
</div>
</section>
)}
{system.atmosphere && (
<section class="detail-block">
<h2>{ui.catalog.systems.visualTheme}</h2>
<p class="atmosphere">{system.atmosphere}</p>
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.systems.related(system.categoryLabel)}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/systems/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.tagline}</span>
<div class="system-swatches" aria-hidden="true">
{r.palette.slice(0, 4).map((hex) => (
<span class="swatch" style={`background:${hex}`} />
))}
</div>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -1,74 +0,0 @@
---
/*
* /systems/category/<slug>/ — every design system grouped by category
* (AI & LLM, Productivity & SaaS, Editorial, Brand, ...).
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SystemCard from '../../../_components/system-card.astro';
import {
getSystemCategoryIndex,
getSystemsForCategory,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSystemCategoryIndex();
return tags.map((tag) => ({
params: { category: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSystemsForCategory(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.systems.categoryHeading(heading, records.length);
const description = ui.catalog.systems.categoryDescription(heading, records.length);
const url = new URL(`/systems/category/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.systems.category}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.systems.label}</span>
<h1 class="display">
{ui.catalog.systems.categoryHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.systems.categoryLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/systems/')}>{ui.catalog.systems.allSystems}</a>
</p>
</header>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{records.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -1,61 +0,0 @@
---
/*
* /systems/ — index of every portable design system in the repo.
*/
import Layout from '../../_components/sub-page-layout.astro';
import SystemCard from '../../_components/system-card.astro';
import { getSystemRecords, getSystemCategoryIndex } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const systems = await getSystemRecords(locale);
const categoryTags = await getSystemCategoryIndex(locale);
const title = ui.catalog.systems.title(systems.length);
const description = ui.catalog.systems.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/systems/', Astro.site).toString(),
numberOfItems: systems.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.systems.label}</span>
<h1 class="display">
{ui.catalog.systems.heading(systems.length)}
</h1>
<p class="lead">
{ui.catalog.systems.lead}
</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.systems.category}</span>
<ul>
{categoryTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
</section>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{systems.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -1,356 +0,0 @@
---
/*
* /templates/<slug>/ — detail page for renderable design templates and
* legacy Live Artifact template bundles.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import LazyImg from '../../../_components/lazy-img.astro';
import { getTemplateRecords, type TemplateRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/* See pages/skills/[slug]/index.astro for the rationale on why these
* tables live inline rather than in the global UI bundle. Same shape,
* just keyed for the templates surface. */
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just forked ${name} from @opendesignai — the open-source Claude Design alternative.
✨ Templates as files, not vendor docs. Fork → swap → ship.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 fork 了一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 模板就是文件,不是 vendor 数据。Fork → 换数据 → 发。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 fork 了一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 模板就是檔案,不是 vendor 資料。Fork → 換資料 → 發佈。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai の ${name} を fork —— オープンソースの Claude Design 代替。
✨ テンプレートはファイル、ベンダー DB じゃない。Fork → 差し替え → 出荷。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai의 ${name} fork —— 오픈 소스 Claude Design 대안.
✨ 템플릿은 파일, 벤더 DB가 아닙니다. Fork → 교체 → 출시.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade ${name} von @opendesignai geforkt — die Open-Source-Alternative zu Claude Design.
✨ Vorlagen als Dateien, nicht als Vendor-DB. Fork → swap → ship.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Je viens de forker ${name} de @opendesignai — l'alternative open-source à Claude Design.
✨ Modèles = fichiers, pas une base vendeur. Fork → swap → ship.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Форкнул ${name} с @opendesignai — open-source альтернативу Claude Design.
✨ Шаблоны — это файлы, не vendor-DB. Fork → swap → ship.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de hacer fork de ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Plantillas como archivos, no como vendor DB. Fork → swap → ship.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de dar fork em ${name} do @opendesignai — a alternativa open-source ao Claude Design.
✨ Templates como arquivos, não como vendor DB. Fork → swap → ship.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena forkato ${name} da @opendesignai — l'alternativa open-source a Claude Design.
✨ Template come file, non come DB vendor. Fork → swap → ship.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa fork ${name} từ @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Template là file, không phải DB của vendor. Fork → đổi data → ship.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie sforkowałem ${name} z @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Szablony jako pliki, nie vendor DB. Fork → swap → ship.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru fork ${name} dari @opendesignai — alternatif open-source untuk Claude Design.
✨ Template itu file, bukan vendor DB. Fork → tukar data → ship.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ${name} geforkt van @opendesignai — het open-source alternatief voor Claude Design.
✨ Templates als bestanden, niet als vendor-DB. Fork → swap → ship.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 fork للتو ${name} من @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ القوالب ملفات، ليست قاعدة بيانات للمزوّد. Fork → swap → ship.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 ${name} fork'ladım (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Şablonlar dosya, vendor DB değil. Fork → swap → ship.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Форкнув ${name} з @opendesignai — open-source альтернативу Claude Design.
✨ Шаблони — це файли, а не vendor-DB. Fork → swap → ship.
→ ${url}`,
};
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this template', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个模板', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個模板', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'このテンプレートを共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 템플릿 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diese Vorlage teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce modèle', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться шаблоном', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir plantilla', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar template', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi il modello', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ template', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij szablon', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan template ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze template', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذا القالب', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu şablonu paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись шаблоном', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
export async function getStaticPaths() {
const records = await getTemplateRecords();
return records.map((template) => ({
params: { slug: template.slug },
props: { template },
}));
}
interface Props {
template: TemplateRecord;
}
const { template: routeTemplate } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const localizedTemplates = locale === 'en' ? [] : await getTemplateRecords(locale);
const template =
localizedTemplates.find((item) => item.slug === routeTemplate.slug) ?? routeTemplate;
const title = ui.catalog.templates.detailTitle(template.name);
const description = template.summary;
const templateUrl = `https://open-design.ai/templates/${template.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: template.name,
description: template.summary,
url: templateUrl,
});
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
const originLabel =
template.origin === 'live-artifact'
? ui.catalog.templates.liveArtifact
: ui.catalog.templates.skillTemplate;
const files =
template.origin === 'live-artifact'
? [
['template.html', ui.catalog.templates.renderer],
['data.json', ui.catalog.templates.seedData],
['README.md', ui.catalog.templates.readme],
]
: [
['SKILL.md', ui.catalog.skills.detailLabel],
['example.html', ui.catalog.templates.previewCaption],
['assets/', ui.catalog.templates.detailLabel],
['references/', ui.catalog.craft.detailLabel],
];
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.templates.detailLabel, item: new URL('/templates/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: template.name, item: new URL(template.detailHref, Astro.site).toString() },
],
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/templates/')}>{ui.catalog.templates.detailLabel}</a>
<span>/</span>
<span aria-current="page">{template.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.templates.detailLabel}
<span class="ix">· {originLabel}</span>
</span>
<h1 class="display">{template.name}<span class="dot">.</span></h1>
<p class="lead">{template.summary}</p>
{(template.mode || template.platform || template.scenario) && (
<dl class="detail-meta">
{template.mode && (
<>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{template.modeLabel ?? template.mode}</dd>
</>
)}
{template.platform && (
<>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{template.platformLabel ?? template.platform}</dd>
</>
)}
{template.scenario && (
<>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{template.scenarioLabel ?? template.scenario}</dd>
</>
)}
</dl>
)}
<div class="detail-actions">
{/* Two CTAs matching skills/[slug]: "Use this template" sends
users to the OD desktop release page (install first, then
use the template); "Find on GitHub" deep-links to the
source folder. See skills/[slug].astro for the broader
rationale on the release-page pivot. */}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this template →
</a>
<a class="btn btn-ghost" href={template.source} target="_blank" rel="noopener">
Find on GitHub →
</a>
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`template:${template.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{template.previewUrl && (
<figure class="detail-preview">
{/* Click-to-expand: thumb is the summary; clicking opens the
live iframe rendering the canonical artifact. Skill-template
origin → /skills/<slug>/example.html; live-artifact origin
→ /templates/<slug>/preview.html. */}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${template.name}`}>
<LazyImg
src={template.previewUrl}
alt={`${template.name} preview`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html`
: `/skills/${template.slug}/example.html`
}
title={`${template.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html`
: `/skills/${template.slug}/example.html`
}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>{ui.catalog.templates.previewCaption}</figcaption>
</figure>
)}
{/* Share modal — same shape as skills/[slug]; see that file for the
copy-then-paste rationale and SEO keyword choice. */}
<dialog
class="detail-share-dialog"
data-share-dialog={`template:${template.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={templateUrl}
>
{shareUi.copyLink}
</button>
</div>
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<section class="detail-block">
<h2>{ui.catalog.templates.whatsInside}</h2>
<p class="block-lead">
{ui.catalog.templates.whatsInsideLead}
</p>
<ul class="trigger-list">
{files.map(([name, copy]) => (
<li><code>{name}</code> — {copy}</li>
))}
</ul>
</section>
</article>
</Layout>

View file

@ -1,63 +0,0 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import LazyImg from '../../_components/lazy-img.astro';
import { getTemplateRecords } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const templates = await getTemplateRecords(locale);
const title = ui.catalog.templates.title(templates.length);
const description = ui.catalog.templates.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/templates/', Astro.site).toString(),
numberOfItems: templates.length,
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.templates.label}</span>
<h1 class="display">
{ui.catalog.templates.heading(templates.length)}
</h1>
<p class="lead">
{ui.catalog.templates.lead}
</p>
</header>
<section class="template-grid" aria-label={ui.catalog.templates.allAria}>
<ul>
{templates.map((t, i) => (
<li class="template-card">
<a href={href(t.detailHref)}>
{t.previewUrl ? (
<span class="template-thumb">
<LazyImg src={t.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
</span>
) : (
<span class="template-thumb template-thumb-empty" aria-hidden="true" />
)}
<span class={`meta-tag ${t.origin === 'live-artifact' ? 'coral' : ''}`}>
{t.origin === 'live-artifact' ? ui.catalog.templates.liveArtifact : (t.modeLabel ?? ui.catalog.templates.skillTemplate)}
</span>
<span class="template-name">{t.name}</span>
<p class="template-summary">{t.summary}</p>
{(t.platform || t.scenario) && (
<span class="template-meta-line">
{[t.platformLabel ?? t.platform, t.scenarioLabel ?? t.scenario].filter(Boolean).join(' · ')}
</span>
)}
</a>
</li>
))}
</ul>
</section>
</Layout>

View file

@ -257,11 +257,11 @@ export default defineConfig({
item.priority = 0.9;
item.changefreq = changefreq.weekly;
} else if (
path === '/skills/' ||
path === '/systems/' ||
path === '/templates/' ||
path === '/craft/' ||
path === '/plugins/'
path === '/plugins/' ||
path === '/plugins/skills/' ||
path === '/plugins/systems/' ||
path === '/plugins/templates/'
) {
item.priority = 0.7;
item.changefreq = changefreq.weekly;

View file

@ -34,3 +34,85 @@
/fa/plugins/* /plugins/:splat 301
/hu/plugins/* /plugins/:splat 301
/th/plugins/* /plugins/:splat 301
# ─────────────────────────────────────────────────────────────────────
# Catalog migration: legacy /skills /systems /templates -> /plugins/*
# The old Astro generators were removed; these 301s preserve inbound
# links and SEO equity. Cloudflare matches first rule wins, so order is:
# faceted/specific -> detail prefixes -> bare index -> locale variants.
# trailingSlash:'always', so every source and target ends in '/'.
# ─────────────────────────────────────────────────────────────────────
# Faceted pages have no new equivalent -> degrade to the section landing.
/skills/mode/* /plugins/skills/ 301
/skills/scenario/* /plugins/skills/ 301
/systems/category/* /plugins/systems/ 301
# Systems detail: design-system-<folder> is the uniform new slug.
# These 8 folders have no new detail page -> degrade (must precede splat).
/systems/cisco/ /plugins/systems/ 301
/systems/hud/ /plugins/systems/ 301
/systems/loom/ /plugins/systems/ 301
/systems/perplexity/ /plugins/systems/ 301
/systems/slack/ /plugins/systems/ 301
/systems/trading-terminal/ /plugins/systems/ 301
/systems/webex/ /plugins/systems/ 301
/systems/wechat/ /plugins/systems/ 301
/systems/* /plugins/design-system-:splat 301
# Templates detail: example-<folder> is the uniform new slug.
/templates/live-otd-operations-brief/ /plugins/templates/ 301
/templates/* /plugins/example-:splat 301
# Skills detail: only these 27 have a new artifact-template equivalent.
# 'replicate' collides with design-system-replicate -> force the section.
/skills/replicate/ /plugins/skills/ 301
/skills/article-magazine/ /plugins/example-article-magazine/ 301
/skills/card-twitter/ /plugins/example-card-twitter/ 301
/skills/card-xiaohongshu/ /plugins/example-card-xiaohongshu/ 301
/skills/data-report/ /plugins/example-data-report/ 301
/skills/deck-guizang-editorial/ /plugins/example-deck-guizang-editorial/ 301
/skills/deck-open-slide-canvas/ /plugins/example-deck-open-slide-canvas/ 301
/skills/deck-swiss-international/ /plugins/example-deck-swiss-international/ 301
/skills/design-brief/ /plugins/example-design-brief/ 301
/skills/doc-kami-parchment/ /plugins/example-doc-kami-parchment/ 301
/skills/frame-data-chart-nyt/ /plugins/example-frame-data-chart-nyt/ 301
/skills/frame-flowchart-sticky/ /plugins/example-frame-flowchart-sticky/ 301
/skills/frame-glitch-title/ /plugins/example-frame-glitch-title/ 301
/skills/frame-light-leak-cinema/ /plugins/example-frame-light-leak-cinema/ 301
/skills/frame-liquid-bg-hero/ /plugins/example-frame-liquid-bg-hero/ 301
/skills/frame-logo-outro/ /plugins/example-frame-logo-outro/ 301
/skills/frame-macos-notification/ /plugins/example-frame-macos-notification/ 301
/skills/hatch-pet/ /plugins/example-hatch-pet/ 301
/skills/mockup-device-3d/ /plugins/example-mockup-device-3d/ 301
/skills/poster-hero/ /plugins/example-poster-hero/ 301
/skills/ppt-keynote/ /plugins/example-ppt-keynote/ 301
/skills/pptx-html-fidelity-audit/ /plugins/example-pptx-html-fidelity-audit/ 301
/skills/resume-modern/ /plugins/example-resume-modern/ 301
/skills/social-reddit-card/ /plugins/example-social-reddit-card/ 301
/skills/social-spotify-card/ /plugins/example-social-spotify-card/ 301
/skills/social-x-post-card/ /plugins/example-social-x-post-card/ 301
/skills/vfx-text-cursor/ /plugins/example-vfx-text-cursor/ 301
/skills/video-hyperframes/ /plugins/example-video-hyperframes/ 301
# Remaining ~110 instruction-only skills have no detail page -> section.
/skills/* /plugins/skills/ 301
# Bare catalog index pages (least specific -> last).
/skills/ /plugins/skills/ 301
/systems/ /plugins/systems/ 301
/templates/ /plugins/templates/ 301
# Locale-prefixed variants (active LANDING_LOCALES minus en: zh zh-tw ja ko).
# Non-en pages are sitemap-excluded; degrade to the section (no detail precision).
/zh/skills/* /zh/plugins/skills/ 301
/zh/systems/* /zh/plugins/systems/ 301
/zh/templates/* /zh/plugins/templates/ 301
/zh-tw/skills/* /zh-tw/plugins/skills/ 301
/zh-tw/systems/* /zh-tw/plugins/systems/ 301
/zh-tw/templates/* /zh-tw/plugins/templates/ 301
/ja/skills/* /ja/plugins/skills/ 301
/ja/systems/* /ja/plugins/systems/ 301
/ja/templates/* /ja/plugins/templates/ 301
/ko/skills/* /ko/plugins/skills/ 301
/ko/systems/* /ko/plugins/systems/ 301
/ko/templates/* /ko/plugins/templates/ 301

View file

@ -47,6 +47,11 @@ import {
type InlineMentionEntity,
} from '../utils/inlineMentions';
import { isImeComposing } from '../utils/imeComposing';
import {
reconcileInsertions,
stripPluginInsertedTokens,
type TrackedInsertion,
} from '../utils/pluginInsertionTracking';
import { ANNOTATION_EVENT, type AnnotationEventDetail } from "./PreviewDrawOverlay";
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
@ -224,7 +229,23 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
) {
const t = useT();
const analytics = useAnalytics();
const [draft, setDraft] = useState(() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "");
const [draft, setDraft] = useState(
() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
);
// Synchronous mirror of the latest committed draft value.
// `updateDraft` reads this as `prev` instead of relying on the
// closure `draft` (which only updates after re-render) or
// `setDraft((prev) => …)` (whose updater is double-invoked
// under React StrictMode and would mutate
// `pluginInsertedTokensRef` twice). The ref is updated
// synchronously by `updateDraft` before `setDraft`, so the
// next call sees a fresh `prev` even when React batches
// multiple updates within one tick. Initialized from the same
// source as the React state to keep the two in lockstep on
// first render. See `updateDraft` below and #2929 round 5.
const draftRef = useRef<string>(
initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
);
// chat_panel page_view fires from ProjectView (which outlives
// conversation switches) so the event measures real chat-panel
@ -271,6 +292,77 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// or from the tools-menu "Details" affordance.
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
const pluginsSectionRef = useRef<PluginsSectionHandle | null>(null);
// Instance-aware tracking for `@<token>` mentions this surface
// inserted into the draft via the @-mention popover plugin-pick
// path (`insertPluginMention`). Each entry pins the precise
// start offset of `@`, so two `@Airbnb` mentions in the same
// draft (one composer-inserted, one user-authored) are
// distinguishable — the chip-clear strip removes only tracked
// instances (#2929 round 3). See utils/pluginInsertionTracking.ts
// for the diff/reconcile/strip primitives.
//
// Lifecycle invariants:
// - add: `insertPluginMention` pushes { token, start } using the
// `insertStart` returned by `replaceMentionWithText`
// - reconcile: `handleChange` runs LCP/LCS diff on each
// keystroke and shifts/drops entries whose offsets crossed
// the edit, plus revalidates surviving entries against the
// mention boundary so `@Airbnbify`-style corruption prunes
// - clear: `reset()` empties the array on send; `onCleared`
// strips by range and empties the array
//
// Tools-menu / details-modal applies route through
// `pluginsSectionRef.current.applyById` without writing to the
// draft, so the array stays empty for those surfaces and the
// post-clear strip is a no-op. Every draft mutation in this
// component goes through the `updateDraft` chokepoint, which
// runs `reconcileInsertions` against the prev → next diff. That
// includes typing, slash-command pick, file/MCP/connector
// insertion, skill chip remove, annotation append, imperative
// handle, post-send reset, and the on-cleared strip itself —
// so a tracked offset can never go stale relative to the draft
// and re-introduce the original #2881 orphan-mention symptom
// (#2929 round 4).
//
// Each entry carries the `pluginId` of the apply that produced
// it. When the active plugin changes (e.g. tools-menu `applyById`
// replaces plugin A with plugin B without writing to the draft),
// entries for the previous active plugin are dropped via
// `setActivePlugin`. Without that, clearing B's chip would still
// strip A's `@A` from the draft — silent user-text deletion in a
// supported replace-plugin flow (#2929 round 6).
const pluginInsertedTokensRef = useRef<TrackedInsertion[]>([]);
// The plugin id whose chip is currently mounted in PluginsSection's
// chip strip, or `null` after the strip clears or before any apply
// succeeds. Updated via `setActivePlugin`, which also drops any
// tracked entries whose `pluginId` does not match the new active
// — a no-op for `insertPluginMention` (the new entry it just
// pushed matches), critical for tools-menu / details-modal
// applies that arrive without an accompanying draft insertion.
const activePluginIdRef = useRef<string | null>(null);
// Monotonic counter that hands out unique `insertionId` strings to
// entries pushed by `insertPluginMention`. The id survives
// `reconcileInsertions` (utils/pluginInsertionTracking.ts forwards
// the field) so the in-flight handler's failure path can locate
// its own tracked entry even after intervening reconciles or
// `onCleared` mutations of the array (#2929 round 10 codex
// review). Plain ref counter is enough — the id only needs to be
// unique within a single composer instance and is never persisted.
const insertionIdSeqRef = useRef(0);
// Single chokepoint for setting the active plugin. Routes every
// `applyById` call so the tracker stays in lockstep with the
// chip strip's currently-mounted plugin.
function setActivePlugin(pluginId: string | null): void {
if (activePluginIdRef.current === pluginId) return;
if (pluginInsertedTokensRef.current.length > 0) {
pluginInsertedTokensRef.current =
pluginInsertedTokensRef.current.filter(
(entry) => entry.pluginId === pluginId,
);
}
activePluginIdRef.current = pluginId;
}
// Consolidated "tools" popover — a single dropdown anchored to the
// leading sliders icon that hosts MCP / Import / Pet quick actions and
// a shortcut to open the full Settings dialog. Replaces the previous
@ -299,7 +391,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
useEffect(() => {
if (seededRef.current) return;
if (initialDraft && initialDraft !== draft) {
setDraft(initialDraft);
updateDraft(initialDraft);
seededRef.current = true;
} else if (initialDraft === undefined) {
seededRef.current = true;
@ -614,7 +706,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// command's canonical insertion text.
const replaced = before.replace(/\/[^\s/]*$/, cmd.insert);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setSlash(null);
requestAnimationFrame(() => {
ta.focus();
@ -658,7 +750,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const trimmed = draft.trim();
if (!/^\/mcp\s*$/i.test(trimmed)) return false;
onOpenMcpSettings();
setDraft('');
updateDraft('');
return true;
}
@ -724,7 +816,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
return false;
}
}
setDraft('');
updateDraft('');
return true;
}
@ -732,7 +824,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
ref,
() => ({
setDraft: (text: string) => {
setDraft(text);
updateDraft(text);
seededRef.current = true;
requestAnimationFrame(() => {
const ta = textareaRef.current;
@ -743,7 +835,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
});
},
restoreDraft: ({ text, attachments = [], commentAttachments = [] }) => {
setDraft(text);
updateDraft(text);
setStaged(attachments);
setStagedVisualComments(commentAttachments);
setStagedSkills([]);
@ -768,8 +860,39 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
[]
);
// Single chokepoint for every draft mutation. Reconciles the
// tracked plugin-mention offsets against the prev → next diff so
// any setDraft path — typing, slash command, file/MCP/connector
// insertion, skill chip removal, annotation append, imperative
// handle, post-send reset, on-cleared strip — keeps
// `pluginInsertedTokensRef` in lockstep with the draft.
//
// Implementation note (#2929 round 5): the reconcile and the
// ref mutation happen *outside* the `setDraft` updater, using
// the synchronous `draftRef` mirror as `prev`. Putting them
// inside `setDraft((prev) => …)` would not be safe under
// React StrictMode, which double-invokes setState updaters in
// development to detect impurity — the second invocation
// would re-shift or re-drop already-reconciled entries,
// bringing back the #2881 orphan-mention symptom for every
// user keystroke in the dev build.
function updateDraft(next: string | ((prev: string) => string)): void {
const prev = draftRef.current;
const value = typeof next === 'function' ? next(prev) : next;
if (prev === value) return;
if (pluginInsertedTokensRef.current.length > 0) {
pluginInsertedTokensRef.current = reconcileInsertions(
pluginInsertedTokensRef.current,
prev,
value,
);
}
draftRef.current = value;
setDraft(value);
}
function reset() {
setDraft("");
updateDraft("");
setStaged([]);
setStagedVisualComments([]);
setStagedSkills([]);
@ -778,6 +901,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
setUploadError(null);
setMention(null);
setSlash(null);
// Drop tracked plugin-mention insertions when the draft is wiped
// — otherwise a later chip clear would prune user-authored text
// that happened to share a label with a previously-applied
// plugin (#2929 round 2/3). Also clear the active-plugin id
// so the next applyById is treated as a fresh activation
// rather than a "same plugin re-apply" (#2929 round 6).
pluginInsertedTokensRef.current = [];
activePluginIdRef.current = null;
}
function currentCommentAttachments(extra: ChatCommentAttachment[] = []): ChatCommentAttachment[] {
@ -829,7 +960,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// Also strip the matching `@<id>` token from the draft so the chip
// and the textarea stay in sync. We allow trailing whitespace to be
// collapsed too.
setDraft((d) =>
updateDraft((d) =>
d
.replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2')
.replace(/\s{2,}/g, ' '),
@ -1001,7 +1132,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}),
]);
}
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
if (detail.note) updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
setStreamingAnnotationSendPending(true);
textareaRef.current?.focus();
ack({ ok: true });
@ -1022,7 +1153,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
if (detail.note) {
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
textareaRef.current?.focus();
}
ack({ ok: true });
@ -1118,7 +1249,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
const cursor = e.target.selectionStart;
setDraft(value);
// Goes through the `updateDraft` chokepoint so the
// plugin-mention offset reconcile runs on every keystroke,
// matching every other setDraft path for free.
updateDraft(value);
// Keep the staged-skill chips in sync with the draft. If the user
// hand-deletes an `@<id>` token from the textarea, the chip must
// disappear too — otherwise submit() would still forward that id in
@ -1165,7 +1299,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const after = draft.slice(cursor);
const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setMention(null);
if (!staged.some((s) => s.path === filePath)) {
setStaged((s) => [
@ -1185,28 +1319,175 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
async function insertPluginMention(record: InstalledPluginRecord) {
const inserted = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
if (!inserted) return;
await pluginsSectionRef.current?.applyById(record.id, record);
// Snapshot tracker AND draft state before any mutation so we
// can roll back if `applyById` fails (#2929 round 7). Without
// this, an `/apply` 5xx leaves the draft holding a freshly
// inserted `@<token>` whose chip never mounted — a user
// clearing the previously-active plugin's chip would then
// strip the user-visible `@<token>` they just picked, even
// though that text is the only signal they have that
// anything happened.
const prevDraftValue = draftRef.current;
const prevEntries = pluginInsertedTokensRef.current;
const prevActiveId = activePluginIdRef.current;
const result = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
if (!result) return;
// Capture the post-insert draft *snapshot* — the value the
// composer is in immediately after our optimistic write.
// Used as a sentinel during the rollback below: if the
// textarea is still in this state when `applyById` fails
// (no user keystrokes during the await), we can fully
// restore `prevDraftValue`. If the user typed during the
// await, the draft has moved past the snapshot and we MUST
// NOT clobber those edits with the stale `prevDraftValue`
// (#2929 round 8 — the textarea stays interactive while
// `/apply` is in flight, so this is a real prompt-data-loss
// path).
const postInsertDraft = draftRef.current;
// Track the precise start offset of the inserted `@` so the
// post-clear strip can excise exactly this instance, leaving
// any user-authored `@<sameLabel>` elsewhere in the draft
// untouched (#2929 round 3). Entry carries `pluginId` so a
// later replace-plugin flow can drop it cleanly (#2929 round 6),
// and an `insertionId` so this handler's failure path can
// locate the entry it pushed even after `reconcileInsertions`
// shifted offsets or `onCleared` mutated the array
// (#2929 round 10).
//
// Push the new entry but DO NOT yet drop entries from the
// previously-active plugin — that filter is committed only
// after `applyById` resolves successfully (#2929 round 9
// codex review). During the await, the chip strip still
// shows the previously-mounted plugin and the textarea is
// interactive: a user click on that chip's × must strip its
// tracked entries (not the optimistic `@<target>` we just
// pushed). `onCleared` filters by
// `pluginsSectionRef.current?.getActiveRecord()?.id` so a
// pending-window clear scopes to the actually-mounted
// plugin's tracked tokens.
const ourInsertionId = `i${++insertionIdSeqRef.current}`;
pluginInsertedTokensRef.current = [
...pluginInsertedTokensRef.current,
{
token: record.title,
start: result.insertStart,
pluginId: record.id,
insertionId: ourInsertionId,
},
];
const applyResult = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (!applyResult) {
// Two failure modes to disambiguate (#2929 round 10):
//
// (a) "no intervening clear" — the user neither cleared
// the previously-mounted chip nor anything else
// mutated the tracker beyond our push + reconciles
// from user keystrokes. `prevEntries` and
// `prevActiveId` are still the truth. We restore the
// tracker wholesale and restore the draft only if
// the user did not type during the await
// (round 7/8 path).
//
// (b) "intervening clear" — `onCleared` ran during the
// await for the previously-mounted chip, stripped
// its tokens from the draft, and nulled
// `activePluginIdRef`. Restoring `prevEntries`
// wholesale here would resurrect already-stripped
// entries with stale offsets, AND leave our
// optimistic `@<target>` orphaned in the draft (the
// original #2881 symptom recurring inside the
// failure window). Instead we surgically remove ONLY
// our own optimistic entry by `insertionId`, strip
// its `@<target>` from the draft, and leave
// everything `onCleared` did intact.
//
// Detection: `onCleared` always nulls
// `activePluginIdRef.current`; our deferred
// `setActivePlugin` never ran (we are in the failure
// branch). So `activePluginIdRef.current === null` while
// `prevActiveId !== null` is the smoking gun for an
// intervening clear. (If `prevActiveId` was already null,
// there was no chip to clear — no race possible.)
const intervenedClear =
activePluginIdRef.current === null && prevActiveId !== null;
if (intervenedClear) {
const cur = pluginInsertedTokensRef.current;
const idx = cur.findIndex(
(e) => e.insertionId === ourInsertionId,
);
if (idx >= 0) {
const ourEntry = cur[idx]!;
// Splice our entry out first so `updateDraft`'s
// internal `reconcileInsertions` operates on a tracker
// that already excludes it (the strip range overlaps
// the entry, which would drop it anyway, but splicing
// first keeps the invariant explicit and avoids
// depending on the reconcile drop edge case).
pluginInsertedTokensRef.current = [
...cur.slice(0, idx),
...cur.slice(idx + 1),
];
updateDraft((d) => stripPluginInsertedTokens(d, [ourEntry]));
}
// Don't touch `activePluginIdRef` — `onCleared` set it
// to null and that is the truth (no chip is mounted).
return;
}
// (a) round 7/8 path: no intervening clear.
pluginInsertedTokensRef.current = prevEntries;
activePluginIdRef.current = prevActiveId;
// Restore the draft only if no user keystrokes arrived
// during the await — overwriting newer edits with the
// stale pre-pick snapshot would be a worse bug than the
// leftover `@<token>` styled mention this branch leaves
// behind. The orphan stays as a styled mention but no
// future chip clear will touch it (tracker is empty for
// it now), and the user can edit it manually
// (#2929 round 8).
if (draftRef.current === postInsertDraft) {
setDraft(prevDraftValue);
draftRef.current = prevDraftValue;
}
return;
}
// Apply succeeded. Now commit the active-plugin switch —
// this drops any entries from the previously-active plugin
// (a no-op for the entry we just pushed since it matches
// `record.id`) and updates `activePluginIdRef`. Deferring
// until after the await means an `onCleared` triggered
// during the in-flight window saw the still-mounted plugin
// as the active one and stripped only that plugin's tokens
// (#2929 round 9).
setActivePlugin(record.id);
}
function replaceMentionWithText(text: string): boolean {
if (!mention) return false;
function replaceMentionWithText(
text: string,
): { insertStart: number } | null {
if (!mention) return null;
const ta = textareaRef.current;
const cursor = mention.cursor;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const replaced = before.replace(/(^|\s)@([^\s@]*)$/, `$1${text}`);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setMention(null);
// The inserted text was appended onto `replaced`, so its first
// char (the `@`) sits at `replaced.length - text.length`.
const insertStart = replaced.length - text.length;
requestAnimationFrame(() => {
if (!ta) return;
ta.focus();
const pos = replaced.length;
ta.setSelectionRange(pos, pos);
});
return true;
return { insertStart };
}
function insertMcpMention(server: McpServerConfig) {
@ -1234,7 +1515,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function removeStaged(p: string) {
setStaged((s) => s.filter((a) => a.path !== p));
setStagedVisualComments((current) => current.filter((attachment) => attachment.screenshotPath !== p));
setDraft((current) => stripInlineMentionToken(current, p));
updateDraft((current) => stripInlineMentionToken(current, p));
}
function removeCommentAttachment(id: string) {
@ -1473,12 +1754,73 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
showRail={false}
onApplied={(brief) => {
// Use functional setState so stale closures from the @-mention
// flow (which awaits applyById after setDraft) still see the
// latest draft value before deciding whether to seed.
// flow (which awaits applyById after updateDraft) still see
// the latest draft value before deciding whether to seed.
if (typeof brief === 'string' && brief.length > 0) {
setDraft((cur) => (cur.trim().length === 0 ? brief : cur));
updateDraft((cur) => (cur.trim().length === 0 ? brief : cur));
}
}}
onCleared={() => {
// Removing the chip strip must drop the `@…` tokens
// this surface authored, otherwise the textarea is
// left holding orphaned mentions whose chips just
// unmounted (#2881). We strip *only* the tracked
// insertions (by precise start offset) so
// user-authored text that happens to share a label
// with a chip is preserved (#2929 round 3).
//
// The chip strip can clear while an `applyById` for
// a *different* plugin is mid-await — the @-popover
// optimistically writes `@<target>` and pushes a
// tracked entry synchronously, then awaits the
// apply (#2929 round 9 codex review). During that
// window the ref carries entries for both the
// still-mounted plugin (the chip the user is
// removing) and the in-flight target. Trusting the
// ref wholesale here would strip the optimistic
// `@<target>` and leave the unmounting plugin's
// `@<token>` orphaned — a recurrence of #2881 in a
// pending-apply window.
//
// PluginsSection only flips `activeRecord` after
// `applyPlugin` resolves successfully (see
// `PluginsSection.tsx`), so `getActiveRecord()` at
// the moment `onCleared` fires reports the plugin
// whose chip is currently being unmounted — exactly
// the one whose tracked entries we should strip.
// Filter to that id; entries for any in-flight
// replace target are left in place (the in-flight
// handler's success path will commit
// `setActivePlugin(target)` and drop them; its
// failure path will roll the tracker back).
const unmountingId =
pluginsSectionRef.current?.getActiveRecord()?.id ?? null;
const entries = pluginInsertedTokensRef.current;
if (entries.length > 0) {
const toStrip = unmountingId
? entries.filter((e) => e.pluginId === unmountingId)
: entries;
if (toStrip.length > 0) {
// `updateDraft` runs `reconcileInsertions`
// against the prev → next diff inside the
// chokepoint, so any in-flight target's entries
// get their offsets shifted to track the
// post-strip draft. We must re-read the ref
// *after* `updateDraft` returns instead of
// filtering the pre-strip `entries` snapshot,
// otherwise we would clobber the reconciled
// offsets and a later clear of the in-flight
// chip would no-op via `isInsertionStillValid`.
updateDraft((d) => stripPluginInsertedTokens(d, toStrip));
}
pluginInsertedTokensRef.current = unmountingId
? pluginInsertedTokensRef.current.filter(
(e) => e.pluginId !== unmountingId,
)
: [];
}
activePluginIdRef.current = null;
}}
onChipDetails={(item: ContextItem) => {
if (item.kind !== 'plugin') return;
const record = installedPlugins.find((p) => p.id === item.id);
@ -1700,11 +2042,32 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
plugins={pluginsForComposer}
activePluginId={pinnedPluginId}
onApply={async (record) => {
// Tools-menu apply: no draft write, so the
// tracked-insertion array gets no new
// entry. The active-plugin switch (which
// drops previously-tracked entries from a
// prior @-popover pick of a different
// plugin, #2929 round 6) is deferred until
// `applyById` resolves successfully so
// that an `onCleared` triggered during the
// in-flight window still sees the
// still-mounted plugin's entries and
// strips them correctly via the
// `getActiveRecord()` filter in
// `onCleared` (#2929 round 9).
//
// No synchronous mutation in this branch
// means no rollback snapshot is needed:
// the failure path is just an early return
// (#2929 round 7's snapshot was needed
// because `setActivePlugin` was eager).
const result = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (result) setToolsOpen(false);
if (!result) return;
setActivePlugin(record.id);
setToolsOpen(false);
}}
onShowDetails={(record) => {
setDetailsRecord(record);
@ -1726,7 +2089,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const before = currentDraft.slice(0, cursor);
const after = currentDraft.slice(cursor);
const next = before + insert + after;
setDraft(next);
updateDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
@ -1750,7 +2113,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const next = before + insert + after;
setDraft(next);
updateDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
@ -1905,7 +2268,24 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
record={detailsRecord}
onClose={() => setDetailsRecord(null)}
onUse={async (record) => {
await pluginsSectionRef.current?.applyById(record.id, record);
// Details-modal apply: same shape as tools-menu apply
// (no draft write). The active-plugin switch is
// deferred until `applyById` resolves successfully so
// that an `onCleared` triggered during the in-flight
// window still sees the still-mounted plugin's
// entries and strips them correctly (#2929 round 9).
//
// Modal closes regardless of apply outcome so the
// user is not stuck on the details view if `/apply`
// 5xx'd. Failure is a no-op: no synchronous mutation
// happened, so nothing to roll back (#2929 round 7's
// snapshot was needed because `setActivePlugin` was
// eager — round 9 made it lazy).
const result = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (result) setActivePlugin(record.id);
setDetailsRecord(null);
}}
/>

View file

@ -142,11 +142,35 @@ function pickEarlierMention(
return known.token.length >= unknown.token.length ? known : unknown;
}
function isMentionBoundary(text: string, start: number): boolean {
/**
* Left boundary rule for inline mentions: `@<token>` is a candidate
* mention only when the character before `@` is the start of the
* string or whitespace / opening bracket / quote. Exported so the
* draft-side plugin-insertion tracker stays in lockstep with this
* parser see `apps/web/src/utils/pluginInsertionTracking.ts`.
*/
export function isMentionBoundary(text: string, start: number): boolean {
if (start === 0) return true;
return /[\s([{"']/.test(text[start - 1] ?? '');
}
/**
* Right boundary rule for inline mentions: the parser's unknown
* mention regex is `/@[^\s@]+/`, so a `@<token>` candidate is the
* full mention only when the character after the token is the end
* of the string, whitespace, or another `@` (which would start a
* new mention). Anything else extends the parser's tokenization
* past the candidate e.g. `@Airbnb/foo` is parsed as a single
* mention even when `@Airbnb` is a known plugin. Exported for the
* same reason as `isMentionBoundary`: the draft-side tracker must
* not declare an entry "still valid" when the parser would no
* longer see the tracked token as a standalone mention.
*/
export function isMentionRightBoundary(text: string, end: number): boolean {
if (end >= text.length) return true;
return /[\s@]/.test(text[end] ?? '');
}
function coalesceTextParts(parts: InlineMentionPart[]): InlineMentionPart[] {
const result: InlineMentionPart[] = [];
for (const part of parts) {

View file

@ -0,0 +1,224 @@
// Instance-aware tracking for `@<token>` mentions that ChatComposer
// inserts into the draft via the @-mention popover plugin-pick path
// (#2881, #2929). Each tracked insertion records the precise start
// offset of `@`, so two `@Airbnb` instances in the same draft (one
// composer-inserted, one user-typed) are individually distinguishable
// — the chip-clear strip only removes the tracked one (#2929 round 3).
//
// Boundary rules are imported from `./inlineMentions` so the
// "tracker thinks this is still a valid mention" predicate stays in
// lockstep with the actual mention parser. Without that, drafts
// like `@Airbnb/foo` (where the parser tokenizes the full
// `@Airbnb/foo` as one mention) would still satisfy a permissive
// tracker boundary, and the post-clear strip would tear out only
// `@Airbnb`, leaving `/foo` as orphaned user-authored text
// (#2929 round 5).
import {
isMentionBoundary,
isMentionRightBoundary,
} from './inlineMentions';
export type TrackedInsertion = {
/** Bare token without the leading `@`, matching `inlineMentionToken` payload. */
token: string;
/** Position of `@` in the draft. The full mention occupies [start, start + token.length + 1). */
start: number;
/**
* `id` of the `InstalledPluginRecord` whose `applyById` produced this
* insertion. Used by ChatComposer to scope the post-clear strip to the
* currently active plugin: when the user replaces plugin A with plugin
* B (e.g. via the tools menu's `applyById` without writing to the
* draft), the entries for A must be dropped from the tracker, otherwise
* clearing B's chip would silently strip A's `@A` from the draft
* user-text deletion in a supported replace-plugin flow (#2929 round 6).
*/
pluginId: string;
/**
* Optional unique handle assigned by the producer (ChatComposer's
* `insertPluginMention`) when the entry is pushed. Survives
* `reconcileInsertions` so the producer's failure-path rollback can
* locate "the entry I just pushed" even after intervening reconciles
* shifted offsets or after `onCleared` mutated the array. Without
* this, a `(token, pluginId)`-only match is ambiguous if the user
* replays the same plugin pick during the await window
* (#2929 round 10 codex review).
*/
insertionId?: string;
};
export type EditRange = {
/** First index where prev and next differ. */
start: number;
/** Index in prev one past the last differing char. */
oldEnd: number;
/** Index in next one past the last differing char. */
newEnd: number;
};
/**
* Longest-common-suffix + longest-common-prefix diff of two strings.
* Returns the minimal `[editStart, oldEnd, newEnd]` range that contains
* every byte that differs between `prev` and `next`.
*
* Suffix is computed first; the prefix is then capped so the two
* matches do not overlap. This ordering matters when the inserted
* text shares a leading character with `prev` e.g. prepending
* `@github ` before `@Airbnb ` to get `@github @Airbnb `. A
* prefix-first walk would greedily claim the leading `@` (LCP=1)
* and then split the diff window at index 1, which crosses through
* a tracked `@Airbnb` entry that is structurally untouched. Suffix
* first claims the entire `@Airbnb ` from the right, leaving LCP=0
* and a clean prepend window of `[0, 0, 8]` the entry shifts
* cleanly by `delta`.
*
* Single-point edits (typing/deleting/pasting at one location) are
* 100% accurate. Multi-point simultaneous edits (rare) collapse into
* one wider range, which conservatively invalidates any tracked
* insertion overlapping that range.
*/
export function computeEditRange(prev: string, next: string): EditRange {
if (prev === next) return { start: 0, oldEnd: 0, newEnd: 0 };
const minLen = Math.min(prev.length, next.length);
// Longest common suffix first — capped at minLen so it does not
// walk past the start of either string.
let suffix = 0;
while (
suffix < minLen &&
prev.charCodeAt(prev.length - 1 - suffix) ===
next.charCodeAt(next.length - 1 - suffix)
) {
suffix++;
}
// Longest common prefix, capped so it does not overlap the suffix.
const maxStart = minLen - suffix;
let start = 0;
while (start < maxStart && prev.charCodeAt(start) === next.charCodeAt(start)) {
start++;
}
return {
start,
oldEnd: prev.length - suffix,
newEnd: next.length - suffix,
};
}
/**
* True iff the draft still contains `@<token>` at the given start
* offset AND the surrounding characters make the parser see it as
* exactly that mention (not a longer one). Boundaries delegate to
* the same `isMentionBoundary` / `isMentionRightBoundary` helpers
* the parser uses, so the tracker cannot diverge from the parser
* and inadvertently strip a prefix of a longer parser-recognized
* mention (#2929 round 5).
*
* Concretely: a tracked `@Airbnb` at offset 0 in `@Airbnb/foo` is
* INVALID under this rule because the parser treats the full
* `@Airbnb/foo` as one mention (its `@[^\s@]+` greedy regex extends
* through `/foo`). Stripping just `@Airbnb` would leave `/foo`
* dangling that is user-authored text mutation, not an orphan
* removal. Invalidating the entry on clear is the conservative
* choice: the post-clear strip becomes a no-op, the orphan
* styled mention remains visible, and the user can edit it
* manually if they want.
*/
export function isInsertionStillValid(
draft: string,
start: number,
token: string,
): boolean {
if (start < 0 || token.length === 0) return false;
const tokenLen = token.length + 1; // include leading `@`
if (start + tokenLen > draft.length) return false;
if (draft.slice(start, start + tokenLen) !== `@${token}`) return false;
if (!isMentionBoundary(draft, start)) return false;
if (!isMentionRightBoundary(draft, start + tokenLen)) return false;
return true;
}
/**
* Re-map tracked insertion offsets across a draft edit. Entries
* entirely before the edit keep their offset; entries entirely
* after shift by `delta`; entries that overlap the edit are
* dropped. Survivors are revalidated against the new draft so any
* boundary corruption (e.g. user typed letters touching the right
* edge of `@Airbnb` to form `@Airbnbify`) prunes the entry.
*/
export function reconcileInsertions(
entries: ReadonlyArray<TrackedInsertion>,
prev: string,
next: string,
): TrackedInsertion[] {
if (entries.length === 0) return [];
if (prev === next) return entries.slice();
const { start: editStart, oldEnd, newEnd } = computeEditRange(prev, next);
const delta = newEnd - oldEnd;
const result: TrackedInsertion[] = [];
for (const e of entries) {
const tokenLen = e.token.length + 1;
const entryEnd = e.start + tokenLen;
let nextStart: number;
if (entryEnd <= editStart) {
nextStart = e.start; // entry entirely before edit
} else if (e.start >= oldEnd) {
nextStart = e.start + delta; // entry entirely after edit → shift
} else {
continue; // edit overlaps entry → drop
}
if (isInsertionStillValid(next, nextStart, e.token)) {
const reconciled: TrackedInsertion = {
token: e.token,
start: nextStart,
pluginId: e.pluginId,
};
if (e.insertionId !== undefined) reconciled.insertionId = e.insertionId;
result.push(reconciled);
}
}
return result;
}
/**
* Remove tracked `@<token>` insertions from the draft by slicing each
* entry's range. Sorts descending by start so earlier offsets stay
* valid as later ones are excised. Invalidated entries (boundary
* corruption since the last reconcile) are skipped the safe failure
* mode is under-delete, never over-delete.
*
* Whitespace handling (#2929 round 8): when both the character before
* `@` and the character after the token are whitespace, the slice
* removes one of them in addition to the token to avoid leaving
* doubled whitespace at the seam (e.g. `text @Airbnb more` `text
* more`, not `text more`). Whitespace ELSEWHERE in the draft is
* never touched a previous version of this function ran a global
* `[ \t]{2,}` collapse over the entire result, which silently
* rewrote any user-authored multi-space spans (e.g. `keep gap`
* `keep gap`). Round 8 reviewer flagged that as prompt corruption
* in the changed flow.
*/
export function stripPluginInsertedTokens(
draft: string,
entries: ReadonlyArray<TrackedInsertion>,
): string {
if (!draft || entries.length === 0) return draft;
const valid = entries
.filter((e) => isInsertionStillValid(draft, e.start, e.token))
.sort((a, b) => b.start - a.start);
let next = draft;
for (const e of valid) {
const tokenLen = e.token.length + 1;
const leftIdx = e.start - 1;
const rightIdx = e.start + tokenLen;
const leftIsWs =
leftIdx >= 0 && /[ \t]/.test(next[leftIdx] ?? '');
const rightIsWs =
rightIdx < next.length && /[ \t]/.test(next[rightIdx] ?? '');
// Seam-local collapse only: if both sides are whitespace,
// extend the slice by one character so the seam ends up with
// a single space instead of two. Anything outside this range
// — including user-authored multi-space spans elsewhere — is
// left untouched.
const sliceEnd = leftIsWs && rightIsWs ? rightIdx + 1 : rightIdx;
next = next.slice(0, e.start) + next.slice(sliceEnd);
}
return next;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest';
import {
computeEditRange,
isInsertionStillValid,
reconcileInsertions,
stripPluginInsertedTokens,
type TrackedInsertion,
} from '../../src/utils/pluginInsertionTracking';
// Pure-function coverage for the diff/reconcile/strip primitives that
// back ChatComposer's instance-aware plugin mention tracking
// (#2929 round 3). The integration spec
// (`ChatComposer.plugin-clear-prunes-draft.test.tsx`) exercises the
// end-to-end React path; this file pins the edge cases the integration
// flow is unlikely to hit, so a regression in the math surfaces here
// before it can corrupt user drafts.
describe('computeEditRange', () => {
it('returns an empty range when the strings are equal', () => {
expect(computeEditRange('abc', 'abc')).toEqual({ start: 0, oldEnd: 0, newEnd: 0 });
});
it('detects a pure prefix append', () => {
// `prev` is the suffix of `next`; the diff sits at the very start.
expect(computeEditRange('world', 'hello world')).toEqual({
start: 0,
oldEnd: 0,
newEnd: 6,
});
});
it('detects a pure suffix append', () => {
expect(computeEditRange('hello', 'hello world')).toEqual({
start: 5,
oldEnd: 5,
newEnd: 11,
});
});
it('detects a middle replacement', () => {
expect(computeEditRange('abc XYZ def', 'abc 12345 def')).toEqual({
start: 4,
oldEnd: 7,
newEnd: 9,
});
});
it('detects a full deletion to empty', () => {
expect(computeEditRange('hello', '')).toEqual({ start: 0, oldEnd: 5, newEnd: 0 });
});
it('does not let prefix and suffix overlap when one string is a substring of the other', () => {
// Both strings share the leading `aa`. If suffix matching greedily
// walked past `start`, the range could go negative.
const r = computeEditRange('aa', 'aaa');
expect(r.start).toBeLessThanOrEqual(r.oldEnd);
expect(r.start).toBeLessThanOrEqual(r.newEnd);
});
it('treats prepended text that shares a leading char with prev as a clean prepend (#2929 round 4)', () => {
// Inserting `@github ` before `@Airbnb ` gives `@github @Airbnb `.
// A naive LCP-first algorithm matches the leading `@`, then walks
// LCS backwards through `Airbnb `, and reports the edit as
// `editStart=1, oldEnd=1, newEnd=9`. That window cuts through a
// tracked entry at offset 0 even though `@Airbnb` was not
// structurally touched. LCS-first is required so the entire
// `@Airbnb ` suffix is claimed by the right side and the diff
// collapses to a clean prepend of `[0, 0, 8]`.
const r = computeEditRange('@Airbnb ', '@github @Airbnb ');
expect(r).toEqual({ start: 0, oldEnd: 0, newEnd: 8 });
});
});
describe('isInsertionStillValid', () => {
it('accepts a token at the start of the draft', () => {
expect(isInsertionStillValid('@Airbnb plan', 0, 'Airbnb')).toBe(true);
});
it('accepts a token after a whitespace boundary', () => {
expect(isInsertionStillValid('see @Airbnb', 4, 'Airbnb')).toBe(true);
});
it('rejects when the surrounding letter forms a longer mention', () => {
// `@Airbnbx` would render as a single mention so the tracked range
// is no longer the intended target.
expect(isInsertionStillValid('@Airbnbx', 0, 'Airbnb')).toBe(false);
});
it('rejects when the left boundary is a non-mention character', () => {
// `x@Airbnb` is not a valid mention per inlineMentions boundary
// rules — the `x` immediately to the left is a word char.
expect(isInsertionStillValid('x@Airbnb', 1, 'Airbnb')).toBe(false);
});
it('rejects when the offset no longer points at the token', () => {
expect(isInsertionStillValid('compare @Airbnb', 0, 'Airbnb')).toBe(false);
});
it('rejects negative or out-of-range offsets', () => {
expect(isInsertionStillValid('@Airbnb', -1, 'Airbnb')).toBe(false);
expect(isInsertionStillValid('@Airbnb', 100, 'Airbnb')).toBe(false);
});
// Parser-alignment cases (#2929 round 5): the inline-mention parser
// tokenizes `@<token>` greedily through `[^\s@]`, then prefers the
// longer match at the same start offset. A tracked entry must
// therefore be invalidated whenever the right-boundary character
// would extend the parser's mention past the tracked range — that
// is, anything other than EOS, whitespace, or another `@`. Without
// these rejections the post-clear strip would carve `@Airbnb` out
// of `@Airbnb/foo`, leaving `/foo` dangling as user-authored text
// mutation.
it('rejects when followed by `/` (parser would tokenize a longer mention)', () => {
expect(isInsertionStillValid('@Airbnb/foo', 0, 'Airbnb')).toBe(false);
});
it('rejects when followed by `.`', () => {
expect(isInsertionStillValid('@Airbnb.test', 0, 'Airbnb')).toBe(false);
});
it('rejects when followed by `,`', () => {
expect(isInsertionStillValid('@Airbnb,', 0, 'Airbnb')).toBe(false);
});
it('rejects when followed by `)` (parser would extend through the paren)', () => {
expect(isInsertionStillValid('see (@Airbnb), then ship', 5, 'Airbnb')).toBe(false);
});
it('accepts when followed by another `@` (next mention starts there)', () => {
expect(isInsertionStillValid('@Airbnb@other', 0, 'Airbnb')).toBe(true);
});
it('accepts at end-of-string', () => {
expect(isInsertionStillValid('@Airbnb', 0, 'Airbnb')).toBe(true);
});
it('accepts when followed by whitespace', () => {
expect(isInsertionStillValid('@Airbnb plan', 0, 'Airbnb')).toBe(true);
});
});
describe('reconcileInsertions', () => {
const entry: TrackedInsertion = { token: 'Airbnb', start: 0, pluginId: 'airbnb' };
it('returns a fresh copy when nothing changed', () => {
const out = reconcileInsertions([entry], '@Airbnb ', '@Airbnb ');
expect(out).toEqual([entry]);
expect(out).not.toBe([entry]); // new array
});
it('keeps an entry whose tail sits before the edit', () => {
// `@Airbnb` at [0,7], edit happens at index 8 (typing after the trailing space)
const next = reconcileInsertions(
[entry],
'@Airbnb ',
'@Airbnb compare',
);
expect(next).toEqual([entry]);
});
it('shifts an entry whose head sits after the edit', () => {
// Insert `prefix ` (7 chars) at the beginning. Entry start moves 0 → 7.
const next = reconcileInsertions(
[entry],
'@Airbnb ',
'prefix @Airbnb ',
);
expect(next).toEqual([{ token: 'Airbnb', start: 7, pluginId: 'airbnb' }]);
});
it('drops an entry the edit overlaps', () => {
// User selects through the entry and replaces it with other text.
const next = reconcileInsertions(
[entry],
'@Airbnb plan',
'@Air-other plan',
);
expect(next).toEqual([]);
});
it('drops an entry whose right boundary is corrupted (letters touching)', () => {
// Typing `ify` immediately after `@Airbnb` makes it `@Airbnbify`
// which is no longer a valid mention.
const next = reconcileInsertions(
[entry],
'@Airbnb',
'@Airbnbify',
);
expect(next).toEqual([]);
});
it('handles multiple entries with mixed shift / keep / drop outcomes', () => {
// prev: `@A xxx @B yyy`
// entry1 at 0 entry2 at 11
// edit: replace `xxx` (cols 4-6) with `12345` (delta = +2)
const e1: TrackedInsertion = { token: 'A', start: 0, pluginId: 'a' };
const e2: TrackedInsertion = { token: 'B', start: 11, pluginId: 'b' };
const prev = '@A xxx @B yyy';
const next = '@A 12345 @B yyy';
const out = reconcileInsertions([e1, e2], prev, next);
expect(out).toEqual([
{ token: 'A', start: 0, pluginId: 'a' },
{ token: 'B', start: 13, pluginId: 'b' }, // 11 + 2
]);
});
it('returns an empty list when the entries are empty', () => {
expect(reconcileInsertions([], 'a', 'b')).toEqual([]);
});
// Purity guard (#2929 round 5): reconcile must not mutate its
// inputs and must produce the same output regardless of how many
// times it is called with the same arguments. React StrictMode
// double-invokes setState updaters in development; the previous
// implementation called reconcile *inside* the updater and
// accumulated shifts (entry at 0 → 8 → 16) on the second
// invocation, dropping the entry as out-of-range. The fix moves
// reconcile out of the updater, but pinning purity here too so a
// future regression there is caught at the algorithm layer.
it('is pure: invoking twice with the same args returns equivalent output and does not mutate input', () => {
const entries: TrackedInsertion[] = [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }];
const frozen = Object.freeze([...entries]) as ReadonlyArray<TrackedInsertion>;
const first = reconcileInsertions(frozen, '@Airbnb ', '@github @Airbnb ');
const second = reconcileInsertions(frozen, '@Airbnb ', '@github @Airbnb ');
expect(first).toEqual([{ token: 'Airbnb', start: 8, pluginId: 'airbnb' }]);
expect(second).toEqual([{ token: 'Airbnb', start: 8, pluginId: 'airbnb' }]);
// Frozen input was not mutated (any attempt would have thrown
// in strict mode).
expect(frozen).toEqual([{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]);
});
});
describe('stripPluginInsertedTokens', () => {
it('returns the draft unchanged when there are no entries', () => {
expect(stripPluginInsertedTokens('@Airbnb ', [])).toBe('@Airbnb ');
});
it('removes a single tracked token at the start of the draft', () => {
expect(
stripPluginInsertedTokens('@Airbnb ', [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]),
).toBe(' '); // trailing space from inserted text remains; integration trims as needed
});
it('removes a tracked token while preserving an untracked duplicate (#2929 round 3)', () => {
// The whole point: composer-inserted `@Airbnb` at offset 0 gets
// removed; the user-authored `@Airbnb` at offset 16 is untracked
// and therefore preserved.
const draft = '@Airbnb compare @Airbnb with our spec';
const out = stripPluginInsertedTokens(draft, [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]);
expect(out).toBe(' compare @Airbnb with our spec');
});
it('slices multiple tracked tokens in one pass without offset drift', () => {
// Two tracked entries, descending sort means the right one is
// sliced first so the left one's offset stays valid.
const draft = '@A and @B';
const out = stripPluginInsertedTokens(draft, [
{ token: 'A', start: 0, pluginId: 'a' },
{ token: 'B', start: 7, pluginId: 'b' },
]);
expect(out).toBe(' and ');
});
it('drops invalidated entries instead of corrupting unrelated text', () => {
// The tracked offset no longer points at `@Airbnb` (user retyped).
// strip should be a no-op rather than deleting whatever sits at the
// stale offset.
const draft = 'hello world';
const out = stripPluginInsertedTokens(draft, [
{ token: 'Airbnb', start: 0, pluginId: 'airbnb' },
]);
expect(out).toBe('hello world');
});
it('collapses double whitespace left behind by the strip', () => {
const draft = 'see @Airbnb here';
const out = stripPluginInsertedTokens(draft, [
{ token: 'Airbnb', start: 4, pluginId: 'airbnb' },
]);
// After slicing `@Airbnb`: `see here` (two spaces) → collapse to `see here`
expect(out).toBe('see here');
});
it('does not normalize user-authored multi-space spans elsewhere in the draft (#2929 round 8)', () => {
// Reviewer-flagged: the previous global `[ \t]{2,}` collapse
// would rewrite any user-authored double-space span to a
// single space, even ones unrelated to the strip seam. The
// seam-local collapse here only touches the whitespace
// adjacent to the removed range.
const draft = 'keep gap @Airbnb here';
const out = stripPluginInsertedTokens(draft, [
{ token: 'Airbnb', start: 10, pluginId: 'airbnb' },
]);
// `keep gap` (two spaces) is preserved; the `@Airbnb` seam
// collapses to a single space.
expect(out).toBe('keep gap here');
});
it('preserves multi-space spans on both sides of an unrelated mention (#2929 round 8)', () => {
// Two user-authored double-space spans flank an `@Airbnb`
// that is not tracked. Strip should be a no-op (no entries
// for it) — verifies that nothing in the function reaches
// out and normalizes whitespace when it has no entries to
// operate on.
const draft = 'one two @Untracked three four';
const out = stripPluginInsertedTokens(draft, []);
expect(out).toBe('one two @Untracked three four');
});
});