Bundled plugins whose manifest declares `preview.entry` but ship no
example HTML (e.g. example-live-artifact pointing at ./index.html that
isn't in the package) made the daemon return 404 on
/api/plugins/:id/preview. The web turned that into the generic
"Couldn't load this example. The example HTML failed to fetch." error
modal, which suggested a transient failure even though Open Design was
running fine and the asset was simply absent.
Mirror the symmetric treatment fetchSkillExample already has from
#897: map 404 -> { unavailable: true, kind: 'html' } in both
fetchPluginPreviewHtml and fetchPluginExampleHtml, and forward the
typed unavailable view through PluginExampleDetail so PreviewModal
renders its calm "no shipped preview" placeholder. Other HTTP failures
keep their existing { error: 'HTTP N' } shape so genuine errors still
surface a Retry affordance.
Red test was added first (registry.test.ts) and confirmed to fail on
main with the old "HTTP 404" payload before this commit was applied.
A design system imported from a SwiftUI repo could never be published. The
import's file scorer was web-only, so every .swift file scored 0 while the
repo's config dotfiles (.zenflow, .vscode, .zed) scored on the generic text
bonus and won the top-N selection. Even when a source file was selected, the
snapshot writer's text-file allowlist didn't include .swift, so it was dropped.
The result: snapshots were all config dotfiles, which the project files API
hides, so the publish gate saw zero evidence snapshots and stayed blocked no
matter how many times the intake ran.
Three layers fixed in tools-connectors-cli.ts:
- scoreDesignFile now scores native/design-token source (Swift, Kotlin, Go,
Rust, etc.), with a high boost for token files like ColorSystem.swift,
Typography.swift, Spacing.swift.
- shouldSkipRepoPath skips editor/CI/agent tooling dirs (.vscode, .zed, .idea,
.zenflow, .github, .husky, ...) so their files stop crowding out real source.
- isTextSnapshotPath recognizes native source extensions so selected files are
actually written.
Also teach the design-system swatch extraction to read SwiftUI colors:
swift-colors.ts parses Color(red:green:blue:), Color(hue:saturation:brightness:)
(HSB), and Color(white:), evaluating decimal, hex-byte, and division component
expressions (0xF4 / 255, 220 / 360), and design-systems.ts uses it as a new
swatch form. scoreDesignFile, shouldSkipRepoPath, and isTextSnapshotPath are
exported for unit tests.
Verified against a real SwiftUI repo: the intake now captures ColorSystem,
TypographySystem, SpacingSystem and the view/model source instead of config
dotfiles.
* Polish the design system review panel
- Float the "Needs work" feedback form below the review buttons as a popover
instead of cramming it into the section header row.
- Collapse a section once it is marked "Looks good" so reviewed sections tidy
away. Clicking Looks good always collapses; the chevron still re-expands.
- Make the inline preview iframe scrollable and drop the click-to-open wrapper.
- Make the whole section header the expand/collapse trigger via a stretched
button, with the title rendered as display-only text (no nested buttons).
- Rework the publish header: name the system in the h1 ("Review <name> design
system"), move publishing into a Publish button inline with the h1, and make
the disabled-state tooltip say what is actually needed.
- Animate the generation mark's small block (hop, spin, bounce) while waiting,
with a reduced-motion guard.
- Tighten the title/subtitle gap to 2px.
* fix(web): reopen a regenerated design-system section instead of leaving it collapsed
renderReviewCard collapsed any section whose stored review decision was "looks-good", reading only the decision and not the section's current status. When a section is regenerated after approval its status moves back to "updated", but the stale "looks-good" decision kept it collapsed, so the "review it again before publishing" notice and the review buttons stayed hidden. A regenerated change was easy to miss in the re-review queue.
reviewedGood now also requires !needsAttention, so a section that moved back to a needs-attention status reopens by default. Manual collapse with the chevron and the force-open during an active run are unchanged.
Adds a regression test that marks a section looks-good, regenerates its preview file, and asserts the section stays expanded. It is red on the old decision-only check and green with the status guard.
* fix(web): keep the disabled-publish guidance reachable instead of on the disabled button
The publish button carried its disabled-state guidance in a title attribute, but the button is disabled in exactly the case that sets the title (!published && !githubEvidence.ready). A disabled control does not fire hover or focus, so the tooltip never appeared, and the explanation for why publishing was blocked went missing right when it was needed.
The guidance now lives on a wrapper span that is never disabled. The disabled button is set to pointer-events: none so a hover falls through to the wrapper and surfaces its title, and the wrapper carries cursor: not-allowed so the blocked cursor stays.
Adds a regression test that asserts the guidance sits on a non-disabled wrapper holding the button, not on the button itself. It is red when the title is on the disabled button and green with the wrapper.
A published design-system project still showed its last generation run status on its card. When that run had failed, the published system read as "Failed" on the home Recent projects strip and in the Projects grid. The system is published and live, so showing a stale run failure there is wrong.
Cards now read "Published" when the backing design system's status is published, keyed off the design system summary instead of the project run status. Every other project keeps its run status.
A shared isPublishedDesignSystemProject helper lets the home strip and the Designs grid apply one rule. The label uses a new designs.status.published key in the locale files, with a green style that matches the existing succeeded color.
* feat: rename editable design systems from Settings + od CLI
Editable (user-created) design systems can already be renamed via
PATCH /api/design-systems/:id, but the capability was not surfaced
in the UI or CLI.
- Settings -> Design Systems: editable cards show a hover-reveal pencil
next to the name that opens a rename modal; built-in cards stay
read-only. Reuses common.rename/save/cancel (no new i18n keys).
- CLI: 'od design-systems rename <id> --title <new> [--json]', backed by
a unit-tested pure arg parser (design-system-rename-args.ts).
Both surfaces call the existing PATCH endpoint.
* Route od design-systems --help and -h to the rename-aware usage
The dispatcher only special-cased the `help` subcommand, so
`od design-systems --help` and `-h` fell through to the generic library
list, which advertises only `list` and `show`. That left `rename` off the
main discovery path even though this PR ships it.
Pulled the usage text and the help-arg check into a small pure module so
`help`, `--help`, and `-h` all render the same rename-aware usage, and added
a test that asserts the flag forms route to help and that the text lists
rename. The pure module keeps the assertion off process.exit / console.log.
* Reject --title flag-as-value and keep the rename modal open on failure
Two rename edge cases from review.
CLI: parseDesignSystemRenameArgs took the next token after --title
unconditionally, so `rename user:acme --title --json` parsed the title as
"--json" and could rename the system to a flag name instead of failing usage
validation. A separate --title value must now be a real token; a leading dash
means the user uses the --title=<value> form. Malformed inputs return null,
which the CLI surfaces as a usage error.
Web: commitRename closed the modal unconditionally, but updateDesignSystemDraft
returns null on any non-OK response or fetch failure, so a transient error
dropped the typed title with no feedback. The modal now stays open with the
title intact and shows an inline error on failure, matching the existing import
error pattern in this component. Added tests for the flag-as-value rejection
and for the failed-update modal state.
* Gate the rename completion on the active modal session
commitRename mutated the shared modal state after awaiting the PATCH, so a
slow rename for system A could resolve after the user cancelled and opened a
rename for system B, then close B's modal or show A's failure inside B's
dialog.
A monotonic session token (bumped whenever the modal opens or closes) is now
captured before the request and rechecked after it resolves. A stale
completion skips all modal-state updates. The list update for a successful
rename still applies, since that reflects a real server-side change regardless
of which modal is open. Added a regression test that opens a second rename
before the first PATCH settles and confirms the newer modal is untouched.
* Localize the rename-failed error instead of hardcoding English
The inline rename error was hardcoded English on a Settings surface that
otherwise runs through useT(), so non-English users saw English while the
rest of the panel was localized.
Added settings.designSystemRenameFailed to the typed dictionary and all 19
locale files, and the modal now reads it through t(). The translations are
adapted from each locale's existing settings.rescanFailed string ("X failed.
Check the daemon and try again."), swapping the verb to rename, so the daemon
and retry wording matches what those locales already ship.
* Fix preview iframe focus stealing
* Fix preview focus guard for URL-loaded HTML previews
Focus guard was only injected via the srcdoc path, but the default
URL-load path bypasses buildSrcdoc entirely. Add htmlNeedsFocusGuard
detection so focus-stealing HTML is routed through srcdoc where the
guard can suppress window.focus/element.focus calls.
* Widen focus guard detector to cover all .focus() call patterns
The previous regex only matched window.focus() and document.focus(),
missing document.body.focus(), querySelector().focus(), and other
chained focus calls. Broaden to match any `.focus(` so the default
URL-loaded preview path is forced to srcDoc for all focus-stealing HTML.
* Conservatively force srcDoc for HTML with external script references
When the HTML contains <script src=...>, we cannot inspect the linked
file for focus-stealing calls. Force the srcDoc path so the focus guard
intercepts any .focus() calls from external scripts.
---------
Co-authored-by: JoeyZhu <15500388+acthenknow@user.noreply.gitee.com>
The composed chat prompt prepends a '# Instructions (read first)'
block in front of '# User request' so a single user message carries
both the system rules and the actual request — the shape every agent
CLI (Claude, Codex, OpenCode, Gemini) expects on stdin.
In practice claude-opus-4-7 (and a few other instruction-tuned
models, particularly with --include-partial-messages on the stream)
start their reply by echoing the top of that user message verbatim.
The chat UI then shows the system prompt as a literal block leading
the visible answer, e.g.:
Instructions
Always respond in Korean. Use Korean for all explanations…
…Maintain full orthographic correctness…
).네, 완료했습니다. 전달하신 4가지 보강 포인트를 …
(The closing token of the instructions block runs straight into the
real answer without a newline — the telltale of a model-side echo
rather than a UI render bug.)
Close every Instructions block with one trailing line:
(Do not quote, restate, or echo the # Instructions block above in
your reply. Begin your response with the answer to the # User
request below.)
This kills the regression in practice without changing the turn
shape (still one user message), so no agent CLI plumbing has to move.
Tested via tests/chat-route.test.ts — pins the literal guard string
so a future refactor cannot silently drop it.
Co-authored-by: nicejames <nicejames@gmail.com>
* fix(web): treat external <script src> as needing the sandbox shim (#2361)
Agent-emitted HTML artifacts that read localStorage from an external
boot.js / app.js currently render blank in the preview pane because the
URL-load iframe's sandbox lacks allow-same-origin and htmlNeedsSandboxShim
only scans the HTML string. The "Known limitation" comment already
anticipated this case; #2361 is the reported case justifying the cost.
Conservatively route any HTML with an external <script src=> through the
srcDoc path so injectSandboxShim is in place before the script runs.
* fix(web): stop infinite srcDoc re-activate loop that blanks animated previews (#2361)
The lazy srcDoc transport iframe fires its 'load' event twice for one successful activation: once when the empty transport shell HTML loads, and again when our own document.open()/write()/close() inside the shell finishes. PR #2699 made the onLoad handler unconditionally reset activatedSrcDocTransportHtmlRef.current = null so that switching preview -> source -> preview (which remounts the iframe as a brand new DOM node) would re-activate the new shell. But that reset also fires on the second load of an unchanged frame, which re-triggers activateSrcDocTransport, which re-runs document.open/write/close, which re-fires the load event, ad infinitum. In one local reproduction the dedupe ref was cleared and re-activated 4763 times before the test was stopped.
Each iteration rebuilds the document, which restarts every CSS animation from its 'from' keyframe. Designs that use 'animation-fill-mode: both' with 'from { opacity: 0 }' (very common for editorial hero fades) therefore stay at opacity 0 forever and the preview reads as blank. In React strict mode + HMR (pnpm tools-dev) the symptom is visible high-frequency flashing; in a packaged production build the loop runs cool enough that the user only sees a stable blank — both are the same root cause.
This change keeps PR #2699's remount-after-Source-toggle behavior by tracking which iframe DOM node we last reset for in a new srcDocFrameDedupeResetForRef. The reset runs exactly once per freshly mounted iframe (the first load is the shell HTML) and is skipped on every subsequent load of the same node (those are the document.write loads). Switching source back to preview remounts the iframe as a fresh DOM node, so the reset still happens and PR #2699's regression test still passes; ordinary srcDoc renders no longer enter the infinite loop.
Refs #2361
* chore: re-trigger CI
Upstream's fork-pr-workflow-approval check hit a transient 401 Bad credentials when calling the GitHub API on the previous run; the underlying workflow has nothing to do with the code in this PR. Pushing an empty commit to re-run the workflow chain.
* chore: re-trigger CI (retry transient checkout race)
First re-trigger surfaced a transient race in ci / Build workspaces (actions/checkout failed to fetch refs/remotes/pull/2805/merge with 'could not read Username for https://github.com'). Other concurrent fork PRs' Build workspaces all passed on the same upstream runner, so this is not a token/permission infra issue — likely just a per-PR fetch race after the previous push. Pushing a second empty commit to retry the workflow chain.
* feat: pin custom design systems to top and read swatches from color tables
Two changes to Settings -> Design Systems.
Custom (user-created) systems now sort to the top of the list instead of
sitting under the built-in catalog. A small pure helper
(orderDesignSystemGroups) floats any group that holds an editable system
above the rest; everything else keeps its order.
Swatches now show for systems whose DESIGN.md keeps colors in a markdown
table. extractSwatches only understood inline forms before, so table
palettes came back empty and the cards showed no color squares. Added a
table-row pass that reads the first hex in a row as the value and the
first plain text cell as the name. Inline forms still win when a file
mixes both.
* Sort editable systems first within a category group
The group-level sort floated any category holding a user system to the top,
but items inside a group rendered in their incoming (alphabetized) order. A
user system that shares a category with built-ins (its DESIGN.md can set any
category) still landed below Apple/Airbnb in that group, which misses the
point of pinning custom systems to the top.
orderDesignSystemGroups now also sorts items editable-first within each
group, stable so built-ins keep their alphabetical order. The display order
comes from the helper output, so this covers the import path re-alphabetizing
before grouping without touching it.
* feat: add "Use system fonts" dismiss to the missing-fonts banner
The 'Missing brand fonts' banner only offered Upload fonts. Now there's
a 'Use system fonts' button next to it that hides the banner for that
project (saved in localStorage) when you're fine with the fallback. It
does not change how fonts render; it just stops nagging.
Pulled the banner into a shared MissingBrandFontsBanner component and
gave the warning card the bottom margin it was missing.
* Resync the missing-fonts banner dismissal when the project changes
FileWorkspace renders MissingBrandFontsBanner without a per-project key, so
the same instance is reused as the user moves between projects. The dismissal
state was read with useState only on first mount, so dismissing project A left
the banner hidden for project B in the same panel even though only A was
written to localStorage. That broke the per-project scoping this banner adds.
Added a useEffect that re-reads the dismissal whenever projectId changes, and a
rerender test that dismisses p1, switches to p2 (banner returns), and switches
back to p1 (stays dismissed).
* fix(packaged): honor OD_DATA_DIR in desktop runtime
Co-authored-by: multica-agent <github@multica.ai>
* fix(packaged): scope OD_DATA_DIR by namespace
Co-authored-by: multica-agent <github@multica.ai>
* fix(packaged): reject relative OD_DATA_DIR overrides
Co-authored-by: multica-agent <github@multica.ai>
* fix(packaged): preserve scoped OD_DATA_DIR overrides
Co-authored-by: multica-agent <github@multica.ai>
* fix(packaged): surface OD_DATA_DIR validation as PackagedPathAccessError
Relative OD_DATA_DIR in packaged mode now throws PackagedPathAccessError
instead of a plain Error. apps/packaged/src/index.ts main() only routes
PackagedPathAccessError to dialog.showErrorBox, so the prior plain Error
made the app exit silently for GUI launches with an invalid override.
Extract PackagedPathAccessError into apps/packaged/src/errors.ts so
paths.ts can throw it without an inter-module value cycle with launch.ts.
Co-authored-by: multica-agent <github@multica.ai>
* fix(packaged): make OD_DATA_DIR absolute-path guard platform-aware
The previous guard ran `win32.isAbsolute(expanded)` unconditionally on
every platform, so on macOS/Linux a value like `C:\Users\Fred\OD` passed
the check (win32 considers it absolute) and silently flowed into
`join(expanded, "namespaces", namespace, "data")`, producing a
cwd-relative POSIX path instead of throwing.
Branch the check on `process.platform === "win32"` so Windows paths are
only accepted on Windows. Update the existing Windows-themed test
fixtures to stub `process.platform = "win32"` (the omission was what
masked this bug) and add a regression that stubs `linux` and asserts
`C:\foo` and `\\server\share` are rejected as PackagedPathAccessError.
Co-authored-by: multica-agent <github@multica.ai>
* fix(packaged): reject mismatched scoped OD_DATA_DIR
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: kami.c <kami.c@chative.com>
* Surface GitHub repo connect/import CTA in design system projects
Design systems built from a GitHub repo used to show a dead-end banner
("Waiting for GitHub connector evidence") when the repo files were never
pulled in. It said nothing actionable and blocked publishing with no way
forward.
That state now reads as a CTA, in both the Design System review banner and
the project's Start-a-conversation empty state. When GitHub is not
connected, both say "Connect your repo to pull aspects of your design
system" with a Connect GitHub button that opens Connectors. Once GitHub is
connected the copy switches to "GitHub is connected" with an Import repo
button that drops the bounded github-design-context intake instruction into
the chat composer, so you review it and send. The agent runs the pull, the
snapshots land, and the prompt clears itself.
The copy lives in one helper so the banner and the chat card never drift,
and ProjectView reads connector status (refreshing on window focus) only
while the CTA is showing.
* Build the import prompt from linked repo URLs, not the manifest
The Import repo prompt hard-coded "read context/source-context.md first,"
but the CTA shows for any incomplete GitHub-backed system, and that manifest
is not always written yet. So clicking Import repo could start the recovery
on a file that does not exist.
buildRepoImportPrompt now builds the instruction from the design system's
provenance.githubUrls, which is always present when the CTA shows (the CTA
gates on a non-empty repo list). It points at context/source-context.md only
when that file is actually present, and otherwise tells the agent to run the
bounded github-design-context intake for the linked repos directly.
* Hold the connect-repo CTA neutral until the status resolves
githubConnected seeded as false, so on first paint every GitHub-backed
project rendered the CTA as Connect GitHub and routed clicks to Connectors
until /api/connectors/status came back. An already-connected user who
clicked fast got bounced to Connectors instead of the Import repo handoff.
githubConnected is now tri-state (undefined while loading). repoConnectCopy
returns a neutral "Checking GitHub..." label for that window, the CTA button
is disabled until the status resolves, and handleConnectRepo guards the
undefined case. Once the fetch lands the button flips to Connect or Import.
Tests cover the pending label, the disabled button, and the no-op click.
In RTL the settings cog moves to the left side of the header, but
.avatar-popover still used right: 0 from the LTR rule, pushing the
280px popup off-screen to the left. Mirror the existing
entry-help-popover RTL pattern with right: auto; left: 0 so the
popover stays in view.
Fixes#2719
The dev server runs on Next.js webpack by default. On a sizeable
monorepo like this one (19 locale files, many components, the i18n
content surface) webpack dev mode pushes the Node heap past the
default 4 GB ceiling and the process dies with 'Ineffective
mark-compacts near heap limit' after a few hot reloads, leaving the
desktop window pointing at a dead URL.
Switch the dev script to '--turbopack'. Next.js 16 ships Turbopack
as stable for dev, and apps/web/next.config.ts already declares
turbopack.root so the workspace resolution is consistent with the
webpack path. The build script is unchanged on purpose — this PR is
scoped to the dev server, where the OOM repro is.
In practice Turbopack runs the same dev workload with materially
lower steady-state heap usage (Rust-side bundling instead of
JavaScript-side webpack) and recovers faster on HMR, so a long dev
session no longer drifts toward the OOM ceiling.
Co-authored-by: nicejames <nicejames@gmail.com>
The detail-page interactive preview iframe pointed at
`/skills/<slug>/example.html/` and `/templates/<slug>/preview.html/`
with trailing slashes. Cloudflare Pages 308-redirects those URLs to
the extension-stripped form, but with the trailing slash present it
fails to map back to the published `out/skills/<slug>/example.html`
file and SPA-falls-back to the homepage. Result: every preview iframe
in production rendered the homepage instead of the skill or
live-artifact preview.
Verified against the deployed site after the #2679 release:
- /skills/deck-guizang-editorial/example.html → 4942 bytes (real preview)
- /skills/deck-guizang-editorial/example.html/ → 163377 bytes (homepage SPA fallback)
- /skills/deck-guizang-editorial/example → 4942 bytes (real preview)
- /skills/deck-guizang-editorial/example/ → 4942 bytes (real preview)
Drop the trailing slash from all six iframe `src` and "Open in new
tab" `href` attributes in `pages/skills/[slug]/index.astro` and
`pages/templates/[slug]/index.astro`, plus the inline comment that
documented the URL shape.
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
* fix(web): ensure sketch save button shows saving state
The save handler completes so quickly that the saving UI state flashes
for less than a frame, making it impossible for users to perceive that
a save is actually in progress. Add a 500ms minimum delay on the save
path so the button's saving state is visible, giving users clear
feedback that their action was registered.
Fixes#109
* fix(web): show saved confirmation after sketch save completes
After a sketch save succeeds, show a checkmark icon on the save button
for 2 seconds so users get clear confirmation that the save completed,
addressing the missing success feedback reported in the issue.
Fixes#109
* test(web): add save feedback tests for SketchEditor
Cover the Save button's default, saving, disabled, and post-save
checkmark states, including the 2-second saved confirmation timeout.
Refs #109
* fix(web): gate saved checkmark on explicit save success
saveSketch now returns false on write failure (daemon down, 4xx/5xx,
disk error, dropped connection) instead of resolving normally.
handleSave checks the return value and skips the green checkmark when
the save did not persist.
Refs #109
* fix(web): make sketch save delay a minimum floor not additive
saveSketch previously added a hardcoded 500ms after writeProjectTextFile completed, making the total perceived save time writeTime + 500ms regardless of how long the real write took.
Now it measures elapsed write time and only sleeps for the remainder of 500ms — fast saves still get the minimum visible duration, but a save that takes 600ms skips the sleep entirely.
Refs #109
* fix(web): remove showSaved from sketch save button disable guard
saveSketch's handleSave already skips the save when onSave returns false, so showSaved was doubling as an accidental debounce that blocked the user from saving again for 2 seconds after each save — even when they had continued drawing during the save animation.
The saving prop from the parent and the dirty/canSave check remain in place, so the button is still disabled while the async write is in flight and when there is nothing new to persist.
Refs #109
* fix(web): clear savedTimerRef on unmount
savedTimerRef schedules a setTimeout that calls setShowSaved(false)
after the save confirmation period. When the component unmounts before
the timer fires (e.g., closing the sketch tab), the pending callback
triggers a state update on an unmounted component. Add a useEffect
cleanup to clear the timer, consistent with the existing ResizeObserver
cleanup in this component.
Refs #109
* fix(web): strip trailing whitespace on save button className line
The className="primary" attribute on the save button accumulated
trailing whitespace in the recent save feedback changes. Remove it.
Refs #109
* test(web): add sketch save minimum visibility test for FileWorkspace
Add a test that verifies the sketch save button keeps the "Saving…"
state visible for at least 500ms before transitioning to the saved
checkmark. Mocks fetchProjectFileText, writeProjectTextFile, and a
stub ResizeObserver to make the SketchEditor render cleanly.
Refs #109
* test(web): update SketchEditor save disable assertion after removing showSaved guard
a6aa82a removed showSaved from the save button disable guard, so the button no longer stays disabled for 2 seconds after each save completes. This test expected the save button to remain disabled after save resolution, which no longer matches the component behavior — the saving prop (false) and dirty/canSave check (true) leave the button enabled immediately after save.
Refs #109
* fix(web): clear saved indicator when sketch save fails
When a save succeeds (shows checkmark), then the user saves again
and the second save fails, the stale checkmark remained visible
until the original 2-second timeout expired. The handler now clears
the timer and hides the indicator immediately on failure.
Refs #109
* fix(web): clear saved checkmark when sketch goes dirty again
After a successful save, the checkmark remained visible for the full
2-second window even if the user resumed drawing during that time.
dirty would become true again while the button still showed "Saved",
contradicting the actual unsaved state.
A new useEffect watches the dirty prop and immediately clears the
timer and hides the indicator when the sketch becomes dirty again.
Refs #109
* refactor(web): fix useEffect dependency array indentation
The closing bracket of the useEffect dependency array was indented
one space too far, breaking the code style consistency in
SketchEditor.tsx.
Refs #109
* refactor(web): remove trailing blank line in FileWorkspace
A blank line with trailing whitespace was left between the useEffect
closing brace and the following if-statement, violating the file's
whitespace conventions.
Refs #109
* fix(web): give sketch save button a stable accessible name
Refs #109
* test(web): add a11y tests for sketch save button aria-label
Refs #109
When the chat panel is widened, the workspace panel shrinks and
the preview toolbar action buttons can overflow. This changes the
fixed toolbar height to a min-height and enables flex-wrap on the
toolbar actions container so controls wrap instead of clipping.
Fixes#2166
Co-authored-by: algojogacor <algojogacor@users.noreply.github.com>
Two CI failures from PR #2461 root-caused to wrong picks in the
merge:
* apps/web/src/components/plugins-home/PluginCard.tsx — reverted to
release-side. The release-side version uses `localizePluginTitle
(locale, record)` / `localizePluginDescription(locale, record)`
to read each plugin's `titleI18n` / `descriptionI18n` fields,
driving the 'localizes plugin card titles' test in
plugins-home-section.test.tsx (which asserts '瑞士国际主义 Deck'
appears under zh-CN). The main-side version replaced that
record-level i18n with hardcoded English + `t()` aria-label
keys — a finer-grained i18n migration but a fundamental loss of
the record-level localization the test exercises. Taking
release-side keeps the test functionality; the aria-label i18n
keys are micro-optimisation we can re-port in a follow-up.
* e2e/ui/settings-local-cli-codex-fallback.test.ts — added the
`SETTINGS_MENU_LABEL` constant declaration that the menu-
dismissal helper (kept from main in c14baf07) references at
line 161. main's diff added the const at the top of the file but
it didn't carry through auto-merge alongside the helper block;
this restores it.
Both fixes verified locally:
- PluginCard now grep-finds locale + localizePluginTitle usage.
- fallback test grep-finds SETTINGS_MENU_LABEL declaration.
PR #2461 sync prep — resolves 14 conflicts merging 84 main-side commits
on top of 58 release-side commits accumulated during the 0.8.0 cycle.
Resolution summary:
Take main (theirs) where main carried deliberate forward progress:
- apps/web/src/components/PluginCard.tsx — 7 hunks, i18n migration:
hardcoded English aria-labels/titles replaced with t() calls keyed
on pluginCard.* (all 8 keys verified present in en.ts).
- apps/web/src/components/TasksView.tsx — 1 hunk, source-ingestion
feature: sortedRoutines (newest-first), sourceIngestionTemplates,
patchSourceForm, submitSourceIngestion. activeCount/pausedCount
semantics preserved (now keyed on sortedRoutines, count unchanged).
- e2e/ui/app.test.ts — new node:fs/promises + tmpdir + path + @/timeouts
imports needed by main-side test helpers.
- e2e/ui/settings-local-cli-codex-fallback.test.ts — menu-dismissal
helper block added by main.
Keep both sides where each added a different field to the same object
literal:
- apps/web/src/components/ProjectView.tsx (locale + analyticsHints
spread).
- apps/web/src/components/DesignSystemFlow.tsx (locale + analyticsHints).
Take release (ours) where release carried deliberate work that ships
0.8.0:
- CHANGELOG.md — release-side 0.8.0 entry + PR link refs; main's
Unreleased section was the same body of work, now finalized.
- apps/landing-page/public/{apple-touch-icon,favicon}.png +
apps/web/public/app-icon.svg — release-side visual refresh assets
consistent with 0.8.0 stable ship.
- tools/pack/src/linux.ts — packageVersion const required by line 466;
taking main's empty line would build-error.
- e2e/ui/project-management-flows.test.ts +
e2e/ui/settings-api-protocol.test.ts +
e2e/ui/settings-memory-routines.test.ts — release-side release-smoke
hardening (shangxinyu1 + PerishFire) takes precedence on overlap.
Closes-issue / unblocks: PR #2461 sync release/v0.8.0 → main.
* feat(web): point .jsx module previews at their HTML entry
Multi-file React prototypes load .jsx modules from an HTML entry via
<script type="text/babel" src>. A module previewed on its own has no
standalone component, so it dead-ended on the React runtime error
"No React component export found".
Modules are now detected (a .jsx/.tsx referenced by a sibling HTML
entry's babel script src) and handled:
- The Preview shows a pointer to the HTML entry(ies) that render the
module; clicking one opens that page and closes the module tab.
- The Code tab still renders the raw source.
- Such modules no longer auto-open as preview tabs after a write.
* fix(web): bound module-detection cache, ignore commented-out scripts
Review follow-up on the .jsx module preview pointer:
- ProjectView: key the HTML content cache by file name with mtime stored
alongside, so a rewrite replaces the file's single entry instead of
leaking a new name@mtime key per revision.
- extractBabelScriptSrcs: strip HTML comments before scanning so a
commented-out babel script is not collected as a live reference.
- i18n: normalize the three new jsxModule values to single quotes across
all 19 locales to match each file's existing style.
* fix(prompt): instruct discovery form to follow user's chat language
The discovery form was reaching users in English even when their UI
language was Chinese (#1416). The form is generated by the LLM under
guidance from packages/contracts/src/prompts/discovery.ts, but the
prompt only mentioned that option labels MAY follow the user's
language. The example form embedded English text for title,
description, per-question labels, and placeholders, and the LLM
copied that text verbatim instead of localizing.
Two minimal changes to the prompt:
1. Add a sentence under RULE 1 making the language-match expectation
explicit before the example forms.
2. Expand the Form authoring rules bullet so it covers every
user-facing string (title, description, label, placeholder, option
label) and pins the unlocalized identifiers (id, type, option
value, branch values) for the runtime branch logic.
Fixes#1416
* fix(prompts): mirror discovery localization rule to daemon prompt copy
Apply the same 'Match the user's chat language' paragraph and the
expanded 'Localize every user-facing string' bullet to
apps/daemon/src/prompts/discovery.ts, which the daemon-backed chat
path uses (it imports ./discovery.js, not the contracts copy).
Also add apps/daemon/tests/prompts/discovery-localization-drift.test.ts,
which reads both prompt copies and asserts each one contains both rules,
so the contracts and daemon files cannot silently drift on this behavior.
Apply-anyway reason: pnpm install / pnpm vitest could not run locally
(registry DNS blocked in sandbox + node v26 vs required v24). Direct
Node content assertion over both files passes. CI will run vitest.
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(web): route chat file links to workspace preview instead of new window (#1239)
Chat-emitted markdown links like `[template.html](template.html)` rendered
as `<a target="_blank">` with no click handler. In Electron that hits
`setWindowOpenHandler` and creates a new `od://` BrowserWindow; relative
hrefs have no base so the new window can't resolve them and the user
lands on the home screen — the file they wanted to preview is never
shown.
Detect in-project file paths in chat markdown via a new
`asInProjectFilePath` helper and route them through the existing
`requestOpenFile` workspace tab opener. External URLs, `mailto:`,
`#anchors`, absolute paths and `..` traversal keep their default
browser-link behavior. The `renderMarkdown(options)` extension is
backwards-compatible: existing callers (file viewer, system reminders)
keep their default `target="_blank"` behavior when the option is
omitted.
Closes#1239.
* fix(web): decode percent-encoded chat file links before workspace open (#1239)
Chat markdown frequently emits links as URL-encoded text — `Mock%20Page.html`
for a file named `Mock Page.html`, multi-byte sequences for non-ASCII
filenames. The workspace tab opener (`requestOpenFile` →
`FileWorkspace`) matches by literal on-disk file name, so handing it
the raw `%20`-encoded form silently misses the existing tab and the
user sees nothing happen on click — the exact regression #1239
reopened against.
Decode after the literal `..` check and re-check `..` on the
decoded form so a `%2E%2E` smuggling attempt cannot bypass the
traversal guard. Malformed encodings fall through to `null` (default
browser link behavior) instead of letting URIError crash the
renderer.
The same gap was flagged on the earlier draft PR #1255 by mrcfps and
lefarcen (P2) but never landed there; this PR now covers it with
five new regression tests (ASCII spaces, nested subdirs, UTF-8 byte
sequences, malformed `%`, percent-encoded traversal).
* fix: prevent plugin info collapse button from scrolling with content
Changed .ds-modal-stage-handle from position: absolute to position: sticky
so the collapse/expand button stays anchored at the vertical center of the
viewport instead of scrolling with the sidebar content.
Before: button moved with scrolling content and could overlap text
After: button remains fixed at 50% viewport height, always accessible
Closes#2209
* fix: use sticky positioning only for collapse button
The original approach broke the expand button because changing the base
class to position: sticky made the expand button's right: 0 ineffective.
Correct fix:
- Base class stays position: absolute (for expand button at stage edge)
- Only .is-collapse overrides to position: sticky (for sidebar scroll)
This ensures:
- Expand button anchors correctly at the right edge when sidebar closed
- Collapse button stays at viewport center when sidebar content scrolls
- No regression in either collapsed or expanded state
* fix(daemon): isolate per-agent detection failures so one bad probe cannot blank the picker
`detectAgents` ran every adapter probe in a bare `Promise.all`, so a
synchronous throw from any single probe (e.g. a filesystem error
during PATH walking on a packaged Windows daemon, or an unhandled
async rejection from one of the post-launch probes) rejected the
whole batch. The `/api/agents` route's `catch(() => [])` then handed
the UI an empty list and the model picker collapsed to BYOK / Cloud
only, losing every installed CLI option — which matches what users
in issue #2297 observed after one or two app restarts on Windows.
Wrap each probe in `safeProbe` so a single failure degrades just that
adapter to `unavailableAgent(def)` while the rest of the registry
keeps its real availability. The new
`apps/daemon/tests/runtimes/detection-resilience.test.ts` pins both
synchronous failure sites that previously sat outside the existing
inner try/catch blocks (`resolveAgentLaunch` and
`applyAgentLaunchEnv`) so a future code change cannot regress the
isolation contract.
This is a defensive guard rather than a Windows-only diagnosis: it
fixes any scenario where a single probe blows up, including ones we
have not reproduced yet. If a user still hits #2297 after this lands,
the daemon log will identify which adapter failed instead of silently
returning an empty list.
Fixes#2297
* ci: re-run checks (unrelated e2e baseline flake on previous run)
The Open Design wordmark sits next to the circular app icon in the
macOS title bar (`EntryNavRail` home button), and the inner mark inside
that circle reads as slightly off-center because its bounding box sits
about 16px right and 9px below the circle's geometric center.
Shift the inner mark by (-15.635, -9.135) so its bbox center matches
the circle center at (266.503, 266.503). The change rewrites the two
sub-paths that draw the mark and its interior cut-out; the outer ring
keeps the original coordinates so the gradient mask, blur filters, and
ring weight all render identically. Dock and Launchpad icons live in
`tools/pack/resources/{mac,win,linux}/icon.*` and are not touched, so
this fix is scoped to the title bar / favicon / mask-icon surfaces as
described in #2401.
ProjectView calls useI18n() for locale/t, but this suite's `../../src/i18n`
mock only returned useT — so every render threw `No "useI18n" export is
defined on the i18n mock` and 5 of 10 cases failed. Mirror the i18n mock the
other ProjectView suites already use (useI18n + useT) so the suite renders.
Co-authored-by: nicejames <nicejames@gmail.com>
* feat(mcp): add write_file, delete_file, delete_project tools
External coding agents driving Open Design through MCP can create new
artifacts (create_artifact) but cannot iterate on a file once written
(create_artifact rejects existing targets), cannot remove a stale
file, and cannot tear down a throwaway project they just spun up via
create_project. Close that loop so the same agent can drive the full
file/project lifecycle end-to-end through MCP.
- write_file(path, content, encoding?): POSTs to /api/projects/:id/files
without `artifact: true`, which the daemon route writes as a plain
overwrite. Supports nested paths and base64 binaries.
- delete_file(path): DELETEs /api/projects/:id/raw/<path> so nested
paths work just like create_artifact's nested name argument.
- delete_project(project, confirm:true): DELETEs /api/projects/:id but
refuses to fall back to the active project and requires confirm:true,
since the operation purges the SQLite row and on-disk project dir
irreversibly. Marked destructiveHint:true on the annotation.
Tests cover each tool's success path, the active-context fallback for
write/delete_file, missing-argument rejection before any network call,
the daemon-error mapper, and the two delete_project guards.
* fix(mcp): echo resolvedProject from delete_project and cover the daemon error path
Two follow-ups from review of #2416:
- delete_project accepts a name substring per its inputSchema and the
server instructions block tells callers to verify which row was
matched via resolvedProject. write_file/delete_file already honor
that contract via withActiveEcho(json, active, resolved), but
deleteProject destructured only `id` and dropped the echo on the
one irreversible tool. Capture `resolved` and pass it through;
active is always null here because the active-context fallback is
intentionally disabled.
- formatDaemonError and the !resp.ok branches in writeFile/deleteFile/
deleteProject had zero coverage — all nine tests stubbed status: 200.
Add three regressions covering the structured-error reformat, the
raw-text fallthrough for non-JSON bodies, and the irreversible
delete_project surface, so a regression in the parse/fallthrough
logic will fail in CI instead of reaching agents.
* fix(tools-dev): preserve web origin trust on web start
Restart daemon/web when the trusted web port is missing, and reuse the active web port during repeated starts so run web and start web keep app-config origin checks aligned.
Generated-By: looper 0.0.0-dev (runner=worker, agent=opencode)
* fix(plugins): refresh official registry bundled count
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* fix(tools-dev): preserve daemon/web reserved ports
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* fix(tools-dev): preserve daemon reuse on web start
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* fix(tools-dev): preserve running daemon port on web reuse
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* fix(tools-dev): reserve explicit web port before daemon allocation
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* test(web): stabilize media provider reload flash timing
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* fix(web): restore merged reattach workspace coverage
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* fix(tools-dev): reserve allocated daemon port
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* test(e2e): wait for artifact manifest persistence
Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
* fix(web): chat pane preserves scroll position when todo card grows
The PinnedTodoSlot renders outside the chat-log scroll container. When
the todo card grows (new tasks added via TodoWrite), the scroll container's
clientHeight shrinks in the flex layout, drifting the user away from the
bottom. The existing ResizeObserver only observed children of the chat-log
div, so pinned-todo growth was invisible to followLatestIfPinned.
Fix: pass a containerRef to PinnedTodoSlot and observe that element in the
same ResizeObserver. syncPinnedTodo() is called on effect setup and from
the MutationObserver callback so observation stays current as the slot
appears and disappears across TodoWrite snapshots.
Red spec: apps/web/tests/components/chat-todo-autoscroll.test.tsx
* fixup! fix(web): chat pane preserves scroll position when todo card grows
Clarify test comment: the second test confirms followLatestIfPinned
snaps scroll to bottom when fired. The structural guarantee (pinned-todo
element is observed) is separately asserted in test 1, which is the
check that goes red on main without the fix.
* fix(web): correctness extend MutationObserver to pane ancestor for PinnedTodoSlot mount detection
The MutationObserver was only watching the .chat-log element. PinnedTodoSlot
(.chat-pinned-todo) is a sibling of .chat-log-wrap inside .pane, outside the
observed subtree. syncPinnedTodo inside the MutationObserver callback was
therefore dead code for mount/unmount transitions of the slot.
Add a second observation on paneEl (el.parentElement?.parentElement) with
childList-only so the MutationObserver fires when PinnedTodoSlot mounts or
unmounts and syncPinnedTodo can register/deregister the element with the
ResizeObserver.
* test(e2e): chat pane auto-scroll on todo card growth
Add Playwright spec that goes red on origin/main and green on this fix
branch. Scenario A asserts that a chat-log pinned to the bottom snaps
back after the PinnedTodoCard grows (the ResizeObserver-on-pinned-todo
path). Scenario B asserts that a deliberate scroll-up is not overridden.
Also allow OD_WORKSPACE_ROOT env override in next.config.ts so Turbopack
resolves node_modules correctly when the web app is booted from a worktree
whose node_modules symlinks resolve outside the default workspace root.
* docs(agents): note pinned-todo observer coverage in chat UI conventions
PinnedTodoSlot sits outside the .chat-log scroll container, so the
ResizeObserver and MutationObserver coverage that keeps auto-scroll
working when the todo card grows is non-obvious to future implementers.
Document the invariant in the Chat UI conventions section.
* fix(web): validate OD_WORKSPACE_ROOT, harden autoscroll test precondition
* fix(web): validate OD_WORKSPACE_ROOT existence, make autoscroll precondition unconditional
* fix(web): throw on invalid OD_WORKSPACE_ROOT instead of warn-and-fallback
* fix(web): require pnpm-workspace.yaml at OD_WORKSPACE_ROOT, drop dead test branch
Three follow-ups to nettee's review feedback:
1. apps/web/next.config.ts gains a pnpm-workspace.yaml existence check
after the relative-path validation. Without it, an override like
'<repo>/apps' or '<repo>/apps/web' passes the relative(resolved, WEB_ROOT)
check but the resolved path is missing the sibling packages/* directory
that apps/web imports from (for example @open-design/contracts). Next
would later fail deep inside file tracing / Turbopack with a much
harder-to-diagnose error. Now we throw at config load with a clear message.
2. e2e/ui/chat-todo-autoscroll.test.ts drops the redundant
'if (scrollUpOccurred)' branch. The hard precondition above it already
guarantees distanceAfterScroll > 80, so the if was dead code that read
as a false-green path. The body now runs unconditionally.
3. Same test tightens the post-grow assertion. The previous
toBeGreaterThan(60) would pass even if a regression dragged the log
most of the way back to the bottom (e.g. before=150, after=61).
Replaced with Math.abs(distanceAfterGrow - distanceAfterScroll) less than
SCROLL_PRESERVATION_TOLERANCE_PX (20) — a delta check that actually
verifies the comment's claim of 'within ~20px of where the user left it'.
* fix(web): canonicalize workspace root with realpathSync and tighten scenario B assertion
- Use realpathSync on both resolved and WEB_ROOT before the ancestor check so
that symlinked paths (macOS /tmp vs /private/tmp, worktree checkouts) compare
correctly instead of false-throwing on a physically valid override.
- Add isAbsolute(rel) guard for the Windows cross-drive case where path.relative()
returns an absolute path instead of a ..-prefixed string.
- Scenario B: replace distance-to-bottom delta assertion with scrollTop preservation
check. Growing the pinned todo naturally increases distance-to-bottom by ~extraPx
(clientHeight shrinks while scrollTop is held fixed), so the old Math.abs(after -
before) < 20 check would fail on correct behavior. asserting scrollTop directly
catches the real regression: followLatestIfPinned incorrectly snapping a non-pinned
user back to the bottom.
- Add hard precondition that clientHeight actually changed so the test fails fast
if the layout stops exercising the non-pinned path.
* test(e2e,web): add clientHeight guard to scenario A and mount-wiring unit test
---------
Co-authored-by: Patrick A <eefynet@users.noreply.github.com>
Co-authored-by: Patrick A <259201958+eefynet@users.noreply.github.com>