* fix(daemon): surface OpenCode error frames + treat empty-output runs as failed
Closes#691. OpenCode runs would silently complete in ~3 seconds without
producing any visible chat output and still be rendered as a successful
turn — three independent bugs along the structured-stream path conspired
to produce this silent-failure shape.
## Bug 1 — `apps/daemon/src/json-event-stream.ts:85-91`
OpenCode emits structured error frames on stdout (e.g. provider auth
failures, network errors, schema mismatches) and still exits 0. The
parser was downgrading these to `{type: 'raw', line: ...}`, which the
chat UI does not render as an assistant message. The error string was
discarded as "no-op output."
Fix: emit a proper `{type: 'error', message, raw}` event matching the
qoder-stream contract that the daemon's existing error-handling path
already recognises.
## Bug 2 — `apps/daemon/src/server.ts:4199-4205`
Even after Bug 1 was fixed, the json-event-stream branch wired the
parser to a bare `(ev) => send('agent', ev)` lambda — bypassing the
`sendAgentEvent` wrapper that interprets `type:'error'` events and
sets the `agentStreamError` flag the close handler reads to flip the
run to `failed`. So an emitted `error` event would just be forwarded
as a no-op `agent` SSE event with no lifecycle effect.
Fix: route json-event-stream through `sendAgentEvent`, mirroring the
qoder-stream-json wiring at line 4175.
## Bug 3 — `apps/daemon/src/server.ts:4220-4234`
Even after Bugs 1 and 2 are fixed, there's still a class of runs where
OpenCode never emits any error frame, never emits any substantive
event, and exits 0. Pre-fix this was marked `succeeded` and the user
saw a blank chat with no diagnostic.
Fix: track `agentProducedOutput` inside `sendAgentEvent` (set on
`text_delta`, `thinking_delta`, `tool_use`, `tool_result`, `artifact`
— deliberately NOT on `status` / `usage`, since a model can emit
token-usage numbers for an empty completion). When the close handler
sees `code === 0 && trackingSubstantiveOutput && !agentProducedOutput`
the run is marked `failed` with an explicit AGENT_EXECUTION_FAILED
SSE error so the chat shows a clear reason instead of a silent
empty turn.
The check is gated by `trackingSubstantiveOutput` so it only fires
on streams that actually contribute to the output flag (currently
qoder-stream-json and json-event-stream). ACP sessions and plain
stdout streams keep their existing success/failure determination.
## Tests
- 3 new unit tests in `apps/daemon/tests/json-event-stream.test.ts`
pin the OpenCode error event shape: full repro
(`error.data.message`), `error.name` fallback, and the
generic-fallback shape when `error` is empty.
- All 60 daemon test files (851 tests) pass on `pnpm --filter
@open-design/daemon test`. All 42 web test files (309 tests) pass
on `pnpm --filter @open-design/web test`.
- Full repo `pnpm typecheck` clean.
## Live verification
Verified end-to-end via a stub `opencode` binary that mimics each of
the failure shapes against `pnpm tools-dev run web`:
1. Stub emits `{"type":"error",...}` then `exit 0` — run now ends as
`failed` with the OpenCode error message surfaced as an SSE
`error` event. Pre-fix this was `succeeded` with an empty chat.
2. Stub emits nothing then `exit 0` — run now ends as `failed` with
"Agent completed without producing any output…" diagnostic.
Pre-fix this was `succeeded` with an empty chat.
3. Stub emits a normal `step_start` / `text` / `step_finish` sequence
then `exit 0` — run still succeeds. (Regression check.)
## Out of scope (mentioned for the next person)
- `claude-stream-json` and `copilot-stream-json` still wire to a bare
`(ev) => send('agent', ev)` and don't currently parse `type:'error'`
frames. If their CLIs ever start emitting structured error events
the same pattern (route through `sendAgentEvent` + emit proper
`type:'error'`) applies. Not in scope here because we have no
evidence those CLIs do this today, and changing the wiring without
a confirmed failure mode risks regressing currently-working flows.
- ACP sessions (`pi-rpc`, `acp-json-rpc`) own their own success /
failure determination via `acpSession?.hasFatalError()` and the
empty-output guard explicitly skips them via
`trackingSubstantiveOutput`.
- Plain stdout streams have no event-level tracking, so the empty-
output guard skips them too. Diagnosing a no-output plain-stream
agent is a separate problem that needs different signals.
* chore: retrigger CI on top of green main (post #697 i18n backfill)
* feat(linux): add headless mode for install/start/stop operations
* docs(linux): document headless mode commands and usage
* refactor(linux-headless): write web-root.json instead of polling IPC for URL
* fix(linux-headless): fail start when web identity never appears instead of returning success
* docs(linux-headless): add use-case context and clarify launcher path dependency
* fix(linux-headless): ensure launcher, identity and shutdown align with tools-pack
- Bake OD_DATA_DIR into launcher so manual runs use the same paths as tools-pack
- Validate web-root.json fields before accepting to reject stale identity
- Remove web-root.json on successful stop
- Add IPC server for graceful STATUS/SHUTDOWN handling
* fix(linux-headless): create IPC server before writing web-root.json
* docs: fix broken pi-ai links, point to correct pi-mono packages
All links to https://github.com/mariozechner/pi-ai returned 404 after
the project was restructured into the badlogic/pi-mono monorepo.
- "pi" / "Pi" (the CLI tool the daemon scans for) now points to
packages/coding-agent
- "pi-ai" (the multi-provider LLM API) now points to packages/ai
via the shared [piai] reference definitions
Closes#275.
* fixup! Merge remote-tracking branch 'upstream/main' into docs/fix-pi-pi-ai-links
Fix [piai] reference in README.ar.md and README.es.md: was incorrectly
pointing to packages/coding-agent (pi CLI) instead of packages/ai (pi-ai
provider library).
* fixup! fix row order in README.uk.md: move Pi after DeepSeek TUI to match English README
* feat: add accent color control and launcher for Open Design
* fix: remove launcher binary from PR
* test: cover accent appearance edge cases
---------
Co-authored-by: ferasbusiness666 <ferasbusiness666@users.noreply.github.com>
* ci: notify Discord #resolved on issue close-via-merged-PR
* ci: address review feedback on Discord #resolved workflow
P1:
- Add contents:read permission (required by listPullRequestsAssociatedWithCommit)
- Drop cross-referenced timeline fallback to eliminate false positives from
plain mentions; closed-event+commit_id is now the only resolver path
(also fixes the cross-repo number-collision concern Codex raised)
P2:
- Validate webhook URL prefix before POST (reject misconfigured secrets)
- Retry on Discord 429 up to 3 times honouring Retry-After header,
bounded 1..60s, with sane default if header missing
P3:
- allowed_mentions: { parse: [] } so issue/PR titles can't @everyone or
ping roles/users in #resolved
* feat(skills): add social-media-dashboard skill + Totality Festival design system
- New skill 'social-media-dashboard': single-screen creator analytics
dashboard with platform switcher (X / GitHub / LinkedIn / YouTube /
Instagram), KPI row, growth chart with annotations, top-post / top-PR
preview, trending topics, and top comments. Includes a self-contained
example.html (Totality Festival styled, X + GitHub tabs, live KPI
ticker, GitHub contributors grid, world-map audience geography).
- New design system 'totality-festival': cosmic-premium dark glassmorphic
system with amber corona highlights and cyan atmospheric accents.
Mirrors Google Labs' design.md spec example so skills can be
validated against an upstream reference.
- Fix swatches parser in apps/daemon/src/design-systems.ts so it
recognises the '- **Name:**' bold-with-inner-colon form used by
several existing systems (ant, totality-festival, ...). Previously
only the '**Name** (`#hex`)' form was matched, which left their
picker thumbnails empty.
* feat(skills): polish social-media-dashboard example + add PR preview
- Top Post media block: replace empty gold frame with an inline SVG
thumbnail (radial glow + ascending data curve + amber/cyan pulse dots
+ play icon + 'LIVE · 0:22' caption). Visually echoes the live-artifact
story the post copy is selling.
- Hoist the brand-mark linearGradient into a global SVG defs block at
the top of <body> so all three avatars (header, user, top-post) can
reference url(#brandRing) and render the teal arrow consistently.
Previously only the header SVG carried the gradient definition, so
the user and post avatars rendered as empty rings under headless
capture.
- Add hero.png preview to .preview/ for the PR description.
---------
Co-authored-by: Tuola Ge <gexingli@refly.ai>
* feat(daemon): export project transcript to disk for downstream consumption
Adds exportProjectTranscript(db, projectsRoot, projectId, options?) — a
pure function that walks SQLite-backed conversation history and writes a
structured, lossless JSONL transcript to <projectDir>/.transcript.jsonl.
This is the input primitive that #450's "Finalize design package"
synthesis step needs. Landed ahead of the synthesis endpoint as a small,
reviewable, well-tested unit — no HTTP route, no LLM call, no web UI in
this diff. PR 2 will wire POST /api/projects/:id/finalize on top of it.
Format: JSONL with header line + per-conversation marker lines +
per-message lines. Compact encoding saves ~20–30% on synth-call tokens
vs pretty-printed JSON. schemaVersion field reserved on the header for
incompatible changes later.
Coalescing: events_json carries streaming text_delta / thinking_delta
chunks plus tool_use / tool_result / thinking_start markers and
telemetry. The export collapses runs of same-type deltas into terminal
text / thinking blocks via arrival-order with type-change flush,
preserving any interleaving with tool blocks. Telemetry (status, usage,
raw) is dropped. thinking_start is treated as an explicit flush trigger
so multiple thinking blocks in one message survive intact.
Content fallback: user-typed messages persist as plain text in
messages.content with events_json = NULL because user input does not
flow through the streaming pipeline. When event-derived blocks come
back empty, fall back to a single text block from content so user
prompts are not silently dropped.
Atomic write: tmp filename includes pid + crypto-random suffix so
concurrent exports for the same project cannot collide. fsync before
rename so a crash between rename and power loss cannot lose bytes.
Hidden file: leading dot keeps .transcript.jsonl out of listFiles
(projects.ts:54), the archive zip, and the gallery.
Tests: 14 unit cases — empty project, text/streaming coalescing, tool
ordering, telemetry filtering, type-change flush, text↔thinking↔text
interleaving, thinking_start flush, multi-conversation chronological
order, atomic-write hygiene, content fallback (both directions),
malformed events_json, and unsafe project id rejection.
Refs: nexu-io/open-design#450
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(daemon): address PR #493 review feedback for transcript export
Addresses every blocker, P2, and P3 raised on
https://github.com/nexu-io/open-design/pull/493:
- Blocker (event-shape mismatch): coalescer now switches on the
PersistedAgentEvent kind discriminator (text/thinking/tool_use/
tool_result), reading the shared `text` field for content kinds,
matching what apps/web/src/providers/daemon.ts:347-394 actually
persists into messages.events_json. Empirical confirmation: live
SQLite contains zero `type` keys.
- Removed @ts-nocheck from the source file; added inline types for
Db (Database.Database), ConversationRow, MessageRow, AttachmentRef,
CommentAttachmentRef, and Block. Tests retain @ts-nocheck per
codebase convention. Note: db.ts still uses @ts-nocheck, so the
new types catch drift inside transcript-export.ts itself, not at
the SQLite-helper boundary.
- parseEvents now distinguishes null / malformed / not_array / ok
cases; non-null-but-unparseable rows emit a console.warn with
project+message id before falling back to content.
- Switched temp-write from writeSync (which can return short) to
writeFileSync({flag:'wx'}); explicit fsync via reopen before
rename, per reviewer concern about partial-write durability.
- Added per-project lockfile (.transcript.lock) acquired with
openSync(..., 'wx') and released in finally; concurrent exports
throw the new TranscriptExportLockedError. Stale-lock recovery
is documented as a known limitation in the file header.
- Header gains attachmentCount, commentAttachmentCount, and explicit
attachmentsInlined: false. Per-message lines gain attachments /
commentAttachments references (paths only, not bytes; synthesis
reads files from disk by path). schemaVersion bumped 1 -> 2 so
the change is explicit; v1 was never consumed.
- mkdirSync(dir, { recursive: true }) at entry covers projects
with DB rows but no on-disk directory yet (codex bot finding).
- Refactored node:fs imports from named to default
(import fs from 'node:fs') so vitest spies in tests #15-#17 can
redefine properties on the underlying CJS exports object. ESM
namespace imports of node:fs produce a frozen Module Namespace
Object that vi.spyOn cannot mutate; default-import returns the
CJS module.exports which is mutable.
- Inline PersistedAgentEvent union: the daemon tsconfig does not
resolve the `@open-design/contracts/api/chat` subpath export, so
the union is restated in the source. Schema-mismatch tests cover
the case where the contract would diverge.
- Test count 14 -> 24: failure injection for writeFileSync /
fsyncSync / renameSync, existing-file replacement, lockfile
contention (lockfile-pre-create design — synchronous API can't
race via Promise.allSettled), parse-warning cases (malformed +
not-array), attachments header + per-message coverage, missing-
project-dir case.
Refs nexu-io/open-design#450 (does not close).
* fix(daemon): preserve thinking-segment boundaries on status thinking-start
Codex flagged this as a P2 on PR #493a39d430:
https://github.com/nexu-io/open-design/pull/493#discussion_r3188524878
The web translator emits `{ kind: 'status', label: 'thinking' }` at every
thinking_start (apps/web/src/providers/daemon.ts:367-369). The previous
default branch dropped all status events without flushing the active
accumulator, so two thinking segments separated only by that marker
merged into one block — losing the original boundary and making the
transcript non-lossless for downstream synthesis.
coalesceBlocks now matches `status` explicitly: when `label === 'thinking'`
the prior accumulator flushes; other status labels and usage / raw drop
without flushing as before. Behavior fix within schemaVersion 2; no
shape change.
Test #25 verifies the boundary preservation:
[thinking 'a', thinking 'b', status 'thinking', thinking 'c', thinking 'd']
→ blocks: [thinking 'ab', thinking 'cd'] (two blocks, not one)
Existing test #5 still passes because it uses status with label 'streaming'
which remains pure telemetry and does not flush.
Suite: 506 -> 507 daemon tests, all green.
Refs nexu-io/open-design#493
---------
Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skills): add 5 Orbit briefing templates
Introduces a new "orbit" scenario family in the Examples gallery for
morning-briefing surfaces. Each template lives at the top of "我的设计"
and aggregates yesterday's connector activity into a single page.
- orbit-general: adaptive bento dashboard that fans across 12-16
connectors, where each module picks its own UI form by data type
(list / avatar stack / status ring / heatmap / file grid / alert
card / kanban / etc.)
- orbit-github: GitHub-flavored single-connector digest mirroring the
Notifications + PR-diff visual language
- orbit-gmail: Gmail-flavored digest rendered as a Daily Digest email
inside the three-pane inbox
- orbit-linear: Linear-flavored digest in the dark Inbox + cycle-
progress layout
- orbit-notion: Notion-flavored digest authored as a native Notion
page (callout / toggle / database table)
The new scenario value 'orbit' surfaces as a filter pill in
ExamplesTab automatically; no UI code change required.
* fix(skills): reframe Orbit skill descriptions as pipeline-triggered
The original descriptions framed each skill as a standalone "X-flavored
briefing template" the user picks. They are actually skills the Orbit
daily-digest pipeline selects automatically based on which connectors
the user has authenticated, then runs against live connector data.
Rewrites both `description` and `example_prompt` for all 5 templates:
- orbit-general: invoked when 2+ connectors are connected; aggregates
the past 24h across every authenticated source
- orbit-github / orbit-gmail / orbit-linear / orbit-notion: invoked
when the named connector is the user's only connection (or scope is
explicitly limited to it); pulls the past 24h from that connection
alone
All 5 now state explicitly that they are not user-triggered — the
Orbit scheduler invokes them.
* feat(examples): add Orbit pill to the mode filter row
Surfaces the Orbit briefing skills as a top-level "type" filter in the
Examples gallery, alongside Prototype · Desktop / Mobile / Slide deck /
Docs & templates. Filter matches skills with scenario === 'orbit'.
- ExamplesTab: extend ModeFilter and MODE_PILLS with 'orbit'; teach
matchesMode and modeCounts about it
- i18n: add 'examples.modeOrbit' to Dict and to all 16 locale files
('Orbit' is left untranslated as a brand name)
* polish(orbit-general): real Figma preview image + revised comment
Replaces the empty gray placeholder in the Figma module with an
Unsplash UI-design photo, and rewrites the mock comment to read like
a substantive design-review note rather than a nit about button
placement.
* feat(examples): eager-load card previews via IntersectionObserver
Card previews previously only loaded on hover, leaving the example
gallery showing 'Hover to preview' placeholders for everything below
the fold. Now each card observes the viewport and prefetches its HTML
800px before scrolling into view, so the iframe is ready by the time
the user reaches it.
Hover remains as a fallback path (and for browsers without
IntersectionObserver, the card loads immediately on mount).
Also reverts the Unsplash photo on the orbit-general Figma module
back to the gray placeholder — the stock image semantically misread
as a Photoshop screenshot rather than a Figma artboard.
* feat(orbit-general): drop Figma connector module
Removes the Figma bento card and its scoped CSS, plus the orphaned
Top-3 entry that referenced a Figma comment. Reassigns Top-3 #2 to
a Notion document review so the priority list stays aligned with
the connectors actually rendered.
* i18n(skills): translate Orbit example prompts to English
The example_prompt is what gets injected into the chat input when a
user clicks 'Use this prompt', and is read by the agent verbatim. It
should match the SKILL.md description language (English), not the UI
locale. Replaces the Chinese drafts with English equivalents across
all 5 Orbit skills, and drops the Figma reference from orbit-general
since that connector module was removed earlier.
* fix(skills): rewrite Orbit SKILL.md bodies with reproducible specs
Earlier the bodies were too abstract (only a connector→UI mapping
table and a one-line style note), so agents running the skill could
not reproduce the shipped example.html and got stuck in long retries.
Each SKILL.md body now contains:
- exact color tokens lifted from the example.html
- type stack and font sizes
- a section-by-section page spec (top-to-bottom)
- chip / pill / icon rendering rules
- forbidden list
The example_prompt is collapsed back to a one-line user intent so the
skill body is the source of design truth.
Covers all 5 templates: orbit-general, orbit-github, orbit-gmail,
orbit-linear, orbit-notion.
* feat(orbit): make every connector item clickable
Each Orbit briefing template now links its rows / cards to the matching
source URL so users can jump straight from the morning digest to the
underlying connector.
- orbit-general: each bento card gains an 'Open in {connector} ↗' CTA
built from a connector→URL map; each Top 3 card becomes an anchor
- orbit-github: every event row opens the corresponding github.com
pull/issue URL parsed from the row identifier; the header logo links
to the repo
- orbit-linear: each issue row gains a small ↗ button that opens
linear.app/{team}/issue/{ID}
- orbit-gmail: action and reply buttons jump to a Gmail search URL
scoped to the sender
- orbit-notion: page-link spans wrap as anchors and database rows are
click-to-open against notion.so
All links use target="_blank" rel="noopener noreferrer".
* fix(skills): force agents to mirror example.html 1:1
Earlier skills told the agent the example was 'source of truth' but
left phrasing soft, so agents felt free to add extra UI elements
(snoozed-mail row, extra yellow stars on inbox rows, etc.) that
were not in example.html.
Each Orbit SKILL.md now opens with a 'Source-of-truth protocol' that
forces the agent to:
1. read example.html before writing any output
2. mirror its DOM structure / class names / module count / element
order 1:1
3. only refresh mock values; never invent additional UI elements,
rail entries, sections, badges, or chrome ornaments
The reference sections that follow stay informative for tokens and
visual language but are explicitly demoted from spec to commentary.
* fix(orbit-gmail): remove three-pane / left-rail / inbox-list claims
The example.html is a single-column page: Gmail top header + the
opened Orbit Daily Digest email (toolbar / subject / sender / digest
body / reply bar). Earlier copy described a Gmail three-pane app with
Compose button, label list, Categories tabs, and an inbox listing —
none of which exist in the actual asset.
- example_prompt: drops 'three-pane inbox' phrasing
- description: same
- body: rewrites Page sections to mirror the real header → email-chrome
layout, top to bottom; explicitly forbids left rail, inbox list, and
Categories tab strip
* feat(orbit): forbid external design systems in all 5 skills
Each Orbit briefing template ships its own complete visual language
baked into example.html (Gmail / GitHub / Linear / Notion / Open
Orbit's editorial bento). Adds an explicit 'Design system policy'
block telling the agent to:
- ignore any DESIGN.md attached to the active project
- ignore brand tokens or Figma files supplied via chat
- use exclusively the colors / fonts / radii from example.html
This is a hard constraint: an Orbit briefing must look like the
connector it represents, not like the user's brand.
* feat(newproj): hide design-system picker for skills that opt out
Skills can declare 'od.design_system.requires: false' in SKILL.md to
opt out of DESIGN.md injection (the Orbit briefing skills do this —
their example.html ships with a complete connector-native visual
language). When the active default skill for a tab opts out, hide the
design-system picker so we don't ask the user to attach a brand we'll
then ignore.
Existing tabs that don't host a default skill (template, other) keep
the picker. The check only fires for prototype / live-artifact / deck.
* review: address P2 reviewer feedback
P2 — Connector family coverage gaps (orbit-general):
Adds Finance, CRM/Sales, Support, Analytics, Infrastructure, Security
rows to the connector→UI mapping table (now 16 families). Adds a
'Fallback heuristics' subsection so unknown connectors are routed by
data shape (numbers + time series → Alerts, rows + status field →
Task mgmt, etc.).
P2 — 'Forbidden' rules too vague (all 5 skills):
Rewrites every Forbidden section as a paired 'Don't / Do' constraints
table so each negative is paired with a concrete positive. Replaces
obvious bans (lorem ipsum) with substantive ones (real-shaped mock
copy, plausible identifiers, dev-team label hues, etc.).
* ci: register orbit skills in de/ru/fr en-fallback lists
The localized-content coverage test asserts that every skill in
skills/ is either translated or explicitly declared as falling back
to English in the LOCALIZED_CONTENT_IDS bundle. The 5 new orbit
skills weren't in any bundle, so the workspace validation job failed
on the de/ru/fr coverage assertions.
Adds the 5 orbit-* ids to DE/FR/RU_SKILL_IDS_WITH_EN_FALLBACK so
those locales explicitly fall back to the SKILL.md English copy
(matching the minimal-change posture chosen earlier in this PR).
* fix(daemon, packaged): unbreak GUI-launched agent detection on minimal PATHs (#442)
GUI-launched daemons (Finder/Dock on macOS, .desktop on Linux) inherit a
stripped PATH from launchd / the desktop session and don't read the
user's interactive shell rc files, so any CLI installed via `npm i -g`
under a sudo-free prefix like ~/.npm-global was silently undetected.
Two layers maintained their own copies of the user-toolchain bin list
(`apps/daemon/src/agents.ts:userToolchainDirs` for the resolver,
`apps/packaged/src/sidecars.ts:resolvePackagedPathEnv` for the packaged
sidecar PATH builder) and had already drifted on `~/.asdf/shims` and
`~/Library/pnpm`. Adding ~/.npm-global to one side would have
preserved the same anti-pattern.
Extracts `wellKnownUserToolchainBins` into @open-design/platform as the
single source of truth, has both layers consume it, and extends the
list to cover ~/.npm-global/bin, ~/.npm-packages/bin, plus
$NPM_CONFIG_PREFIX/bin / $npm_config_prefix/bin for users with a
non-standard prefix. New vitest coverage in the platform package and
a regression test in apps/daemon/tests/agents.test.ts modelled on the
existing mise case.
Verified end-to-end: under PATH=/usr/bin:/bin:/usr/sbin:/sbin (the
launchd default a `.app` actually inherits), `resolveAgentExecutable`
now returns ~/.npm-global/bin/gemini instead of null.
* fix(daemon): isolate OD_AGENT_HOME resolution from $NPM_CONFIG_PREFIX leakage
Address review feedback on PR #614:
- mrcfps spotted that the daemon wrapper called wellKnownUserToolchainBins
without passing `env`, so the helper read its default process.env. A
developer or CI runner with NPM_CONFIG_PREFIX / npm_config_prefix
exported would inject that real <prefix>/bin into resolveOnPath() even
while the OD_AGENT_HOME hook pointed home at a temp fixture, making
agent-detection tests environment-dependent. Reproduced locally: with
OD_AGENT_HOME=<tmp> + NPM_CONFIG_PREFIX=/Users/me/.npm-global,
resolveAgentExecutable({ bin: 'codex' }) returned the real machine's
binary instead of null. Wrapper now passes `env: {}` whenever
homeOverride is set, alongside the existing includeSystemBins gate.
- lefarcen suggested also handling whitespace-only NPM_CONFIG_PREFIX
values (e.g. NPM_CONFIG_PREFIX=" ") so the helper does not emit a
bogus "<whitespace>/bin" entry. Added a .trim() check before
appending.
- lefarcen also suggested a comment pointer from the daemon wrapper to
the platform helper so readers don't have to grep. Added the
reference inline.
Coverage:
- packages/platform/tests/index.test.ts: new whitespace-prefix case.
- apps/daemon/tests/agents.test.ts: new env-isolation regression
asserting that OD_AGENT_HOME + NPM_CONFIG_PREFIX cannot leak the
real prefix bin into the sandbox.
* test(daemon): preserve $NPM_CONFIG_PREFIX across the env-isolation case (#614)
Address mrcfps's second-round review on PR #614: the env-isolation
regression sets `process.env.NPM_CONFIG_PREFIX = realPrefix` in its
body and then unconditionally `delete`s it in `finally`. On a developer
machine or CI runner that already exported `NPM_CONFIG_PREFIX`, that
mutates the worker-wide env for every later test, making downstream
env-sensitive assertions order-dependent.
Move the save/restore into the file's existing afterEach hook (mirroring
the OD_AGENT_HOME / OD_DAEMON_URL / OD_TOOL_TOKEN pattern) and drop the
in-test `delete`. Same coverage, no worker-state mutation.
* fix(platform): prioritise $NPM_CONFIG_PREFIX over the conventional npm guesses (#614)
Address mrcfps's third-round review on PR #614: when the user has
explicitly configured a prefix via $NPM_CONFIG_PREFIX (or
$npm_config_prefix), that's where `npm i -g` puts the *current*
binaries. The conventional guesses ~/.npm-global / ~/.npm-packages
often hold *stale* installs from an older prefix the user has since
rewritten — searching the env-driven prefix first matches npm's own
resolution order (env > .npmrc > default) and gives "explicit beats
convention" semantics.
Move the env-driven push above the conventional `dirs.push(.npm-global,
.npm-packages)`. Add a vitest case in the platform package that asserts
$NPM_CONFIG_PREFIX/bin's index in the result is strictly less than
~/.npm-global/bin's and ~/.npm-packages/bin's.
`resolveOnPath()` and the packaged PATH builder both preserve insertion
order, so first hit wins and the new ordering propagates to both
layers.
* fix(platform): lift $NPM_CONFIG_PREFIX above every conventional bin (#614)
Address mrcfps's fourth-round review on PR #614: the previous fix only
moved $NPM_CONFIG_PREFIX/bin ahead of ~/.npm-global / ~/.npm-packages,
but ~/.local/bin still appeared earlier in the array. Under a minimal
GUI-launch PATH a stale agent in ~/.local/bin (also a shared dumping
ground for pip --user / cargo install / hand-built binaries) could
outrank the user's *current* explicit npm prefix.
Move the env-driven push to the head of `dirs` so the explicit prefix
wins over every conventional location below — ~/.local/bin included.
Matches npm's own resolution order (env > .npmrc > default) across the
whole list, not just the npm-prefix block.
Tightened the existing order test to assert `explicitIdx === 0` and
that ~/.local/bin's index is strictly greater than the explicit
prefix's index, so a future drift would fail loudly.
* feat(media): add Nano Banana image provider
* fix(media): support Gemini API key headers for Nano Banana
* refactor(media): move Nano Banana model override flag into provider metadata
* feat(craft): add form-validation + opt-ins on saas-landing, mobile-onboarding
Module 5 of 5 in the behavioral craft series proposed in #501.
Modules 1-4 merged: state-coverage (#502), animation-discipline (#515),
accessibility-baseline (#587), rtl-and-bidi (#595).
Picks up where accessibility-baseline.md ends (label + describedby +
invalid + role=alert for inline errors) and connects the four layers a
real form spans: WHATWG Constraint Validation as the platform floor,
validation timing as a state machine on the input, WCAG 3.3.x as the
announcement and recovery contract, schema as the cross-stack truth.
Sections: input state machine; validation timing (4 rules anchored on
:user-invalid Baseline 2023); Constraint Validation API rules
(setCustomValidity, requestSubmit vs submit, readonly + #11841,
inputmode); error wiring beyond the baseline (adaptive messages,
error summary without role=alert, preserve user input on error);
schema as cross-stack contract (Standard Schema, server-authoritative,
Zod 4 z.email() form); WCAG 3.3.3 / 3.3.4 / 3.3.8 / 3.3.9; native
mobile parity (UIKit, SwiftUI, Compose, Flutter, RN); common mistakes.
Reviewed in 3 loops with Claude CLI Opus 4.7 xhigh effort:
- Loop 1: 6 P0s caught (SwiftUI Form validity claim, SwiftUI
announcement primitive, Compose semantics syntax, UIKit
UIAlertController, contradictory Baymard stats, 3.3.8 CAPTCHA
framing reversed) + 11 P1/P2s; all addressed.
- Loop 2: verified P0 fixes; flagged 1 P1 (RN table row scrambled) +
4 P2s; all addressed.
- Loop 3: SHIP verdict. Three P2 nits applied (Zod 4 z.email() form,
WebAIM Million 2026 stat woven in: 51% page-level, 33.1% input-level).
WebAIM Million 2026 numbers verified directly against
webaim.org/projects/million/.
Skill opt-ins: saas-landing (lead capture form), mobile-onboarding
(sign-in screen). Skill bodies do not contain validation-specific
instructions that would override craft guidance — opt-in alone is
sufficient. README updated.
Refs #501.
* fix(craft+skills): form-validation review fixes (lefarcen + mrcfps P2s)
Both non-blocking findings addressed:
- Drop form-validation from saas-landing.craft.requires. The skill body
produces a CTA-driven landing page with no JS and no interactive
form. Adding form-validation injected ~221 lines of irrelevant prompt
pressure and conflicted with the README opt-in rule ("primary
artifact contains an interactive form"). mobile-onboarding keeps the
opt-in — sign-in screen is a real form.
- Reword timing rule 4 (async checks). Previous "never block submit on
a network round-trip" was too broad and conflicted with the
schema-layer "server is the truth" rule. Split into two paths:
background preflight (uniqueness, address lookup) doesn't gate the
form; authoritative submit-path server validation must await the
server response and surface its field errors. The rule is "don't let
a slow background check freeze the form," not "don't ever wait for
the server."
* fix(craft): form-validation mrcfps round-2 (novalidate trade-off, Flutter RTL)
Two non-blocking precision items:
- novalidate trade-off: previous wording said keeping required/pattern/type
preserves no-JS PE, but a literal server-rendered <form novalidate>
disables the browser's submit-blocking and validation UI even when
JS is unavailable — losing the no-JS constraint-validation floor.
Reworded to spell out the two safe patterns: (A) render <form>
without novalidate server-side and have the form library set
form.noValidate = true after hydration, or (B) ship novalidate from
the start only when the submit path reaches server validation
without JS. Either way, keep the constraint attributes.
- Flutter announcement example: hardcoded TextDirection.ltr would
announce Arabic/Hebrew/Persian validation messages with wrong bidi
direction when this craft is combined with rtl-and-bidi. Switched
to SemanticsService.announce(message, Directionality.of(context))
with an explicit warning never to hardcode the direction.
* fix(craft): form-validation mrcfps round-3 (readonly safety, Compose error message)
Two non-blocking precision items:
- Non-input readonly fallback: previous text said `aria-readonly` plus
hidden mirror input was an option for non-input controls that need
to submit. But `aria-readonly` doesn't actually stop a `<select>` or
custom widget from being changed, so the visible control can drift
while the hidden input ships a stale value — user sees one option,
server gets another. Tightened: prefer `disabled` plus a same-named
hidden input, or non-editable text plus hidden input. If using
`aria-readonly`, the interaction must also be blocked or the two
values kept in sync.
- Compose error message: previous rule was too absolute about avoiding
`Modifier.semantics { error("…") }`. `isError = true` flips the
field state but does not carry the localized error message; Android
Compose accessibility guidance pairs `isError` with
`semantics { error(message) }` so the accessibility service gets the
real text. The trap is duplication, not the API itself. Reframed
the rule: use both, source the message from the same state field as
`supportingText` so they stay in sync.
* fix(craft): form-validation Compose live-region API name
Compose row in the native-mobile parity table named a "LiveRegion"
semantic that doesn't exist. Real API is `Modifier.semantics
{ liveRegion = LiveRegionMode.Polite }` on the supporting-text node.
Also replaced the generic `view.announceForAccessibility(…)` with the
Compose-idiomatic `LocalView.current.announceForAccessibility(message)`
so generated snippets compile.
* fix(web): improve settings dialog scroll behavior
- Remove double scroll containers by changing modal-body overflow from auto to hidden, letting only settings-content handle scrolling
- Add min-height: 0 to settings-content and settings-sidebar to allow proper shrinking in grid layout
- Add overscroll-behavior: contain to prevent scroll chaining (scroll bleed-through to parent page)
- Add overflow-y: auto to settings-sidebar for cases where navigation items exceed viewport height
These changes fix the nested scroll issue that caused confusing scroll behavior and prevent content overflow on smaller viewports.
* fix(i18n): add missing Ukrainian translations for promptTemplates
Add promptTemplates.allSources and promptTemplates.sourceFilterAria translations to fix TypeScript error.
* feat(daemon): let Codex image projects avoid API-key setup
Codex has a built-in image generation path available inside the agent runtime, while the generic media dispatcher still routes gpt-image models through the daemon OpenAI provider. Pass the active agent id into prompt composition so Codex-only gpt-image projects can use built-in imagegen first without changing non-Codex media behavior.
Constraint: Existing media contract remains the default path for non-Codex agents and explicit provider fallback
Rejected: Add a nested daemon Codex media provider | heavier auth, streaming, timeout, cancellation, and output parsing surface for this parity fix
Confidence: high
Scope-risk: narrow
Directive: Keep this override after the media contract so it can intentionally supersede dispatcher-only wording for Codex gpt-image projects
Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/system-prompt-template.test.ts
Tested: pnpm --filter @open-design/daemon typecheck
Tested: pnpm guard
Tested: pnpm typecheck
Not-tested: Live Codex image generation inside the Open Design UI
* fix(daemon): harden Codex imagegen prompt routing
PR review found the Codex override could be superseded by the web-supplied media contract, trusted unvalidated image model metadata, and assumed generated image paths outside the workspace were readable.
This keeps the override daemon-owned, appends it last in the live prompt, validates against registered gpt-image model IDs, allowlists only Codex's generated_images folder, and tightens copy-failure instructions.
Constraint: The web contracts composer still emits the generic media contract without agent identity.
Rejected: Mirror Codex-specific prompt logic into contracts/web | duplicates daemon model registry and still leaves final ordering fragile.
Confidence: high
Scope-risk: narrow
Directive: Keep Codex imagegen override appended after client systemPrompt so it remains the final media instruction for Codex gpt-image projects.
Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/system-prompt-template.test.ts tests/agents.test.ts tests/chat-route.test.ts
Tested: pnpm --filter @open-design/daemon typecheck
Tested: pnpm guard
Tested: pnpm typecheck
Not-tested: Live Codex image generation inside the Open Design UI
* fix(daemon): keep Codex add-dir writable scope narrow
PR review found Codex --add-dir grants writable workspace access, so passing skill, design-system, and linked reference directories through the same chat allowlist broke their documented read-only boundary.
This routes chat extra directories by active agent: Codex receives only the validated generated_images output directory needed for built-in imagegen, while non-Codex adapters keep the existing resource and linked-directory read access behavior.
Constraint: Codex CLI treats --add-dir as writable sandbox expansion.
Constraint: The daemon still stages active skill files into the project cwd as Codex's read-safe path.
Rejected: Keep one shared extraAllowedDirs list for all agents | grants Codex write access to read-only resources.
Confidence: high
Scope-risk: narrow
Directive: Do not add read-only resource/reference directories to Codex --add-dir unless Codex gains a read-only allowlist flag.
Tested: git diff --check -- apps/daemon/src/server.ts apps/daemon/tests/chat-route.test.ts
Tested: pnpm --filter @open-design/daemon exec vitest run tests/chat-route.test.ts
Tested: pnpm --filter @open-design/daemon typecheck
Tested: pnpm guard
Tested: pnpm typecheck
Not-tested: Live Codex image generation inside the Open Design UI
* fix(daemon): validate Codex imagegen add-dir grants
PR review found the generated_images grant still trusted symlinked paths and rendered the Codex override before proving the sandbox grant would be present.
This validates the generated_images directory before prompt assembly, rejects final-component symlinks and protected-root canonical escapes, passes Codex the canonical grant path, and only appends the Codex imagegen override when that same path is in extraAllowedDirs.
Constraint: Codex --add-dir grants writable workspace access, so path aliases into read-only resource roots must be rejected.
Rejected: Keep returning the nominal CODEX_HOME path after validation | leaves Codex operating through a symlink alias instead of the audited grant target.
Confidence: high
Scope-risk: narrow
Directive: Keep Codex imagegen prompt rendering downstream of generated_images validation and grant resolution.
Tested: git diff --check -- apps/daemon/src/server.ts apps/daemon/tests/chat-route.test.ts
Tested: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/chat-route.test.ts
Tested: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/agents.test.ts tests/chat-route.test.ts
Tested: pnpm --filter @open-design/daemon typecheck
Tested: pnpm guard
Tested: pnpm typecheck
Not-tested: Live Codex image generation inside the Open Design UI
* i18n(es): align README.es.md UI references to es-ES.ts locale
The README.es.md merged in #552 references the UI in English in a
handful of places where the actual Spanish locale (es-ES.ts, shipped
in #182) renders different strings. A reader who follows the README
into the app sees mismatched button and tab names. This PR aligns
those references.
- 'Send' -> 'Enviar' (chat.send: 'Enviar' in es-ES.ts)
- 'Save to disk' -> 'Guardar en disco' (common.save / fileViewer.save
resolve to 'Guardar'; 'en disco' mirrors the descriptive looseness
of the English original which is also not a strict button label)
- 'Image templates' / 'Video templates' tabs ->
'Plantillas de imagen' / 'Plantillas de vídeo' (entry.tabImageTemplates
and entry.tabVideoTemplates in es-ES.ts)
- 'Use this prompt' -> 'Usar este prompt' (examples.usePrompt)
- Persistencia bullet: 'tabs' / 'tab activa' -> 'pestañas' /
'pestaña activa' (workspace.closeTab: 'Cerrar pestaña' and 8+ other
uses establish 'pestaña' as the canonical Spanish term in the UI)
- Subsection 5: 'Sessions, conversations, messages y tabs persisten…'
-> 'Sesiones, conversaciones, mensajes y pestañas persisten…'.
Fixes the inconsistency of mixing English nouns with Spanish
conjunction; treating these as concepts (parallel to the English
original's lowercase non-code list) reads as Spanish prose.
ASCII tree comments and SQLite literal table names ('tabs' as a
column reference, 'projects · conversations · messages · tabs' code
identifiers) are intentionally left untouched.
* i18n(es): align Settings → Ajustes (MCP server section)
Spotted in PR review: line 407 in the 'Usar Open Design desde tu
coding agent' section references the settings entry as 'Settings'
when es-ES.ts consistently renders it as 'Ajustes' (settings.kicker,
entry.openSettingsTitle, avatar.settings — three independent uses
in the locale, all 'Ajustes').
The 'MCP server' part of the path is currently hardcoded in the UI,
so it stays in English.
* feat(prompt-templates): add 11 HyperFrames video prompts and surface media generation in README
Adds eleven `hyperframes-*` prompt templates under `prompt-templates/video/`,
each one a concrete brief with scene-by-scene timing, GSAP eases, palette,
and the HyperFrames non-negotiables (deterministic, paused timelines,
entrance-only motion, lint/inspect commands). Archetypes covered:
- minimal product reveal (5s, 16:9)
- SaaS product promo (30s, 16:9, Linear/ClickUp-style)
- TikTok karaoke talking-head (9:16, TTS + word-synced captions)
- brand sizzle reel (30s, beat-synced kinetic typography)
- animated bar-chart race (NYT-style data infographic)
- Apple-style flight map route (origin → destination)
- 4s cinematic logo outro
- $0 → $10K money counter hype (9:16)
- 3-phone app showcase
- 9:16 social overlay stack (X · Reddit · Spotify · Instagram)
- 15s website-to-video pipeline
Each template uses `model: "hyperframes-html"`, real catalog-block thumbnails
from HeyGen's CDN as previewImageUrl, and source attribution to
`heygen-com/hyperframes` (Apache-2.0).
README also gets a new **Media generation** section between *Visual directions*
and *Beyond chat*, plus a new row in the *At a glance* table. The section
documents the three model families currently surfaced as templates
(gpt-image-2, Seedance 2.0, HyperFrames) with example galleries — gpt-image-2
thumbnails, Seedance MP4-linked thumbnails, and the 11 HyperFrames tiles —
and notes the wider model coverage (Kling, Veo, Sora, MiniMax, Suno, Udio,
Lyria, TTS) already wired in `VIDEO_MODELS` / `AUDIO_MODELS_BY_KIND` and
open for community templates.
* i18n(de): register new HyperFrames templates, categories, tags
Adds German titles/summaries for the 11 new hyperframes-* video templates
plus the Product/Marketing/Data/Travel/Branding/Short Form categories and
hyperframes/title-card/sizzle/etc. tags they introduce, so the German sync
guarantees enforced by apps/web/src/i18n/content.test.ts hold.
* docs(readme): sync Media generation section to de / ja / ko / zh-CN; bump counts to 93 (43 + 39 + 11)
Mirrors the English Media generation row + section into the four locale READMEs
(README.de.md, README.ja-JP.md, README.ko.md, README.zh-CN.md), translating
prose / table headers / captions while keeping the gpt-image-2, Seedance MP4,
and HyperFrames catalog-block thumbnails identical across all five locales so
the galleries render the same images.
Counts updated to reflect current main (after rebase): 43 gpt-image-2 + 39
Seedance + 11 HyperFrames = 93 prompts total. The English README's At-a-glance
row, intro paragraph, and gallery sub-headers now read "sample of 43" /
"sample of 39" / "11 ready-to-replicate templates" — locales follow.
Resolves the Codex review's German-i18n flag end-to-end: README copy is in
sync, and the German content map (DE_PROMPT_TEMPLATE_*) was already extended
in the prior commit on this branch.
* feat(prompt-templates): video previews + provider badge + source filter for HyperFrames
- Add `previewVideoUrl` to all 11 HyperFrames video templates so the preview
modal plays the real catalog clip instead of falling back to a static image.
- Add a per-card provider badge (top-left thumbnail chip) keyed off
`source.repo`. HyperFrames cards get a HeyGen-accent gradient so they are
identifiable at a glance; other repos get a neutral pill.
- Add a Source filter dropdown next to Category in PromptTemplatesTab,
populated from the small enumerated repo set (HyperFrames, Seedance 2,
GPT Image 2, Open Design). Auto-hides when only one source is present.
The text search now also matches the provider name.
- Wire i18n keys `promptTemplates.allSources` and
`promptTemplates.sourceFilterAria` across all 9 locales.
* fix(daemon): ignore .venv and other large dirs in project file watcher
A project containing a Python virtual environment (.venv) could exhaust
the daemon's file descriptor table — chokidar recursively watched every
file in the tree, opening ~18 000 fds. With the fd table full, macOS
posix_spawn returned EBADF when the daemon tried to create stdio pipes
for a child process (codex exec, or any other agent), surfacing as
"spawn failed: spawn EBADF" on every chat request.
Adds .venv, venv, __pycache__, .mypy_cache, .pytest_cache, .tox,
.ruff_cache, target, vendor, and .cargo to the per-segment IGNORE_NAMES
set so the watcher skips these trees in any project.
* fix(daemon): narrow project-watcher ignores to safe Python dirs only
Remove target, vendor, and .cargo from IGNORE_NAMES — they match any
path segment, so src/vendor/… or .cargo/config.toml (a valid Rust
project file) would be silently dropped from file-change events.
Keep only the Python-specific names (.venv, venv, __pycache__, and the
mypy/pytest/tox/ruff caches) which are never legitimate authored source
at any depth and were the root cause of the fd-exhaustion bug.
Add a real-chokidar test covering all seven newly added ignore dirs.
---------
Co-authored-by: hbrown <hbrown@mitre.org>
* fix(daemon): add required env field to McpServerStdio in live-artifacts MCP descriptor
The ACP schema's McpServerStdio marks env as a required field
(List[EnvVariable] with no default). Omitting it causes Pydantic V2
Union validation to fail across all three variants (HttpMcpServer,
SseMcpServer, McpServerStdio), returning -32602 Invalid params on
session/new for agents with mcpDiscovery: 'mature-acp' (Hermes,
Devin, Kimi).
This bug is invisible when mcpServers resolves to an empty array
(no live-artifacts token), so it only manifests when the MCP
live-artifacts integration is enabled.
* fix(daemon): recover from -32602 Invalid params on session/set_model
Extend the existing -32603 Internal error recovery logic to also
handle -32602 (Invalid params) when the set_model request fails.
This allows the prompt to proceed with the default model instead of
hanging or timing out.
Some ACP agents may not support session/set_model or may reject the
model ID — treating this as a non-fatal condition and falling back to
the default model is more resilient than failing the entire run.
* fix(daemon): narrow -32602 handling and update test fixtures for env field
Address PR #627 review feedback:
1. Narrow -32602 Invalid params suppression to setModelRequestId only.
Unexpected-id -32602 errors are now treated as real protocol failures
and propagated via fail(), matching the reviewer's suggestion. Only
-32603 Internal errors from unexpected IDs are still suppressed as
cleanup noise.
2. Update all buildLiveArtifactsMcpServersForAgent test fixtures to
include the new required env: [] field.
Kimi CLI 1.35.0 expects MCP stdio servers to include 'type', 'name',
'command', 'args', and 'env' fields. Open Design was passing only
'name', 'command', and 'args', which caused session/new to return
JSON-RPC -32602 Invalid params when MCP discovery was enabled.
This change normalizes every MCP server descriptor to the full ACP
stdio shape before sending it over the wire.
* feat(craft): add rtl-and-bidi + opt-ins on blog-post, docs-page, finance-report
Module 4 of 5 in the behavioral craft series proposed in #501. Modules
1 (state-coverage, #502) and 2 (animation-discipline, #515) merged.
Module 3 (accessibility-baseline, #587) open at time of authoring.
Differentiating niche per the corpus prior-art survey: zero existing
OSS RTL skill is Apache-2.0, framework-agnostic, and aligned with
UAX #9 rev 51. The closest comparators (idanlevi1/rtlify 5★, MIT;
skills-il/localization 7★, MIT) are LTR-web-skewed and don't cover
Flutter Directionality, RN I18nManager, Compose LocalLayoutDirection,
or iOS UIKit semanticContentAttribute / SwiftUI layoutDirection.
Three-loop adversarial review pass via Claude Opus 4.7 xhigh effort
(codex unavailable). Loop 1 caught five revisions (typography spin-out,
WebKit prose compression, mistakes-list trim 12→9, alreq letter-spacing
rename dropped, WebKit r94775 specific revision dropped). Loop 2 caught
one blocking SwiftUI 4 claim and three nits. Loop 3 said ship.
Skill opt-ins picked to avoid PR #587 merge surface: blog-post (long-form
text), docs-page (LTR code islands in RTL prose), finance-report
(numerals + IBAN + currency).
Refs #501.
* fix(craft): rtl-and-bidi review fixes (lefarcen 6 findings)
- P2 #1 WebKit #50949: bug is RESOLVED FIXED, not still open. Verified
directly against bugs.webkit.org. Removed the broken-WebKit framing;
the recommendation to prefer <bdi> over CSS now stands on UAX #9
§2.7 ("prefer markup over CSS or control characters") rather than a
WebKit bug. Source list updated to drop the dead reference.
- P2 #2 isolate vs embedding controls: U+202C PDF is the
embedding/override terminator, not an isolate terminator. Split into
two families: isolate controls (U+2066/2067/2068 + U+2069 PDI) for
modern code, embedding/override controls (U+202A/202B/202D/202E +
U+202C PDF) as legacy. Recommend isolates first.
- P2 #3 base direction and language: new section covering
<html dir lang>, mixed-language subtrees, dir=auto for UGC. Without
this, agents can follow every other rule and still ship an LTR
document containing Arabic.
- P2 #4 phone/IBAN/card values: bare <bdi> is unreliable for
weak/neutral character runs; updated must-mirror bullet and forms
section to require <bdi dir="ltr">. Added common-mistake entry.
- P3 #1 native mobile budget: added a one-line opt-out hint at the
top of the section so HTML-only skills know they can skim it. Full
split into web/native files deferred — the table is 16 lines on a
176-line file, the cost is bounded.
- P3 #2 lintability: restructured "common mistakes" into three groups
— mechanically lintable, needs script detection, HTML semantics —
with explicit exception language (chart axes, physical-object icons,
platform-pinned UI). Avoids false positives in future linting.
Reviewed via Claude CLI Opus 4.7 xhigh effort (3 loops on the
original draft); these fixes are explicit reviewer responses with
WebKit Bugzilla state verified live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(craft): rtl-and-bidi mrcfps round-2 precision (lang+dir, isolate picks)
Two non-blocking precision items:
- lang-without-dir scope: previous wording implied English never needs
dir="ltr". True only at the document root in a default-LTR page.
lang does not reset an inherited bidi base direction, so an
<section lang="en"> inside an RTL ancestor still resolves RTL.
Reworded to "lang without dir is fine at the document root in a
default-LTR page; inside any opposite-direction ancestor, set both."
- Plain-text isolate picks: previous wording recommended U+2068 / U+2069
generically. U+2068 is FSI (first-strong auto-detect) — wrong default
for known-direction runs, especially weak/neutral-heavy values like
phone, IBAN, card numbers (the same class this file forces to LTR in
HTML). Split: LRI/PDI for known-LTR, RLI/PDI for known-RTL, FSI/PDI
reserved for unknown direction. Added an explicit "don't default to
FSI" callout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(craft+skills): rtl-and-bidi mrcfps round-3 — skill-body conflicts + bidi semantic correction
P1 BLOCKING — skill-body physical-direction conflicts (mrcfps):
- skills/docs-page: "left nav" / "right-rail TOC" / "left-edge accent
stripe" survive in skill body even with the rtl-and-bidi opt-in,
because craft is injected ABOVE the skill body. An Arabic docs
request would still see "Left nav" and emit physical-direction
layout. Updated description, lay-out section, and self-check to
inline-start / inline-end vocabulary; added a self-check bullet
requiring logical CSS on rails and accent.
- skills/blog-post: pull-quote "accent rule on the left" updated to
"accent rule on the inline-start edge" with a matching note about
flipping under dir="rtl".
P1 craft semantic correction (mrcfps):
- HTML-semantics lint: previous wording equated <bdi dir="auto"> with
unicode-bidi: plaintext. Not equivalent. <bdi> isolates an inline
run from surrounding bidi resolution; unicode-bidi: plaintext
changes how base direction is *determined* for each plaintext
paragraph in a block. Different surfaces. Reworded the lint guidance
to "prefer semantic isolation in HTML for inline runs; reach for
unicode-bidi: plaintext only when that block-level paragraph
behavior is explicitly required and tested" — and explicitly flagged
that they are not drop-in equivalents to avoid future linters
flagging valid CSS with a non-equivalent fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(craft): rtl-and-bidi mrcfps round-4 — split progress-bar from media scrubber
Non-blocking precision: prior must-mirror bullet lumped "progress-bar
fill" together with sliders, which would have flipped a video / audio
scrubber under dir="rtl" — directly conflicting with the must-not-mirror
rule for media playback controls (play/pause/FF/rewind represent tape
direction, not reading direction). The two cases collide on every audio
or video player.
- Must-mirror progress bars now scoped to "non-media" (download, upload,
form-completion).
- Media scrubber / progress timeline added explicitly to the must-not-
mirror media bullet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(daemon): add model name to pi initial status and RPC abort on cancel
- Emit status:initializing with model name before pi responds so the UI
shows 'pi · claude-sonnet-4-5' — matching Claude Code, Copilot, Gemini,
and Cursor Agent model-name parity
- Replace raw SIGTERM with RPC abort command on cancel, giving pi a
chance to clean up gracefully before SIGTERM fallback
- Wire run.acpSession onto the run object so cancel() can dispatch to
session.abort() for pi and ACP adapters
- Add stdinOpen guard so sendCommand is a no-op after stdin closes
- Add 4 tests covering initializing status, abort wire format, and
stdin-closed guard
* fix(daemon): gate stdout parser after abort to prevent post-cancel events
Once abort() sets finished=true, the stdout listener kept feeding
chunks into mapPiRpcEvent, so text_delta/tool/status events could
still be emitted during the PI_ABORT_GRACE_MS window. Add a finished
guard at the top of the parser callback so no agent events are
forwarded after abort, while still draining stdout cleanly.
Adds a test that aborts mid-session, then feeds message_update and
tool events, proving zero post-abort agent events are emitted.
* refactor(daemon): own SIGTERM fallback in cancel, rewrite abort tests as integration
- Move SIGTERM fallback from pi-rpc abort() to runs cancel() so the
termination guarantee is centralized — a misbehaving session can't
leave the child alive indefinitely (address lefarcen P3 on L130)
- Remove the setTimeout/SIGTERM from abort(); it now only sends the
RPC abort command, termination is the caller's responsibility
- Rewrite initial-status and abort tests as integration tests that
exercise attachPiRpcSession against mock child processes instead
of duplicating private sendCommand/send helpers inline (address
lefarcen P3 on L453 and L491)
- All 28 tests pass
GitHub's `/contribute` page only renders the `good first issue` label,
so 12 open `help wanted` issues never reach newcomers via that entry.
Switch the link to an issues search URL covering both labels (OR), so
both pools surface from one click. Wording is unchanged across all 10
README locales.
* feat(web): add Cmd/Ctrl+P quick file switcher
A keyboard-driven file palette overlaid on the workspace. Press Cmd/Ctrl+P
anywhere in the project view; type to fuzzy-filter the file list, ↑/↓ to
navigate, Enter to open in a tab, Esc to dismiss. With an empty query the
palette surfaces recents (per-project, localStorage) followed by the rest
of the file list sorted by mtime.
Adds:
- apps/web/src/components/QuickSwitcher.tsx: palette UI and matcher
- apps/web/src/quickSwitcherRecents.ts: per-project recents store
- index.css: scoped .qs-* styles using existing design tokens
- i18n: 6 new keys translated across all 16 locale files
Wires into FileWorkspace's existing openFile() so recents and tab state
behave identically to opening from DesignFilesPanel. Capture-phase keydown
beats the browser's print dialog. No backend changes; uses the files prop
already passed to FileWorkspace.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): address QuickSwitcher review feedback
Three fixes from the PR review:
- z-index: bump .qs-overlay from 200 to 1500 so the palette renders in
the modal tier (alongside prompt-template-modal-overlay) instead of
behind context menus and popovers (which sit at 200).
- Arrow-key guard: skip setCursor when matches is empty. Without this,
pressing ↓ on a no-results query set the cursor to -1, making the
highlight selector miss every row on the next render.
- Tests: add 19 unit tests covering scoreMatch ranking tiers, render
output (empty state / row count / kbd hints / placeholder), and the
full recents lifecycle (cap at 6, dedupe-on-push, corrupt-JSON
recovery, per-project scoping, quota-exceeded no-op). Vitest stays
on the node env via a small in-memory localStorage stub.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): QuickSwitcher review — wrap, IME, platform gate
Three follow-ups from @mrcfps's review on #556:
- ArrowUp/ArrowDown now wrap at list bounds (last → first, first → last)
via modulo arithmetic in a new pure helper `nextCursor(current, total,
direction)`. Previously they clamped, which contradicted the wrap
behavior the PR test plan promised. Pulled into a pure function so
boundary cases are unit-testable without simulating keyboard events.
- Palette's onKeyDown now early-returns on `e.nativeEvent.isComposing`,
so users typing CJK file names through an IME keep ↑/↓/Enter for
candidate navigation instead of having them steered by the palette.
The global Cmd/Ctrl+P opener already had the equivalent guard.
- Global keydown is now platform-gated: macOS responds only to metaKey,
win/linux only to ctrlKey. Previously both fired everywhere, which
meant Ctrl+P on macOS was stealing native readline "previous line" in
text fields (and the chat composer).
Tests: +6 unit tests for `nextCursor` covering forward/backward wrap,
mid-list moves, empty list (no division-by-zero), and single-item
no-op. Suite now 258 passing (up from 252).
Verified live: ↓ from last row → first row; ↑ from first row → last
row, in a mocked-project Playwright harness.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(craft): add accessibility-baseline + opt-ins on dashboard, hr-onboarding, mobile-onboarding
Module 3 of 5 in the behavioral craft series proposed in #501. Modules 1 (state-coverage, #502) and 2 (animation-discipline, #515) merged earlier today.
The differentiator that survived the corpus review is native-mobile
parity. Existing OSS prior art (fecarrico/A11Y.md, awesome-copilot,
Community-Access) covers web ARIA well, none covers Flutter Semantics,
Compose semantics, iOS UIKit/SwiftUI, or RN labelling APIs.
Secondary differentiator: jurisdictional legal-floor calibration. EAA
references WCAG 2.1 (via EN 301 549 v3.2.1), not 2.2. ADA Title II
2026-04-24 deadline slipped to 2027-04-26 via 2026-04-20 IFR. Most
existing OSS a11y prior art doesn't track either accurately.
Three-loop adversarial review pass before push (codex unavailable, ran
via substitute agent). Loop 1 caught nine cuts plus four factual fixes
including a wrong Android Compose API name. Loop 2 verified and flagged
two more trims. Loop 3 said ship.
Anchored citations: WCAG 2.2 Understanding pages, ISO/IEC 40500:2025,
ADA Title II 2024 + 2026-04-20 IFR, EN 301 549 v3.2.1, WAI-ARIA 1.3 +
AccName 1.2 + Core AAM 1.2, WebAIM Million 2025, A11yn (arXiv 2510.13914),
APCA W3C silver branch.
Refs #501.
* fix(craft): accessibility-baseline review fixes (lefarcen + mrcfps)
Address all P1/P2/P3 findings:
- P1 (lefarcen): add "Keyboard operability and semantic structure" section covering tab reachability (2.1.1), activation keys, no keyboard trap (2.1.2), focus order (2.4.3), native-control-first, document language (3.1.1), heading hierarchy (1.3.1, 2.4.6), landmarks (1.3.1, 2.4.1), text alternatives (1.1.1)
- P2 (lefarcen): expand jurisdiction scope with US Section 508 (WCAG 2.0 AA), ADA Title III caveat, EU WAD reference
- P2 (lefarcen + mrcfps): rename contrast-table row to "Normal text below 18 pt regular / 14 pt bold" so the table matches the threshold rule
- P2 (mrcfps): correct "exclusive" → "inclusive" — exact 4.5:1 / 3:1 passes; the no-rounding rule is what makes 2.999:1 fail
- P2 (lefarcen): add "Prior art and scope" note differentiating from existing OSS a11y agent docs
- P3 (lefarcen): narrow APCA framing to "not part of WCAG/EN/ADA/Section 508" and clarify size/weight-dependent thresholds
- P3 (lefarcen): expand WCAG 2.5.8 exceptions list (Spacing, Equivalent, Inline, User Agent Control, Essential)
- Common-mistakes additions: Section 508/2.1 confusion, tabindex>0 anti-pattern, modal-focus-trap distinction from 2.1.2, heading-size vs level confusion
* fix(craft): accessibility-baseline mrcfps round-2 precision fixes
All three non-blocking precision items addressed:
- Update WebAIM Million benchmark from 2025 to 2026 (February 2026 crawl). Form labels: page-level 51% (was 48.2%), input-level 33.1% (was 34.2%) of 6.9M inputs (was 6.3M). ARIA: 59.1 errors on ARIA pages vs 42 on non-ARIA (was 57 vs 27); gap is ~17 in 2026, was 30 in 2025. ARIA usage 82.7% of pages (was 79.4%). Verified directly against webaim.org/projects/million/.
- Soften keyboard/semantic-structure intro: Level A items are still labeled Level A, but 2.4.6 Headings and Labels is correctly tagged AA, and the one-h1 / no-skipped-levels rules are now framed as OD craft conventions on top of WCAG's programmatic-structure floor (1.3.1).
- Tighten <a> activation note: bare <a> without href is not focusable, not a link, and not keyboard-operable. Use <a href="…"> for navigation or <button> for actions. Added a "common mistakes" entry to lock the rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(readme): document OD_DATA_DIR + migration from .od/ to Desktop app
The "Move it elsewhere" row in the First-run state table still said the
path is hard-coded; OD_DATA_DIR (resolveDataDir in apps/daemon/src/server.ts)
has supported relocation since the runtime-data refactor. Replace the
"not supported yet" note with the actual env-var usage and resolution
semantics, and add OD_MEDIA_CONFIG_DIR for the narrower credentials
override.
Add a Migrating a pre-desktop-app `.od/` section so users who started in
the repo and later installed the packaged Desktop app know:
- the two writers target different roots (repo .od/ vs.
~/Library/Application Support/.../namespaces/<channel>/data on macOS,
with the platform-equivalent paths on Windows and Linux),
- how to copy projects/SQLite/artifacts/media-config.json over after
quitting the app cleanly,
- how to keep both writers on the same dir going forward via
OD_DATA_DIR.
Documentation only; no code changes.
* docs(readme): address review feedback on .od/ migration section
Resolves the review on #570:
- chatgpt-codex P1 / mrcfps: replace the literal `<repo>` token in the
copy command (Bash parses `<repo>` as input redirection, so the
documented snippet would fail before any copy). Use a shell-safe
`REPO=` variable in the example.
- chatgpt-codex P2 / mrcfps / lefarcen P2: correct the cross-platform
Desktop data-root. The packaged runtime resolves
`app.getPath("userData")/namespaces/<namespace>/data` (see
apps/packaged/src/config.ts:106-107), and Electron's `userData`
default on Linux is `$XDG_CONFIG_HOME` / `~/.config`, not
`$XDG_DATA_HOME`. Replace the single macOS-only path with a per-OS
table, plus a hint to inspect the packaged daemon log for the
resolved `daemonDataRoot`.
- lefarcen P2: list platform-specific channel namespaces. The release
workflows append `-win` and `-linux` suffixes (release-stable-win,
release-beta-win, release-stable-linux, release-beta-linux); only
macOS uses the bare `release-stable`/`release-beta` strings.
- lefarcen P1 (data corruption): demote the "share one data dir between
repo dev-server and Desktop app" recommendation to an Advanced
callout with an explicit warning that the two writers must never run
at the same time. The daemon opens app.sqlite in WAL mode and writes
uncoordinated project/artifact files, so concurrent use can corrupt
SQLite or clobber artifacts.
- lefarcen P2 (downgrade risk): add a forward-only schema migration
warning. apps/daemon/src/db.ts applies `CREATE TABLE IF NOT EXISTS` /
`ALTER TABLE` without a version guard, so opening a migrated dir with
an older repo checkout can leave the workspace inconsistent. Advise
backing up app.sqlite* before the first launch.
- lefarcen P2 (failure-safety): replace the in-place `cp -R` with a
rsync-into-sibling-then-rename pattern so a partial copy cannot leave
the Desktop data dir in a half-populated state. Document the restore
path from the .fresh-baseline-* backup.
- lefarcen P2 (replace vs merge): add a preflight `ls` of the Desktop's
existing projects and a callout that this is a replace operation, so
users with projects on both sides can stop and choose which is
authoritative.
Documentation only.
* docs(readme): address second review round on .od/ migration section
Resolves the follow-up review on #570 from a72b35f:
- mrcfps (blocking): require stopping the repo dev-server too, not just
the Desktop app, before copying. Without that the source `$REPO/.od/`
may still receive SQLite/WAL writes mid-rsync, so the staged copy can
be inconsistent even though the Desktop target is clean. The clean-
state callout and the bash block both now name `pnpm tools-dev stop`
alongside the Desktop quit step.
- lefarcen P2 (fail-fast gap): the rsync block was not actually fail-
fast — a non-zero rsync exit would still let the subsequent `mv`
promote a partial staged copy. Added `set -euo pipefail` at the top
of the bash block plus an explicit `|| { echo …; exit 1; }` guard on
the rsync line so a failed copy aborts before any swap.
- lefarcen P3 (wording): "Electron's userData path" overlapped with the
per-OS table values, since `app.getPath("userData")` already appends
the `Open Design` segment. Renamed the table column to "<appData>
(Electron `appData` base)" and reworded the surrounding sentence so
the path components compose unambiguously: `<appData>/Open Design/
namespaces/<channel>/data/`.
Documentation only.
---------
Co-authored-by: StotheC90 <StotheC90@users.noreply.github.com>