The <attached-preview-comments> block opened with 'Scope: apply the
user request to the attached preview target by default. ... Preserve
unrelated elements.' That reads as a soft preference, and in practice
agents still refactor sibling sub-pages, parent layout, or global CSS
when the user only intended the attached element.
Replace with a hard scope directive:
Hard scope: change ONLY the elements identified below by selector /
position / pod members. Do NOT modify sibling sub-pages, parent
layout, global CSS, design tokens, or unrelated rules even if you
notice issues there — surface those as a follow-up note in your
reply instead of editing them. If the user's request cannot be
satisfied without touching outside this scope, ask the user before
proceeding. For visual marks, inspect the screenshot and modify the
marked region first.
Applied symmetrically to apps/web/src/comments.ts and
apps/daemon/src/server.ts so the web composer and the daemon-side
attachment renderer stay in sync.
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>
* blog(landing-page): announce Open Design 0.8.0 plugin engine rebuild
Adds the Product (announcement) post for the open-design-v0.8.0 release
(305 PRs · 75 contributors · 7 days; tag c20d156). Covers the three
architectural shifts that ship in 0.8.0 — plugin engine, headless-by-
default CLI, plugins create plugins — plus the design-system /
critique-theater / media-provider / packaged-auto-update plate. CTA
links to the GitHub release.
Also:
- Hero plate plate-15-plugin-engine-modular.png wired into postImages
map in app/pages/blog/index.astro (editorial study plate, warm paper
+ red circle, per the open-design-blog-factory hero policy)
- _topics.md backlog updated: new row in Shipped, supersedes the queued
#9 "auto-update on Windows + Linux" row (folded into this release
announcement), Last reviewed bumped to 2026-05-22
Verified locally:
- pnpm typecheck — 0 errors, 0 warnings
- tsx scripts/blog-indexing/lint-blog-seo.ts — 0 errors, 0 warnings
Co-authored-by: Cursor <cursoragent@cursor.com>
* blog(landing-page): rework plate-15 to match editorial study-plate family
The first generation of plate-15 read as a marketing cover (16:9 frame
with baked-in "Plugin Engine Rebuild 0.8.0" headline + OPEN DESIGN
wordmark), which broke the visual register of plates 09–14 — all square
1024×1024 editorial study plates with a single dominant red circle and
no on-image typography.
Regenerated the plate to match the family: 1024×1024, warm cream paper
with deckle edges, a bold red circle in the upper-right, schematic
plugin tiles around a central black cube engine core, olive branch +
ink arc at the bottom, no headline / wordmark text. Updated the alt
text to describe the actual artefact.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: ashley li <ashleyli@ashleydeMacBook-Air-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(landing-page): detail pages — interactive preview, share row, dual CTAs
Joey requested three additions to every `/skills/<slug>/` and
`/templates/<slug>/` detail page, with opendesigner.io's skills
catalog and youmind.com's seedance prompt page as references.
What
- **Interactive preview**: a `<details>` toggle below the static thumb
reveals an `<iframe sandbox>` rendering the canonical artifact
(`/skills/<slug>/example.html` for skill-template origins,
`/templates/<slug>/template.html` for live-artifact origins). The
iframe loads lazily — only on first toggle — so the page stays fast.
An "Open in new tab ↗" pill on top-right of the frame links to the
same URL standalone.
- **Six-channel share row**: Reddit, X, LinkedIn, Facebook, Email,
Copy-link. Each anchor is a vendor "intent" URL (no tracker SDKs);
the copy-link button uses the Clipboard API with a `prompt()`
fallback for older Safari / embedded webviews. Wired by a small
handler appended to `header-enhancer.astro`.
- **Two primary CTAs** in the detail-actions row:
- "Use this skill →" / "Use this template →" routes to
`/quickstart/?skill=<slug>` (or `?template=<slug>`). The OD
desktop client has no public protocol handler yet, so a
`od://skill/<slug>` deep link would 404. Quickstart is the v1
pivot; once the client registers a scheme, the anchor flips to
a JS try-`od://`-then-fallback without changing the page surface.
- "Find on GitHub →" deep-links into the source folder.
Share copy keeps "open-source Claude Design alternative" front and
center across every channel — same brand keyword Google associates
with the homepage and `/alternatives/claude-design/`, so each social
click reinforces the same entity claim. Per-skill name + summary
follow so a reader who lands on a friend's tweet has a concrete
reason to click.
- X intent: "I'm using <skill> from @opendesignai — the open-source
Claude Design alternative.\n\n<description>"
- Reddit submit title: "<skill> — open-source Claude Design alternative"
- Email subject: same as Reddit; body: "I thought you'd like this —
<skill>, an open-source Claude Design alternative skill from Open
Design.\n\n<description>\n\n<url>"
- LinkedIn / Facebook: URL-only (those vendors auto-fetch OG meta,
so they read the existing canonical title + image).
Surface area
- Marketing site only. `apps/landing-page/app/pages/skills/[slug].astro`,
`pages/templates/[slug].astro`, `_components/header-enhancer.astro`,
`sub-pages.css`.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI surfaces.
- No new top-level dependencies. WeChat QR was dropped from the v1
scoping in favor of Joey's revised channel set; brings Reddit and
Facebook in instead.
Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: `/skills/deck-swiss-international/` shows all six share
buttons, both CTAs, and the iframe `<details>` toggle. Same on
`/templates/magazine-poster/`.
- Local dev: Reddit submit URL contains the SEO keyword in the title
param; X intent URL contains the @opendesignai mention + keyword
in the tweet body; Email mailto: subject + body wired correctly.
Followups
- Once OD desktop client registers a `od://` scheme, flip the "Use
this skill" anchor to JS-driven try + fallback so installed users
bypass /quickstart/.
- Translate the share copy + CTA labels across the 18 landing
locales (currently English-only).
- `i18n.ts` `ui.catalog.skills` keys could absorb the share-copy
template if we want per-locale share text in the future.
* fix(landing-page): preview clicks the thumb; CTA goes to releases
Two follow-ups to #2679 against Joey's review.
1. Preview UX: the thumb is the trigger
The previous shape rendered a static thumb followed by a separate
"View interactive preview ▸" disclosure row underneath. Joey wanted
one composed unit: click the thumb itself to open the live frame.
Wraps the existing `<details>` so that `<summary>` IS the thumb
image (with a hover overlay revealing "Click for live preview ↗"),
and once open the summary hides so the iframe lands in the same
visual slot. The figcaption moves below the open/closed unit so it
labels both states identically.
2. "Use this skill" / "Use this template" → /releases
Sends users straight to the desktop-app release page rather than
pivoting through /quickstart/. The flow is now concrete (download
the binary now) instead of asking users to read an install doc as
step 0. Once the desktop client registers a `od://skill/<slug>`
protocol handler, this anchor flips to a JS try-deep-link-then-
fallback without changing the page surface.
Note on the other two issues Joey raised:
- example.html 404: production has all 4 example files at HTTP 200
(verified with curl). The 404 in his screenshot was production
serving the previous deploy that pre-dates this PR; the fix is in
flight, not a missing route. Once #2679 deploys, the iframe will
resolve cleanly.
- Empty share copy: same root cause. Production HTML still rendered
the pre-#2679 share row (no copy at all). Local dev confirms the
X intent URL contains the full "I'm using <skill> from
@opendesignai — the open-source Claude Design alternative…"
string in the `text` param; Reddit submit URL contains the
"<skill> — open-source Claude Design alternative" title; Email
mailto: subject and body are wired. LinkedIn and Facebook are
URL-only by their vendor design — those platforms read the OG
meta tags from the destination page itself.
Surface area
- Marketing site only. `pages/skills/[slug].astro`,
`pages/templates/[slug].astro`, `sub-pages.css`.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI.
- No new dependencies.
Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: skill detail thumb shows the live-preview overlay on
hover; click opens the iframe in the same frame. Use this skill
→ opens https://github.com/nexu-io/open-design/releases. Same on
the templates detail page.
* fix(landing-page): example.html copy step + share dialog with copy-then-paste flow
Two follow-ups against Joey's review of #2679.
1. example.html 404 — production was SPA-falling back to the homepage
The "404" Joey screenshotted on
`/skills/deck-guizang-editorial/example.html` was a Cloudflare Pages
SPA fallback: the URL returned HTTP 200 but the body was the
homepage HTML, so the iframe loaded "the homepage inside the iframe"
which the browser displays as broken-page. Root cause: the build
artifact never contained `out/skills/<slug>/example.html`. Astro
generates `<slug>/index.html` for the detail page from `[slug].astro`,
but the canonical `example.html` next to the SKILL.md file in the
repo root never gets copied into `out/`.
Adds `scripts/copy-example-html.ts` and chains it into the
`build` script. After `astro build`, the script walks:
- `skills/<slug>/example.html` → `out/skills/<slug>/example.html`
- `design-templates/<slug>/example.html` → `out/skills/<slug>/example.html`
(design-templates surface as skill-template-origin records in the
catalog and the iframe targets the `/skills/<slug>/example.html`
path for those.)
- `templates/live-artifacts/<slug>/template.html` → `out/templates/<slug>/template.html`
(live-artifact-origin records — the iframe targets template.html.)
Source files that don't exist are silently skipped. The script
prints a summary line so the build log makes the count visible.
2. Share UX — modal with copy-then-paste flow
The previous inline 6-button row had two problems Joey called out:
- Position was below the meta block, not prominent enough.
- LinkedIn and Facebook ignore `text` pre-fill params, so users
landing on those platforms saw an empty composer with no idea
what to write. X / Reddit pre-fill works but truncates Chinese
unpredictably.
Replaces the row with a `<dialog>` modal:
- A `Share ↗` button sits inside `.detail-actions` next to the
primary CTAs, so it has equal visual weight.
- Clicking opens the dialog with the canonical share copy
(containing the brand SEO keyword "open-source Claude Design
alternative") in a readonly `<textarea>`.
- `Copy text` button writes the textarea contents to the clipboard
(with a `prompt()` fallback for older browsers) and flashes the
coral confirmation state.
- `Copy link only` writes just the URL.
- Below: a row of platform jump buttons (X · LinkedIn · Reddit ·
Facebook · Email). Each opens the vendor's compose URL in a new
tab. The user pastes the already-copied text — uniformly
reliable across every platform.
- Modal closes via the × button (form method="dialog") or Escape.
Native `<dialog>` element + `showModal()` API. No new dependencies;
the JS handler lives in the existing `header-enhancer.astro`
inline script alongside the headroom + stars + hamburger handlers.
Surface area
- Marketing site only. `pages/skills/[slug].astro`,
`pages/templates/[slug].astro`, `_components/header-enhancer.astro`,
`sub-pages.css`, plus the new `scripts/copy-example-html.ts` and
one-line `package.json` build script change.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI.
- No new dependencies.
Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: `/skills/<slug>/` shows the `Share ↗` trigger inside
detail-actions; clicking opens the modal with the readonly
textarea pre-filled with the canonical share copy. Copy text /
Copy link only both flash coral on click and write to clipboard.
Platform buttons open compose pages in new tabs.
- After deploy: `/skills/<slug>/example.html` will resolve to the
actual canonical example output rather than SPA-falling back to
the homepage. Same for templates.
* fix(landing-page): example.html endpoint routes + locale-aware share + brand logos
Three follow-ups against Joey's review of #2679 round 2.
1. example.html 404 — root cause + proper fix
The 404 Joey kept seeing was real, not a deploy lag: nothing in
the build pipeline copied `skills/<slug>/example.html` from the
repo root into the landing-page output. Astro generated only the
detail-page `index.html`; Cloudflare Pages SPA-fell-back to the
homepage on requests for `example.html`, which the browser
rendered as "wrong page in iframe" and Joey read as 404.
Replaces the post-build copy script (`scripts/copy-example-html.ts`,
removed) with two Astro endpoint routes:
- `pages/skills/[slug]/example.html.ts` — streams the canonical
example for skill-template-origin records, including the
design-templates passthrough
(`design-templates/<slug>/example.html` → same URL).
- `pages/templates/[slug]/template.html.ts` — streams the canonical
artifact for live-artifact-origin templates.
Both use `getStaticPaths` so Astro pre-renders into the static
build artifact under `out/`. Works in dev (Astro dev server runs
the endpoint live) and prod (file is on disk after `astro build`).
Required moving `pages/skills/[slug].astro` →
`pages/skills/[slug]/index.astro` (and same for templates) because
Astro can't have BOTH a `[slug].astro` file AND a `[slug]/`
directory with dynamic param children at the same level. The
`[locale]/skills/[slug].astro` re-exporters were updated to point
at the new index files.
`trailingSlash: 'always'` rewrites endpoint URLs to `path/`, so the
iframe `src` and "Open in new tab" anchor now use
`example.html/` and `template.html/` (with trailing slash). Tested
locally: HTTP 200 + real example HTML in the body.
2. Share copy now per-locale; description dropped
The previous template hardcoded the framing in English ("I'm using
X from @opendesignai…") with the description following from
`skill.description`. Joey's catch: when the SKILL.md description is
in one language and the page locale is another, the share text
reads as a forced bilingual mash-up.
Adds an inline `SHARE_COPY` table per landing locale (18 entries,
one per locale). Drops the description from the share template
entirely — the framing + URL is enough to prompt a click, and
removes any chance of a bilingual mismatch when SKILL.md
frontmatter happens to be in a non-matching language.
The brand keyword "open-source Claude Design alternative" stays
English because that's the canonical search query Google
associates with the domain — translating it would split the
entity claim. Surrounding sentence translates per locale so the
message reads as one voice.
Same template added for templates/[slug]/index.astro.
3. Share dialog UI: brand logos for the 4 platform jump buttons; Email dropped
Replaces the previous text labels (`X` / `LinkedIn` / `Reddit` /
`Facebook` / `Email`) with inline-SVG brand logos. Per Joey's
revision the Email channel was dropped — Gmail / Outlook
pre-fill is reliable but the audience reach is much smaller than
the four social platforms, and removing it tightens the row.
Logos are SimpleIcons-style SVG paths inlined directly (no font
dependency, no external icon library). Each button keeps an
`aria-label` plus a visually-hidden `<span class="sr-only">`
for screen readers.
Surface area
- Marketing site only. `pages/skills/[slug]/index.astro`,
`pages/skills/[slug]/example.html.ts`,
`pages/templates/[slug]/index.astro`,
`pages/templates/[slug]/template.html.ts`,
`_components/header-enhancer.astro`, `sub-pages.css`,
`package.json` (build script revert), and the two `[locale]/...`
re-exporters.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI surfaces.
- No new top-level dependencies.
- The two restructured detail pages keep their existing route URLs
and existing static-paths logic — only the file location changed.
Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: `/skills/deck-guizang-editorial/example.html/` returns
HTTP 200 with a 4942-byte body that's the actual canonical
example output (not the homepage SPA fallback).
- Local dev: `/skills/deck-swiss-international/` share dialog shows
4 brand-logo platform buttons (no Email); textarea contains the
English-only framing + URL. `/zh/skills/...` shows the Chinese
framing + URL with no English bleed-through.
* fix(landing-page): punchier share copy with emojis across 18 locales
The previous share template ("I'm using <name> from @opendesignai —
the open-source Claude Design alternative.\n\n<url>") was too flat to
spark a click — Joey called it out as 平淡 with the keyword
front-and-center but no hook.
New shape: three-line punchy block with emojis as visual anchors.
Skills surface (`/skills/<slug>/`):
🎨 Just discovered <name> on @opendesignai — the open-source
Claude Design alternative.
✨ Local-first · BYOK · your agent does the design.
→ <url>
Templates surface (`/templates/<slug>/`):
🎨 Just forked <name> from @opendesignai — the open-source
Claude Design alternative.
✨ Templates as files, not vendor docs. Fork → swap → ship.
→ <url>
Pattern per locale:
- Line 1: action verb hook (`Just discovered` / `Just forked` /
locale equivalent like `安利一个` / `推薦一個` / `Gerade entdeckt` /
`Découvert` / etc) + skill name + brand keyword.
- Line 2: tight value-prop with `·` separators — Local-first ·
BYOK · agent does the design (skills) or Templates as files,
not vendor docs (templates).
- Line 3: → URL.
Both lines lead with an emoji (🎨 then ✨) so the post visually pops
in a feed. The brand keyword "open-source Claude Design alternative"
stays English in every locale (canonical search query for the
domain); surrounding sentence translates per locale.
All 18 landing locales rewritten — ar, de, en, es, fr, id, it, ja,
ko, nl, pl, pt-br, ru, tr, uk, vi, zh, zh-tw. Skills and templates
each have their own `SHARE_COPY` table; the templates variant has
fork-flavored framing because the user action there is fork-and-ship,
not run-once.
Surface area
- Marketing site only. `pages/skills/[slug]/index.astro` and
`pages/templates/[slug]/index.astro`.
- No other files touched. No new dependencies.
Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: en / zh / ja all render with emojis intact and
language-specific framing; X intent URL preserves the multiline
breaks via `\n` in the `text` query param.
* fix(landing-page): restore post-build copy step for preview iframes
The detail-page interactive preview iframe pointed at endpoint routes
(`pages/skills/[slug]/example.html.ts`,
`pages/templates/[slug]/template.html.ts`) introduced in 0d9c9a5, but
Astro 6 silently drops `pages/<dir>/[slug]/<file>.<ext>.ts` routes
under dynamic segments at build time — even with `export const
prerender = true` — so the URLs returned 404 in both `pnpm dev` and
the production build.
Verified locally: dev server `curl /skills/<slug>/example.html` → 404,
`find apps/landing-page/out -name 'example.html'` → 0 files after a
clean `pnpm build`.
Restore the post-build copy step that 138cbd2 had: an `astro build`
postscript that mirrors `skills/<slug>/example.html` and
`design-templates/<slug>/example.html` into the static output. While
re-introducing the script, also address the live-artifact preview
mismatch flagged by review:
- Live-artifact records carry a `live-` slug prefix from
`shapeLiveArtifactTemplate()` in `_lib/catalog.ts`, so the iframe
URL is `/templates/live-<slug>/preview.html` — copy the source
file into `out/templates/live-<slug>/preview.html` to match.
- Serve `index.html` (the rendered preview) rather than
`template.html` (which still contains `{{data.*}}` placeholders).
The iframe is for visitors and reviewers, not the template
runtime.
Detail-page iframe `src` and "Open in new tab" link in
`pages/templates/[slug]/index.astro` already use `/preview.html`;
sub-pages.css comment kept aligned.
---------
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
Mirror the issue #398 fix the claude adapter already has: when
spawning Codex CLI without a custom OPENAI_BASE_URL, strip both
OPENAI_API_KEY and CODEX_API_KEY from the child env so Codex CLI's
own `~/.codex/auth.json` (codex login) wins.
Without this guard, a stale BYOK key left behind in
`agentCliEnv.codex.OPENAI_API_KEY` (e.g. after the user clears the
BYOK dialog and switches execution mode back to Local CLI) silently
flows through `spawnEnvForAgent` and trips 401 invalid_api_key.
The stripping is gated on OPENAI_BASE_URL so users who intentionally
route Codex CLI through a third-party OpenAI-compatible gateway keep
the credential that authenticates against it. Comparison is
case-insensitive to close the Windows mixed-case env name hole that
issue #398 already documents for ANTHROPIC_API_KEY.
Fixes#2420
* fix(daemon): finish live-artifact chat runs via watchdog quiet-period handoff (#1451)
Live-artifact runs were staying in `Working` for the full 10-minute
inactivity window even after the deliverable had been registered, and
sometimes finishing as `failed` with `Agent stalled without emitting
any new output for 600s`. The agent process kept its stdin/stdout
alive (claude-code stream-json idle stdin, post-write reasoning that
never reaches the chat) so the existing watchdog could not tell the
deliverable was already in the user's hands.
Wire `/api/tools/live-artifacts/create` back into the chat run via a
small per-run handle registry: on the first `created` event, the run
flips a local `artifactRegistered` flag and rearms the watchdog with
the shorter `OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS` (default 60s)
instead of the 10-minute pre-artifact ceiling. When that quiet timer
trips, the watchdog no longer emits a stalled-error / `failed` finish;
it SIGTERMs the child and lets the existing child-exit handler do
final classification — the close handler now treats a SIGTERM exit
after a registered artifact as `succeeded`, matching what the user
actually got (a delivered artifact, not a failed run).
The handoff stays with the existing child-exit lifecycle, so tool
token revocation, cancel semantics, and exit-status classification
keep their current owner — addressing the PR #1543 review history
where finishing the run from the tool route bypassed those guarantees.
Closes#1451.
* fix(daemon): gate artifact quiet-period close on daemon-initiated flag (#1451 review follow-up)
Reviewer (#2585) found that the close-handler branch reclassifying
SIGTERM/SIGKILL as `succeeded` only checked `artifactRegistered`, so an
unrelated later termination (external `kill`, OOM, container shutdown)
after a successful artifact write would silently flip the run from
`failed` to `succeeded` — the exact "completed without producing
anything visible" failure mode the existing close handler is trying
to prevent.
Track the watchdog-initiated shutdown explicitly: set
`artifactQuietShutdownRequested = true` immediately before
`failForInactivity()` sends SIGTERM (covering the kill-grace SIGKILL
escalation under the same flag), and require that flag in the close
handler's quiet-period branch.
Extract the final-status decision into a pure
`classifyChatRunCloseStatus` so the daemon-initiated vs external
signal cases can be pinned with focused unit tests instead of
asserting closure-internal state via end-to-end timing.
* fix(daemon): treat OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS=0 as disabled (#1451 review follow-up)
Reviewer (#2585 non-blocking) found that an operator override of
`OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS=0` no longer behaved as a
"disable the quiet period" knob: once the artifact was registered,
`activeInactivityTimeoutMs()` dropped to 0, `noteAgentActivity()`
early-returned without clearing the prior timer, and the pre-artifact
10-minute timer kept running while further agent activity stopped
refreshing it.
Make the quiet-period switch conditional on a positive value. A 0
override now means "do not shorten after artifact registration" — the
pre-artifact ceiling stays active, subsequent activity continues to
reschedule it, and the existing pre-artifact stalled-error path still
fires when the agent genuinely hangs. Pin the resolver as a pure
`resolveActiveInactivityTimeoutMs` helper so the four quiet-vs-pre
matrix cases are unit-tested directly.
* fix(daemon): arm the quiet-period watchdog when pre-artifact timeout is disabled (#1451 review follow-up)
Reviewer (#2585 non-blocking, round 3) found that
`OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS=0` paired with
`OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS>0` left the watchdog disarmed
forever. The `noteAgentActivity()` call at run start exited early
because the pre-artifact delay was 0, so `inactivityTimer` was still
`null` when the artifact was registered, and the prior
`if (inactivityTimer) noteAgentActivity()` guard inside
`noteArtifactRegistered()` then skipped the re-arm. The
newly-positive quiet-period delay never armed a timer at all — a
chat run that went silent right after artifact creation would stay
`running` indefinitely.
Drop the guard. `noteAgentActivity()` is already the function that
decides whether to schedule (it bails when the active delay is 0),
so calling it unconditionally keeps the behavior coherent across the
four pre/quiet combinations: both non-zero (was already fine), pre=0
+ quiet>0 (now arms the quiet timer), pre>0 + quiet=0 (still falls
back to the pre-artifact ceiling via the existing resolver), both
zero (still no watchdog at all — operator opted out).
Pure-function coverage of the ceiling decision stays in
`resolveActiveInactivityTimeoutMs` — exercised across the same four
combinations in the existing unit suite.
Four follow-up gaps surfaced from comparing the v2 spec field lists
against live PostHog data on a DS-create + DS-detail walkthrough:
1. **`design_system_apply_result` had no `page_name=studio` carrier.**
PR #2706 wired the home variant (NewProjectPanel picker) but the
in-project header picker (`ProjectDesignSystemPicker`, lives in
ProjectView chrome) PATCHed `project.designSystemId` without
emitting an apply row. The funnel only saw applies from the
home-side, not from running projects switching their DS.
Fix: `ProjectView.handleChangeDesignSystemId` emits
`design_system_apply_result` with `page_name=studio`,
`action=select_design_system` or `clear_selection`,
`design_system_selection_mode=manual`, and the DS's
origin/status mapped from the picker's `DesignSystemSummary`.
2. **`design_system_create_result.project_id` was always missing.**
The contract had `project_id` optional but the emit site never
populated it, even though a successful generate path opens a
workspace project (`project.id` is in scope right after
`ensureDesignSystemWorkspace`). `created_as_project: true` rows
carried no project id at all.
Fix: `emitCreateResult` signature gains a `projectId` parameter;
the success branch passes `project.id`, failure branches pass
`undefined`.
3. **`design_system_review_result` only emitted on Looks good /
Needs work clicks (`review_action: looks_good|needs_work`).**
The spec also defines `submit_revision` and `regenerate` actions.
When a user clicks Needs work → types feedback → sends, the send
IS the `submit_revision` lifecycle moment, but no row fired.
Dashboards couldn't separate "picked Needs work but never sent"
from "picked Needs work and dispatched a revision request".
Fix: `sendProjectChatMessage` checks for `feedbackSection` (set
by the Needs work onClick) and emits `review_action:
submit_revision`, `result: submitted`, with the section slug and
the feedback length bucket. `regenerate` stays out of scope —
the current UI has no explicit regenerate button; the Generate
path always creates a fresh DS.
4. **`page_view{page_name=design_system_project}` carried no
`project_id`.** Both the generation-active and the
design_system_preview emissions had `system.id` but not the
workspace project id, so the funnel couldn't join the DS detail
page_view to the project the user actually edits inside.
Fix: both `trackPageView` calls pass `workspaceProjectId`; the
useEffect deps add `workspaceProjectId` so a delayed ensure
(workspace mounts after first render) re-emits with the now-
known id.
Validation:
pnpm --filter @open-design/web typecheck clean
pnpm --filter @open-design/web test 1844/1844
* fix(daemon): restore full assistant turn after mid-flight reload reattach
When a daemon run is in progress and the browser reloads, the client
reattaches and the artifact recovers, but the restored chat turn drops
assistant text, thinking events, and producedFiles. Three independent
defects combine to cause this:
1. The reattach onDone never populated producedFiles. The pre-turn file
snapshot used as the diff baseline lived only in a closure. Now it is
persisted on the assistant message as preTurnFileNames so the reattach
path can rebuild the diff after reload.
2. The SSE replay used a strict `>` cursor compare. A client that had
already persisted lastRunEventId equal to the final event id received
zero replay events on terminal-run reattach, fell into the status-only
REST fallback, and never fired a clean onDone. The server now replays
the final buffered event on terminal-run reattach when the cursor is
at or past the end, so the client always sees a terminal signal.
3. The text buffer flushed on visibilitychange but not on pagehide.
Hard reloads on browsers where visibilitychange does not fire before
teardown could lose the last ~250ms of streamed text from the
persisted message. A pagehide listener now flushes synchronously.
Refactor: extracted computeProducedFiles helper so the send and reattach
flows share the diff logic and cannot drift apart again.
Tests:
- apps/web/tests/components/ProjectView.reattach-restore.test.tsx
covers: reattach onDone populates producedFiles from preTurnFileNames;
reattach reaches succeeded via SSE even when only the end event replays;
computeProducedFiles unit cases.
- apps/daemon/tests/runs.test.ts adds replay-cursor coverage for both
the terminal-replay safety branch and the no-duplicate normal branch.
* fix(daemon): persist preTurnFileNames end-to-end on the messages table
Review on #2383 caught that `ChatMessage.preTurnFileNames` (added in
packages/contracts) had no daemon-side persistence: the messages
schema, upsertMessage, and normalizeMessage all ignored the field.
saveMessage() would PUT the field, the daemon would silently drop it,
and a real page reload would read a row without `preTurnFileNames`, so
the reattach onDone fell back to `new Set(nextFiles.map(...))` and
still missed files produced earlier in the turn.
This commit closes the round trip:
- New `pre_turn_file_names_json TEXT` column on the messages table,
with a forward-compatible ALTER for existing databases (same pattern
as agent_id / feedback_json / run_status).
- Both upsertMessage branches (UPDATE and INSERT) now serialize
m.preTurnFileNames into the new column.
- listMessages, the post-upsert readback SELECT, and normalizeMessage
surface the column back to callers.
Round-trip tests in apps/daemon/tests/db-pre-turn-file-names.test.ts
cover: write+listMessages, the UPDATE upsert path preserving the
baseline, and a legacy-row case returning undefined.
* fix(web): preserve terminal status + full multi-file diff on reattach
Two correctness issues caught in review of the prior reattach commits:
1. The reattach onDone path hard-coded `runStatus: 'succeeded'`, which
overwrote a 'failed' or 'canceled' status that the replayed terminal
event had already recorded via onRunStatus. Restored messages would
come back as success even when the run had actually failed or been
canceled. Now derives the final status from `prev.runStatus` via the
existing `resolveSucceededRunStatus` helper, mirroring the send path
at line 2333.
2. When `findExistingArtifactProjectFile()` recovered an existing
on-disk artifact, the produced-files list was replaced with that
single file, dropping any other files the turn had created earlier.
Now always computes the full diff against `preTurnFileNames`, then
appends the recovered artifact only if it isn't already in that
set. Extracted as `mergeRecoveredArtifact(diff, recovered)` so the
logic is a unit-testable invariant.
Tests in ProjectView.reattach-restore.test.tsx:
- mergeRecoveredArtifact: three cases (recovered appended to pre-files,
no duplication when already in the diff, passthrough on no recovery).
- reattach failed-status: onRunStatus('failed') → onDone → final
saveMessage has runStatus 'failed', not 'succeeded'.
- reattach canceled-status: same shape for cancellation.
* fix(web): force keepalive PUT on pagehide so the last buffered chunk survives reload
Review on #2383 caught that onPageHide() only called flush(), which
updates React state then schedules persistSoon() — a 500ms debounce.
On a hard reload the page tears down before that timer fires, so the
final ~250ms of streamed text never reaches the daemon.
Threaded a new flushAndPersistNow() callback through
createBufferedTextUpdates(). Both buffer call sites (send-path +
reattach-path) supply it backed by persistMessageById(id, { keepalive:
true }). saveMessage in state/projects.ts forwards the new
SaveMessageOptions.keepalive flag onto fetch's keepalive option, which
the browser honors specifically for unload-time requests.
onPageHide now calls flush() followed by flushAndPersistNow?.(), so:
- flush() pushes the buffered delta into React state synchronously
- the immediate persistMessageById then PUTs the updated message with
keepalive:true, surviving document teardown
Regression test in ProjectView.reattach-restore.test.tsx: stream a
delta, dispatch pagehide, assert saveMessage was called with the
flushed content AND { keepalive: true } before the 500ms debounce
would otherwise have fired.
PR #2590 wired About-you dropdown selections to fire one
`onboarding/ui_click` per pick (organization_size / use_case /
hear_about_us) with the chosen value attached. Live PostHog data on
nightly.11 showed a real session where every survey value was missing:
the user filled all four dropdowns and clicked Finish setup inside a
~3-second window, the route navigated away before posthog-js could
flush the per-dropdown rows, and the resulting `onboarding_complete_result`
only said `has_about_you: True` without surfacing what the user picked.
Two specific gaps surfaced.
`role` was never tracked at all. `OnboardingDropdown` for role had no
`emitOnboardingClick` call; `OnboardingClickProps` had no `role` field;
`TrackingOnboardingClickElement` had no `'role'` element. Even on a
slow-path session the dashboard could not see what role the user
identified as.
The other three fields (organization_size / use_case / discovery_source)
fired the right click events but were one round-trip away from a route
change. With per-dropdown rows as the only carrier, a Finish-setup
within a ~3-second batch lost them all to navigation-side flush failure.
Fix is two complementary delivery paths plus a closure-staleness repair:
- Contract: `TrackingOnboardingRole` (open-string, like the other
About-you survey types so adding a future role doesn't force a
contract bump); `role` element on `TrackingOnboardingClickElement`;
`role?` and `use_cases?` fields on `OnboardingClickProps`. New
`about_you_submit` click element + survey-snapshot fields
(`role`/`organization_size`/`use_cases`/`discovery_source`) on
`OnboardingCompleteResultProps`.
- EntryShell: emit `role/select_option` on the role dropdown so the
per-pick funnel is symmetric with the other three. Introduce
`profileRef` (live mirror via `useEffect`) so closures that fire
faster than React commits (rapid multi-pick on `use_case`, the
Finish-setup click after the last onChange) read the latest
selection instead of stale render-time state. On Finish setup,
emit a single `about_you_submit/continue` click with the full
survey snapshot BEFORE `continue` + `onboarding_complete_result`,
so the highest-value row is queued first. Mirror the same survey
fields onto `onboarding_complete_result`, giving the dashboard two
independent carriers for the same data — losing either path still
leaves the funnel a complete picture.
`use_case` multi-select also had a stale-closure bug separate from
the navigation issue: the delta computation read `profile.useCase`
which was closure-captured one tick behind the latest pick. Reading
`profileRef.current.useCase` makes the delta correct even when two
picks land in the same commit. Live data from the fix session shows
`use_case` rows firing one-per-pick in real time.
Validation against the live PostHog stream on namespace
`onboarding-survey` (dev build, fresh install via wipe + restart):
09:51:13 role/select_option role=pm
09:51:15 organization_size/select_option organization_size=solo
09:51:16 use_case/select_option use_case=product
09:51:18 hear_about_us/select_option discovery_source=github
09:51:20 about_you_submit/continue role=pm + organization_size=solo
+ use_cases=['product']
+ discovery_source=github
09:51:20 continue/continue
09:51:20 onboarding_complete_result has_about_you=True
role=pm + organization_size=solo
+ use_cases=['product']
+ discovery_source=github
An earlier pre-fix session on the same window (same user, no reload)
shows the original bug: 4 use_case rows, no role, no
`about_you_submit`, no survey fields on complete despite
`has_about_you: True`.
Targets release/v0.8.0.
pnpm --filter @open-design/contracts build green
pnpm --filter @open-design/contracts test 111/111
pnpm --filter @open-design/web typecheck clean
pnpm --filter @open-design/web test 1843/1843
* fix(web): remove Ingest source panel from Automations tab
The Automations tab carried a free-form "Ingest source" composer that
let users paste arbitrary content (URL, repo path, connector event,
chat snippet) and turn it into a source packet plus evolution proposals.
The form was confusing next to the routine/template flow on the same
page, exposed an internal canonicalization concept users don't need to
think about, and shipped before the surrounding evolution-proposal flow
was wired into a coherent end-to-end story.
Drop the UI surface only:
- Remove the <section className="automations-ingest"> block, the
Template / Source / Compression / Connector selects, the title/source
ref/content fields, the recent-packets list, and the Ingest button.
- Drop the now-dead local state (sourcePackets / sourceForm /
ingestingSource), the patchSourceForm and submitSourceIngestion
helpers, the SOURCE_KIND_OPTIONS / COMPRESSION_OPTIONS constants, the
SourceIngestionForm type and DEFAULT_SOURCE_FORM, the
/api/automation-source-packets refresh leg, and the sourcePackets
side-write inside crystallizeRun.
- Remove the matching .automations-ingest / .automation-ingest-* CSS
block (plus the two responsive overrides) from tasks.css.
- Delete the test case that drove the form in TasksView.templates.test.
Backend stays intact: apps/daemon/src/automation-ingestions.ts, the
POST /api/automation-ingestions route, `od automation ingest` CLI, the
routine-evolution call site, and the AutomationContentPacket /
AutomationSourceKind / AutomationTokenCompressionMode contracts all
remain, since routine scheduling still depends on them.
* fix(web): drop crystallize test assertion on removed packet list
The crystallize test was asserting that the new content packet's title
shows up on the page. That assertion only passed because the daemon
response was being side-written into the deleted sourcePackets state
and rendered in the Ingest source recent-packets strip. With that UI
removed, the packet title has no surface to land on; the proposal title
(`Skill: Artifact polish loop run`) is still asserted and remains the
real signal that crystallize succeeded.
* feat(design-files): add directory support and localization for folders in design files panel
* feat(directory-navigation): implement directory navigation and selection reset in DesignFilesPanel
* feat(rename): improve draft handling for file renaming in DesignFilesPanel
The `/html-anything/` landing page was loading 11 screenshots directly
from `raw.githubusercontent.com/nexu-io/html-anything/main/docs/…` —
that's 6.5 MB of raw PNGs per first visit, no AVIF/WebP transcoding,
and a separate cross-origin TLS handshake the rest of the site doesn't
pay. The TODO comment at line 70 of the page has been calling this out
since the page landed.
This PR routes those images through the same pipeline as `hero.png`:
- Source PNGs upload to `static.open-design.ai/landing/assets/html-anything/`
on the existing `open-design-static` R2 bucket. Upload package lives
at `~/Downloads/r2-html-anything-orig/` (README + upload.sh ready);
merge AFTER running that script.
- Page uses `imageAsset('html-anything/<name>.png', { width, quality })`
so CF Image Resizing emits `format=auto` AVIF/WebP variants — same
contract every other landing image already uses.
- Banner is the LCP element; gets `srcset` across 768/1280/1920/2400
(mirroring `heroImageSrcset`) plus its existing `fetchpriority="high"`.
- 10 below-the-fold screenshots become `<LazyImg>` so they pick up the
precise IntersectionObserver (rootMargin 300px) instead of Chrome's
native lazy load that over-prefetches up to 3000px.
Expected on first visit (mobile, AVIF-capable browser):
banner: ~150 KB AVIF (was 2.05 MB PNG)
10 screenshots ~80-120 KB each (was 500-900 KB PNG each)
total ~1.2-1.5 MB (was 6.5 MB)
LCP estimated -800ms to -1.5s