Remove borders, padding, and overflow from the sidebar container so the
NewProjectPanel tab component fills the full sidebar width. Move scroll
responsibility into the tab body so only the content panel scrolls, not
the entire sidebar. Tab buttons sit flush at the top edge.
Co-authored-by: 克隆(宗可龙) <zongkelong@soyoung.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
* feat(daemon+web): add install/uninstall for skills & design systems (#497 Phase 2)
Phase 2 of the Library Settings feature. Adds the ability to install
skills and design systems from GitHub repos or local paths, and
uninstall user-installed items. Built-in items remain read-only.
Daemon:
- Multi-directory scanning: built-in + ~/.open-design/{skills,design-systems}
- Install from GitHub (git clone --depth 1) or local path (symlink)
- Uninstall removes cloned dirs or symlinks under user dir only
- 4 new API routes: POST install + DELETE for skills and design systems
- Reuse isBlocked() from linked-dirs.ts for path safety
Web:
- Install form with GitHub/Local tab switch in Library settings
- Source badge ("Built-in" / "Installed") on cards
- Uninstall button (trash icon) on installed items only
- Native-language i18n for all 17 locales
Contracts:
- Add source field to SkillSummary and DesignSystemSummary
- Add InstallInput, InstallSkillResponse, InstallDesignSystemResponse, UninstallResponse types
* fix(daemon): follow symlinks in scanners and respect OD_DATA_DIR for user libraries
Two fixes from PR review feedback:
1. Scanners (skills.ts, design-systems.ts) used Dirent.isDirectory() which
skips symlinks on macOS/Linux, so locally-installed items never appeared
in API responses. Now also accepts isSymbolicLink() entries.
2. User library directories were hardcoded under os.homedir(), ignoring
OD_DATA_DIR. This broke test isolation and packaged runs. Now derived
from RUNTIME_DATA_DIR so they respect the data dir override.
* add release notes one pager skills and supporting templates
* Update SKILL.md and template.html for release notes: refined upgrade note and enhanced responsive design elements in the template.
* Update SKILL.md to clarify topnav CTA label and href destination requirements, and enhance instructions for handling missing details in release notes.
* Refactor buttons to links in release notes templates and checklist: updated button elements to anchor tags for improved navigation and added explicit checklist items for handling missing sections in release notes.
* Update topnav link labels in SKILL.md and template.html for release notes: changed placeholder links to specific destinations (#added, #fixed, #upgrade-note) for improved navigation clarity.
* Enhance SKILL.md by adding section ID requirements for release notes structure: ensure each section (Added, Fixed, Breaking changes, Known issues, Upgrade note) includes a corresponding `id` attribute for improved navigation and consistency.
* Update example.html for release notes: refine title, update logo text, and modify section summaries to indicate no entries or actions required.
* Add consistent spacing for lists in template.html and update checklist.md to require matching id anchors for release-note sections
* Update SKILL.md, example.html, checklist.md, and layouts.md for improved clarity and structure in release notes
* fix: p2 anchor + p2 dead links
* fix: add id to section block
* fix: replace placeholder URLs with real destinations in SKILL.md, checklist.md, and layouts.md
* fix: update release notes guidelines to omit CTAs without real destinations and ensure no placeholder URLs remain
* fix: clarify CTA handling in release notes and remove placeholder links from template
* fix: add guidelines for using Layout 7 in release notes to remove header CTA row
* fix: add 'release-notes-one-pager' to localized content IDs for French, Russian, and German
* fix: remove 'release-notes-one-pager' from localized content IDs for French, Russian, and German
* fix: increase top padding in modal footer for better visual balance
Fixes#913
The primary action button in modal footers (e.g., Deploy to Cloudflare Pages)
was sitting too close to the top border, making the footer area feel cramped.
Changes:
- Increased top padding from 12px to 16px in .modal-foot
- Kept bottom padding at 12px to maintain existing bottom spacing
- Left and right padding unchanged (uses --modal-padding variable)
This provides better visual balance and makes the primary action button
feel less cramped against the divider line above it.
* fix: add fallback value for --modal-padding variable
The --modal-padding variable is only defined under .modal-settings,
but .modal-foot is also used by generic modals (FileViewer, SketchEditor).
Without a fallback, the padding declaration is invalid for those modals.
This adds a fallback value of 22px to ensure the footer padding works
for all modal types, not just settings modals.
* fix(web): prevent creating new conversation when current is empty
* fix(web): use ref to track loaded conversation for race-free empty check
* fix(web): eagerly update ref on new conversation to prevent rapid-click duplicates
* fix(settings): add install onboarding links for unavailable local CLIs
* fix(settings): rename Claude config dir label to config directory
---------
Co-authored-by: mrcfps <mrc@powerformer.com>
* fix(web): dispatch Examples preview on od.preview.type (#897)
The Examples gallery unconditionally fetched `/api/skills/:id/example`,
and the daemon endpoint only resolves HTML files (`example.html`,
`assets/template.html`, `assets/index.html`, `examples/*.html`). Skills
that declare `od.preview.type: image` (`hatch-pet`) or
`od.preview.type: markdown` (`dcf-valuation`, `last30days`,
`x-research`) ship no such HTML — the fetch returns 404 and the modal
landed on the misleading "Couldn't load this example. The example HTML
failed to fetch." copy.
Dispatch on `previewType` at the data layer (`fetchSkillExample`) and
at the render layer (`PreviewModal`):
- `fetchSkillExample(id, previewType)` short-circuits any non-`html`
value to `{ unavailable: true, kind }` without firing a network call.
- `PreviewView` grows an optional `unavailable: { kind }` shape; the
modal renders a calm "no shipped preview" placeholder distinct from
loading and error states. The Share menu disables (no HTML to export).
- `ExamplesTab` tracks `previewUnavailable` per skill alongside the
existing `previews` / `previewErrors` maps; the card placeholder
swaps to "open to learn more" copy so users don't hover waiting for
a render that won't come.
- New `preview.unavailableTitle` / `preview.unavailableBody` and
`examples.unavailablePlaceholder` / `examples.shareUnavailable` keys
shipped across all 17 locales. Body copy uses the raw preview kind
(`{kind}` placeholder) so future kinds slot in without a copy change.
Tests: registry-level coverage that the dispatch never hits the network
for non-html types; PreviewModal-level coverage that the unavailable
affordance is mutually exclusive with loading/error and disables the
Share menu; ExamplesTab-level coverage that the gallery renders the
unavailable state for image/markdown skills and routes html skills
through the existing fetch path. Updated the existing `#860` retry
regression test for the new two-arg signature.
* fix(web): use neutral noun for preview.unavailableBody copy (#1001 review)
P3 from lefarcen: `'a {kind} document'` reads awkwardly when `{kind}`
is `image` ("a image document") and the article disagreement
undermined the PR body's claim that future kinds slot in without
copy changes. Drop the article and replace `document` with a more
neutral noun (`output` / `resultat` / `产物` / `出力` / etc.) so
every kind reads naturally:
- `produces {kind} output` (English)
- `produit un résultat {kind}` (French)
- `生成 {kind} 产物` (Simplified Chinese)
- … and 14 more
`{kind}` placeholder stays literal in every locale; surrounding
vocabulary for skill / prompt / chat preserved per existing
file conventions.
* fix: fix link handling in example preview iframe sandbox
* fix: added missing single quote
* fix(srcdoc): security/correctness issues
- Use location.hash = href for hash-link navigation so hashchange
events, CSS :target, history/back and named anchors work correctly
- Always pass 'noopener,noreferrer' to window.open() for _blank links
- Guard e.target with instanceof Element before calling .closest() to
avoid TypeError on text node click targets
* fix(srcdoc): security/correctness issues
- Added a protocol allowlist
- Re-navigation to same hash
- Consistency with rest of shim IIFE using var instead of let and const
* fix(srcdoc): scroll to top on bare hash links (#)
Intercept href="#" clicks and scroll to top of page in the iframe sandbox to mimic native browser behaviour.
* test(web): update PreviewModal sandbox test assertions to include popup flags
Switching to a different settings section (e.g. clicking About after
scrolling Library) kept the previous scrollTop on the right-hand
content panel, so the new section's heading often landed below the
fold and the panel read as half-loaded. The complaint in #634 was
specifically about reaching About from a sidebar position near the
bottom and finding the About content not fully visible.
Add a ref on .settings-content and a useEffect that resets scrollTop
to 0 whenever activeSection changes. The sidebar's own scroll position
is left alone, so users who scrolled to reach a tab still see that tab
in view after clicking it.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
- Add 31 typed i18n keys in types.ts for MCP settings strings
- Add English values in en.ts (source of truth)
- Add zh-CN and zh-TW translations
- Move MCP_CLIENTS from global const into IntegrationsSection
so buildMethod/buildInstruction/deeplinkLabel can use t()
- Wire all JSX strings in IntegrationsSection through useI18n
- Change deeplinkLabel from string to () => string to support
dynamic translation
Command/config snippets and product names remain untranslated
per maintainer guidance.
Fixes#745
* fix(web): restore media config from daemon on startup
* fix(media): preserve stored keys on settings save
* fix(web): harden daemon media restore flow
* fix(web): unify media provider empty-state rules
* fix(desktop): retry loading discovered web url
* fix(web): preserve local media providers on partial daemon reload
* fix(web): preserve media providers on daemon reload
* fix(web): skip media migration for masked-only local state
* fix(web): preserve daemon media state across reloads
* feat: add scheduled routines for unattended agent runs
Generalizes Orbit's single hard-coded daily-digest scheduler into
user-defined routines: each one fires on a schedule (hourly / daily /
weekdays / weekly with IANA timezone) and starts a fresh agent
conversation, either inside an existing project or in a new project
minted on the spot.
Backend:
- New RoutineService with timezone-aware nextRunAt computed via
Intl.DateTimeFormat (no new dependency); two-pass tzWallToUtc so
DST transitions stay correct. Each fire chains rescheduleOne in
finally() to keep the cadence alive.
- routines + routine_runs SQLite tables; schedule_json is the
authoritative form, with legacy schedule_kind/value kept populated.
- /api/routines CRUD + /api/routines/:id/run + /api/routines/:id/runs.
- Run handler resolves agent (routine override -> app config -> first
available), creates project (or reuses configured one) and a fresh
conversation per fire, then dispatches into startChatRun.
UI (Settings -> Routines):
- Pill-chip schedule kind picker, time + timezone fields, weekday
picker for Weekly. Live preview line ("Runs daily at 9:00 AM GMT+8").
- Routine list with inline status pill, next/last meta, expandable run
history; each history row links into the project the run wrote to
via the existing router primitive.
* fix(daemon): swallow trailing finally rejection for inflight cleanup
Without a terminal `.catch`, the promise returned by `promise.finally(...)`
mirrors the original rejection and produces an unhandled rejection — fatal
in modern Node — when the run handler rejects before producing a start
handle. Callers still see the rejection on the returned `promise`.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
* fix(daemon): handle DST spring-forward gap in tzWallToUtc
The two-pass conversion picked the pre-gap candidate when the requested
wall time fell inside a spring-forward gap (e.g. 02:30 in
America/New_York on 2026-03-08), so the resulting instant rendered back
as 01:30 local and a 02:30 routine fired an hour early on the
transition day. Routines are local wall-clock schedules, so firing
before the requested time breaks the contract.
Now we round-trip both candidates through partsInTimezone, return the
one whose wall-clock matches the request, and on a gap day where
neither matches return the later candidate so the routine fires at the
first valid post-gap instant on the same day.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
* fix(daemon): preserve both wall-time candidates on DST fall-back day
On a fall-back day, the requested wall time inside the repeated hour
(e.g. 01:30 America/New_York) maps to two distinct UTC instants. The
previous tzWallToUtc collapsed them to the first (pre-transition) one,
so a daemon that woke between the two instants would skip the second
01:30 entirely and fire a day late once per fall-back. Replace it with
tzWallToUtcCandidates (returns all valid instants, ascending) plus a
gap-only fallback for spring-forward, and have nextWallTimeMatching
walk both ambiguous candidates before advancing to the next day. Adds
fixtures for the repeated-hour case so the intended behavior stays
locked in.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
* fix(web): make routine timezone picker IANA-complete and DST-truthful
The timezone dropdown was a hardcoded subset, but the backend validator
accepts any IANA zone — so users could not pick zones like
`America/Phoenix` or `Africa/Johannesburg` unless they happened to be
local. And `gmtLabel()` always derived the offset from `new Date()`,
which drifted seasonally for DST-observing zones (a New York routine
created in winter rendered `GMT-5` while it would actually fire on
`GMT-4` after DST started).
Source the picker from `Intl.supportedValuesOf('timeZone')` (with a
curated fallback for older runtimes) and anchor the GMT label to the
routine's next fire time. When the next fire time is unknown (e.g.
the live preview while the form is open) and in the dropdown itself,
fall back to the IANA city, which is stable year-round.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
* fix(web): always include UTC in routine timezone picker
`Intl.supportedValuesOf('timeZone')` returns only canonical region
names on current runtimes (Node 24, recent browsers) and omits `UTC`,
so the previous picker dropped the most common non-local zone unless
the runtime itself was already UTC. The backend validator and the
contract examples still accept `UTC`, so a user on a non-UTC machine
could not create a documented UTC routine from Settings.
Prepend `UTC` inside `listSupportedTimezones()` when the runtime list
omits it, so the picker stays aligned with the supported schedule
surface.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
* feat(web): scroll question forms to top of viewport instead of pinning to bottom
* fix: reset scroll state on composer send after form has claimed control
* fix: use getBoundingClientRect for form scroll position; guard early-return on streaming
* fix: smooth scroll for all chat-scroll operations; polyfill scrollTo for jsdom tests
* fix: revert streaming bottom-pin to instant to avoid scroll event thrash
* fix: revert initial-load bottom-pin to instant to avoid scroll event thrash
Bundle four pending template skills and retag eight related skills to video/hyperframes so the categorization and i18n fallback coverage can be reviewed and merged in one pass.
Co-authored-by: Tuola Ge <gexingli@refly.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce a new Swiss editorial template skill with interactive multi-scene HTML seed files and add DE/FR/RU fallback coverage so localized-content validation stays green.
Co-authored-by: tuolaji <tuola@tuolajideMacBook-Air.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add a burgundy editorial live-artifact template with a headline metric slide, studio keyword cloud, and eight-principles card grid. Include DE/FR/RU fallback ids so localized-content coverage stays green.
Co-authored-by: Tuola Ge <gexingli@refly.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce a Swiss editorial user-research live-artifact template with self-contained HTML seed/example and checklist, and register i18n fallback ids for DE/FR/RU so localized coverage stays green.
Co-authored-by: Tuola Ge <gexingli@refly.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add a new dark editorial HyperFrames template skill with seed/example/checklist assets and register the skill id in DE/FR/RU i18n fallback lists so localized-content validation stays green.
Co-authored-by: Tuola Ge <gexingli@refly.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): redesign top bar — lift Share/Present, add zoom dropdown, move focus toggle
- AppChromeHeader: add #app-chrome-file-actions portal anchor so file viewers
can render their primary actions (Present/Share) up in the project chrome
instead of cramming a second toolbar row.
- HtmlFileViewer / LiveArtifactViewer: portal Present + Share into the top
bar via createPortal; Share gets a real chrome-action-primary button.
- HtmlFileViewer: replace the 100% reset button with a zoom dropdown
(50/75/100/125/150/200) with click-outside + Esc handling.
- HtmlFileViewer: move Preview/Source tabs next to Reload (left side, view
modes); move Tweaks to the right cluster next to Inspect/Edit.
- HtmlFileViewer: showPresent no longer requires deck — any HTML artifact
with loaded source can be presented (prototype/slide/regular HTML).
- LiveArtifactViewer: add Present (in-tab/fullscreen/new-tab) with iframe
ref + previewBodyRef wrapper; in-tab present hides chrome and overlays an
exit button (Esc also exits).
- ChatPane: add chevron-left collapse icon in chat header (onCollapse prop)
so users can hide the chat from where it lives.
- FileWorkspace: focus toggle is now icon-only and only renders when chat is
collapsed, sitting on the LEFT of the workspace tabs row as a chevron-right
expand button — direction matches where chat re-emerges from.
- index.css: add chrome-action-primary/secondary, zoom-menu, present-exit-btn,
app-chrome-file-actions styling, plus a narrow-width media query that
collapses secondary action labels.
* fix(web): tests — fall back to inline render when chrome portal anchor missing
The Share/Present primary actions render via createPortal into
#app-chrome-file-actions, which only exists when AppChromeHeader has
mounted. Vitest renders FileViewer / LiveArtifactViewer in isolation,
so the portal anchor was absent and the buttons disappeared from the
test DOM, breaking 7 share-menu tests.
- HtmlFileViewer / LiveArtifactViewer: when chromeActionsHost is null,
render the present/share JSX inline instead of returning null. UX is
identical in production (host is always present); tests now find the
buttons without needing a portal-aware harness.
- FileWorkspace tests: rewrite the "focus toggle in tab bar" assertions
to reflect the new design — the toggle lives in ChatPane while the
chat is open, and FileWorkspace only renders an icon-only expand
button on the LEFT of the tab bar once chat is collapsed.
* feat: add Ollama Cloud to KNOWN_PROVIDERS as OpenAI-compatible BYOK provider
* feat: add ollama.com to isOpenAICompatible base URL detection
* feat: add Ollama Cloud models to SUGGESTED_MODELS_BY_PROTOCOL fallback list
* fix: use full Ollama Cloud model list from /api/tags, drop -cloud suffix
* feat: add Ollama Cloud as native protocol with NDJSON streaming and connection test support
* fix: remove ollama.com from OpenAI compatibility check
* feat: add token overrides for Ollama Cloud models to prevent truncation
* fix: extend inferApiProtocol and legacy migration to recognize ollama.com base URLs
* fix: normalize Ollama Cloud base URL by stripping /api suffix during migration and in daemon
---------
Co-authored-by: herediaron <aronheredi346@gmail.com>
When the agent emits an HTML artifact with no `data-od-id` /
`data-screen-label` annotations (a freeform PRD → HTML pass through a
Claude-Code-compatible CLI without going through a skill, for
example), the existing Inspect / Picker affordances no-oped silently:
- The bridge's click handler walks up to <html>, finds nothing tagged,
and bails before emitting `od:comment-target` — by design, since
posting a synthetic id here would change save-to-source semantics
for inspect overrides (the persisted CSS keys off the same
elementId).
- The host then sat at "Click any element with `data-od-id` to tune
its style" — phrased as if the user just hadn't found the right
element, when the page in fact had nothing matching at all.
- Picker mode (Tweaks → Picker) had no hint at all.
The bridge already broadcasts `od:comment-targets` with the full list
on every mode toggle and DOM mutation, but the host's existing
listener was gated on `boardMode` only — Inspect mode never learned
the artifact's annotation count.
Two surgical fixes:
1. `FileViewer.tsx`: a dedicated `od:comment-targets` listener that
installs whenever Inspect OR Comments mode is active, mirroring
the bridge's broadcast into `liveCommentTargets`. The
comment-mode-only listener still owns its hover / click / pod
events; this new listener only handles the targets list.
2. `FileViewer.tsx`: the inspect-empty-hint banner now dispatches on
`liveCommentTargets.size === 0`. Empty: a clear "this artifact has
no `data-od-id` annotations yet — ask the agent to add them"
message that names the missing attribute. Populated: existing
instructive copy. Mirrored across Inspect and Picker modes so the
failure surface gives the same calibration signal in both.
Tests:
- `tests/runtime/srcdoc-bridge-empty-targets.test.ts` (3 cases): pin
the bridge contract this fix depends on. Run the IIFE in jsdom and
assert (a) `allTargets()` posts an empty list for unannotated DOM,
(b) clicks on unannotated elements do NOT post `od:comment-target`
(regression pin against future "synthetic id" fallbacks that would
silently change save-to-source semantics), (c) clicks DO still
resolve to an annotated ancestor when one exists.
- `tests/components/FileViewer.inspect-empty-hint.test.tsx` (3
cases): pin the host dispatch — empty state in Inspect mode, the
switch back to instructive copy when targets show up, and the
mirrored affordance in Picker mode.
Out of scope (flagged in the design comment so it isn't lost):
- The follow-up scenario from #890 ("parent has data-od-id, target
child does not → adjustments hit the parent") is a different bug
that needs either synthetic-id fallback or a UI affordance to
descend into the click target. Leaving that to a follow-up so this
PR stays narrow.
- i18n: the existing inspect-empty-hint copy is hardcoded English;
rolling it into the 17-locale Dict is a separate cleanup.
* fix(orbit): scope last run to selected template
* fix(orbit): preserve legacy last run on upgrade
* fix(orbit): pin legacy last-run fallback on refresh
* fix(orbit): pin template id at run start
* test(web): sync orbit fixtures with skill summary
The upload-failure banner in FileWorkspace renders unconditionally inside
ws-body, so a failed upload's error message stayed pinned above any file
the user opened next. The banner is information about the Design Files
upload surface, not the active file viewer; once the user clicks an
existing file in the list, the message becomes unrelated stale state.
Scope the banner to activeTab === DESIGN_FILES_TAB so previewing any
file hides it. The error stays in state, so returning to the Design
Files tab brings it back: the failed upload is still unaddressed and we
do not want to silently drop that signal entirely.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
* docs(specs): add langfuse telemetry change spec
Captures the design for forwarding completed agent runs to Langfuse,
including data-model mapping, field-budget caps, privacy gates,
build-secret injection, GDPR right-to-deletion approach, and the
resolved decisions on default consent, identifier shape, region, and
ownership.
* feat(daemon): add langfuse-trace module and telemetry prefs
Adds the dependency-free building blocks for forwarding completed
agent runs to Langfuse. Two layers:
- AppConfigPrefs gains installationId and a TelemetryPrefs object with
metrics / content / artifactManifest gates. The daemon validator
treats telemetry like agentModels — replace-on-write, drop-when-empty,
reject non-boolean inner values.
- New langfuse-trace.ts builds a {trace-create, generation-create}
pair from a ReportContext, capping prompt at 8 KB, output at 16 KB,
artifacts at 50 entries, and dropping any batch larger than 1 MB
before send. reportRunCompleted is no-op when LANGFUSE_PUBLIC_KEY /
LANGFUSE_SECRET_KEY are unset (so dev runs and forks never emit) and
short-circuits on prefs.metrics === false.
Server-side wiring into the run-close path lands in a follow-up.
* fix(langfuse): default to US Langfuse region
End-to-end smoke against the project's actual dev key on 2026-05-07
returned 401 from cloud.langfuse.com (EU) and 207 from
us.cloud.langfuse.com (US), confirming the org lives in US. Update the
default base URL, the matching test, and the spec's Q3 decision row to
match. Self-hosted or EU-region operators can still override via the
LANGFUSE_BASE_URL env var.
* feat(daemon): wire langfuse trace forwarding into run-close
Adds the daemon-side glue to forward completed agent runs:
- runs.ts gains an optional onTerminate hook fired once per run after it
reaches a terminal state. Errors thrown from the hook are caught and
logged, never propagated, so telemetry can never break the run path.
- New langfuse-bridge.ts assembles a ReportContext from the in-memory
run record, the conversation's persisted assistant message, and the
user's app-config preferences. It tolerates a missing message (e.g.
when web has not yet PUT the final delta) and a missing app-config.
- server.ts stashes the original user prompt on the run object inside
startChatRun so the bridge can include it without crossing the
createChatRunService boundary, and registers the hook callback when
building the run service.
Behavior remains a no-op unless LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY
are set in the daemon env AND telemetry.metrics is true in app-config.
A live smoke against us.cloud.langfuse.com on 2026-05-07 confirmed the
matching trace + generation schema is accepted (HTTP 207, both events
201 created).
* fix(langfuse): address PR #800 review feedback
P1 — Move trace forwarding off the daemon-internal run-close hook and
onto the message-persistence path. The original onTerminate hook ran
inside finish() the moment the SSE 'end' event was emitted, which is
*before* the web client's onDone handler refreshes project files and
PUTs producedFiles + final assistant content back to SQLite. Reading
SQLite at that moment routinely missed both. The fix: drop the runs.ts
hook entirely and trigger from PUT /api/projects/:id/conversations/:cid/
messages/:mid when the saved row carries a terminal runStatus. A
reportedRuns Set guards against the multiple PUT calls web makes per
turn (each retry / state update). Set entries auto-evict after the same
30 min TTL the runs map uses. Web persists a terminal-status message in
all three completion paths — onDone (succeeded), onError (failed), and
cancel (canceled) — so this catches every run shape.
P2 — postLangfuseBatch now parses the 207 Multi-Status response body.
Langfuse legacy ingestion always returns 207, and response.ok is true
for 207, so per-event validation errors used to slip through silently.
We now warn when body.errors is non-empty. Two new unit tests.
P2 — truncate() and the HARD_BATCH cap now compare UTF-8 byte length,
not String.length (which counts UTF-16 code units). A 4096-character
CJK prompt occupies 12 KB, well over the 8 KB input cap. truncate also
walks backwards to a UTF-8 leading byte so the cut never lands inside a
multi-byte codepoint. New unit test covers '设'.repeat(4096).
P2 — Spec R7 now lists the actual Langfuse trace deletion endpoint
(DELETE /api/public/traces/{traceId} for single, DELETE /api/public/traces
with body for batch). Verified by curl on us.cloud.langfuse.com:
DELETE /api/public/traces/X → 200; the path the original spec named
(POST /api/public/trace/X) returns 404. Reference link points at
langfuse.com/docs/administration/data-deletion.
P3 — Q4 (legacy ingestion vs OTel) moved from Open Questions to
Resolved Decisions. The implementation already commits to legacy and
the trade-off was discussed during design; the open-question status was
stale.
* feat(web): privacy consent surface + Settings → Privacy tab
Adds the user-facing half of the telemetry feature so the daemon-side
hook from PR #800 has something to talk to.
- AppConfig gains optional `installationId` (anonymous v4 uuid generated
on first opt-in; null after explicit decline; undefined when the user
has never seen the consent surface) and `telemetry: TelemetryConfig`
({metrics, content, artifactManifest}). syncConfigToDaemon round-trips
both fields so the bridge module sees the same prefs.
- SettingsDialog grows a Privacy section with two states. When the user
has never made a consent decision (typical first-run path), the
section renders the GDPR-aligned consent card: a kicker, the disclosure
body listing both metrics and conversation content as separate bullets,
and two equally-prominent buttons ("Share usage data" / "Don't share").
The Don't-share path keeps the app fully usable (core app must work
with all tracking declined). After a decision the same panel switches
to three independent toggles + the anonymous ID + a "Delete my data"
button that rotates the ID and turns everything off.
- App.tsx points the welcome modal at the new Privacy section so the
consent decision is the first thing a fresh installation sees.
- 17 i18n keys land in en + zh-CN + zh-TW with hand-translated copy,
and as English placeholders in the remaining 14 locales — enough for
the parity check to pass while leaving room for proper localisation
in a follow-up. Dict type updated.
- Minimal index.css for the consent card + toggle rows so the panel is
legible without depending on follow-up design polish.
Telemetry remains a no-op end-to-end until the user clicks Share usage
data: the daemon gate (prefs.metrics === true) keeps every code path
short-circuited otherwise.
* refactor(web): rebuild Privacy panel using project-native settings primitives
The first cut used custom .settings-privacy-* classes + raw HTML
checkboxes that didn't match any other Settings tab. Replace with the
shell other sections already use:
- settings-subsection containers with section-head + h4 + .hint
- seg-control / seg-btn pill toggles ("active" / "offline") for each of
the three telemetry preferences, mirroring NotificationsSection
- a 2-cell seg-control for the consent card so Share usage data and
Don't share carry identical visual weight (the GDPR equal-prominence
requirement that the previous accent / outline split missed)
- ghost button + readonly text input for the installation id row,
mirroring the API-key field pattern elsewhere
Drop the bespoke CSS block in favor of inheriting the existing
settings-section / seg-control / ghost styling. The only privacy-
specific style left is a tight definition list inside the consent card
for the metrics + content disclosure rows.
* refactor(web): use .toggle-row iOS switch for Privacy preferences
Active/offline pills (the seg-control single-cell pattern that
NotificationsSection uses) read awkwardly for a flat preference list.
Switch the three telemetry toggles to .toggle-row — the same control
NewProjectPanel uses for "speaker notes" / "animations": label + hint
on the left, iOS-style sliding switch on the right, full-row click
target. The consent card's two-button seg-control stays as-is — there
the equal-weight pill pair is exactly what GDPR equal-prominence wants.
* feat(web): standalone first-run privacy consent banner
Replaces the Settings-dialog-as-onboarding hack with a dedicated
bottom-right banner card that mounts whenever the user has never made
a privacy decision (cfg.installationId === undefined). The banner is
prominent (anchored to the corner with a soft shadow) but
non-blocking, mirrors cookie-consent UX, and shares the project's
panel styling — same .modal-elevated background, --radius-lg corners,
--shadow-lg lift.
Wiring:
- App.tsx imports PrivacyConsentModal and renders it at the root,
gated on installationId === undefined && !settingsOpen so it doesn't
double up with the Privacy tab's own consent card when Settings is
already showing.
- Share / Don't share both go through handleConfigPersist, so the
resulting installationId + telemetry prefs land in localStorage and
the daemon at the same time, reusing the existing autosave plumbing.
- The previous attempt that pinned the welcome SettingsDialog to the
Privacy section is reverted; onboarding now stays focused on agent
configuration, and the consent decision lives in its own surface.
* fix(web): keep privacy banner visible while Settings welcome modal is open
The banner gated itself on `!settingsOpen` to avoid double-rendering
with the Privacy tab's consent card. But the first-run path opens the
Settings welcome modal automatically when `onboardingCompleted=false`,
which fired immediately after bootstrap — so the banner flashed for a
moment and then vanished behind the modal backdrop.
Drop the `!settingsOpen` clause so the banner stays mounted whenever
the user has not yet made a privacy decision, and bump its z-index
above the modal backdrop (200 vs 100) so first-run users can actually
reach the consent buttons. The minor visual overlap with the Privacy
tab's own card is fine: clicking either copy resolves both surfaces.
* copy(privacy): soften consent button labels
Banner action buttons now read "Help improve Open Design" / "Not now"
(en, with hand translations in zh-CN / zh-TW and English placeholders
in the other 13 locales) instead of "Share usage data" / "Don't share".
The new wording aligns the affirmative action with the kicker copy
("Help us improve Open Design") and reads less alarming, while the
disclosure list above still names both data categories explicitly so
the consent stays informed under GDPR. The decline button stays as a
soft "Not now" rather than an aggressive "Don't share" so the reject
path doesn't read as hostile to the user.
No structural change — the two-cell seg-control still gives the buttons
identical visual weight, and the underlying side-effects are unchanged
(installationId is generated on Help / nulled on Not now, and the
telemetry prefs flip the same way).
* feat(telemetry): expand trace fields for evals & dataset construction
Each Langfuse trace now ships the full per-turn + per-install fact
sheet that the eval/dataset workflow needs, instead of only the bare
turn id + token count from before. Everything below is gated by
`prefs.metrics === true`; nothing here is content (those gates remain
separate).
Per-turn:
- model — first-class generation.model field, drives Langfuse cost
lookup and model-grouping in the UI; also mirrored in trace.metadata
and trace.tags so list-view filters work.
- reasoning — generation.modelParameters.{ reasoning } so the Model
Parameters card lights up; mirrored in metadata.
- skillId / designSystemId — metadata + tags, so dataset slices can
group by which skill/DS produced which output.
Per-process / build (constant within one daemon run, cached at start):
- appVersion / appChannel / packaged from app-version.ts
- nodeVersion (process.version), os (platform()), osRelease,
arch (os.arch())
- clientType — desktop vs web, derived from a new X-OD-Client header
the web layer sets in providers/daemon.ts (with a User-Agent sniff
fallback for third-party callers).
Plumbing:
- startChatRun stashes model / reasoning / skillId / designSystemId
on the run object alongside the existing userPrompt stash.
- POST /api/runs reads X-OD-Client and stores run.clientType.
- langfuse-bridge collects RuntimeInfo once per process and merges
per-run client carrier; ReportContext gains optional `turn` +
`runtime` blocks; existing fields stay backward compatible.
Spec gains a "Telemetry Fields Catalog" section enumerating every
field, its source, and the gate it lives under, so the eval team has a
single place to look up what's available without reading the trace
schema by example.
Tests:
- new langfuse-trace tests cover turn tags, runtime tags, generation
model/modelParameters promotion, modelParameters omission when
reasoning is unset, and metadata mirroring.
- langfuse-bridge gains an end-to-end "turn-level config" test that
threads model/reasoning/skill/DS/clientType + appVersion through
the bridge and asserts the Langfuse payload shape.
- existing tests adjusted to tolerate host-dependent os tag.
* copy(privacy): trim Share button to verb phrase only
"Help improve Open Design" overflowed the equal-width 2-cell
seg-control on the consent banner — the product name is already in
the kicker + headline above the buttons, so the button itself only
needs the verb phrase. Drop the product name from all locales:
- en: Help improve Open Design → Help improve
- zh-CN: 帮助改进 Open Design → 帮助改进
- zh-TW: 協助改進 Open Design → 協助改進
The decline button ("Not now" / "暂不" / "暫不") was already short, so
the two buttons now have comparable length and the equal-prominence
seg-control fits cleanly. Standalone Settings → Privacy panel uses
the same labels for consistency.
* fix(web): defer Settings welcome modal until privacy decision is made
Previously bootstrap raced two surfaces against each other on first
launch: the privacy consent banner (gated on installationId ===
undefined) and the Settings welcome modal (gated on
onboardingCompleted === false). The banner's higher z-index kept it
above the backdrop visually, but having two foreground surfaces at
once is still confusing UX.
Sequence them instead: bootstrap only opens the welcome modal when
the user has *already* resolved consent (installationId !== undefined).
Until then the banner owns the foreground alone. Once the user clicks
Help improve / Not now, the corresponding handler hands off to the
welcome modal if onboarding is still pending. End state matches what
it was before — just without the simultaneous-render flash.
* debug(privacy): log banner gate state to track sudden disappearance
Two console.log points to find which setCfg call (or stale bundle) is
flipping cfg.installationId from undefined to a value while the banner
is visible. To remove once the regression is reproduced.
* fix(privacy): keep installationId + telemetry out of localStorage
Daemon is now the single source of truth for the privacy decision.
Why this matters: the consent banner gates on
\`config.installationId === undefined\`, but loadConfig() merges
localStorage on top of the daemon's reply, so a stale uuid in
\`open-design:config\` (left over from a previous opt-in) was
re-hydrating the React state and immediately syncing back to the
daemon — defeating "Delete my data" and re-suppressing the banner
within milliseconds of every page load.
The deeper reason to fix it here, not just patch the gate: a privacy
identifier persisted in browser storage that the user can't see or
clear without DevTools is a compliance liability. Anything users can
revoke needs one canonical place to store it. Daemon \`app-config.json\`
already serves that role for everything else gated through
syncConfigToDaemon, so installationId + telemetry now ride that path
exclusively:
- saveConfig() strips both keys before writing localStorage.
- loadConfig() strips both keys when reading older stale payloads,
so existing installs migrate transparently on next launch.
- syncConfigToDaemon() / mergeDaemonConfig still round-trip them, so
the React state stays in sync with the daemon as before.
Net effect: clearing app-config.json (or hitting "Delete my data") now
fully resets the install identity, with no residual cohort key in
browser storage.
* feat(privacy): scrub secrets + PII from prompt/output before send
When prefs.content is on, daemon now runs the prompt and assistant
text through a regex scrubber (apps/daemon/src/redact.ts) before
posting to Langfuse. The scrubber is the simplest thing that gives
the user-facing copy a truthful claim — pure regex, zero new
dependencies, fully auditable in this Apache-2.0 repo (vs. pulling a
single-maintainer 5-month-old npm package into a core process).
Categories covered (each replaced with [REDACTED:<kind>]):
- Anthropic / OpenAI sk- keys (incl. proj/live/test/ant variants)
- Langfuse pk-lf- / sk-lf- (specific rule wins over generic sk-)
- GitHub gh[opsur]_ tokens
- AWS access key ids (AKIA + 16 uppercase)
- Google API keys (AIza + 35)
- Slack xox[abprs]- tokens
- Stripe live/test keys
- JWT header.payload.signature triples
- Bearer-header values (scheme word stays readable)
- Emails, IPv4, US-style phone numbers
- Credit cards — 13–19 digit runs that pass a Luhn check, so order ids
and unix-nanos timestamps that fail Luhn pass through unchanged
Not covered, stated openly in spec + i18n: names, postal addresses,
business-secret semantics, raw 40-hex tokens (too high a false-positive
cost for artifact slugs). Those would require an ML layer.
Wired in:
- apps/daemon/src/redact.ts — exports redactSecrets() +
redactSecretsWithCounts() helper for future audit-summary metadata.
- apps/daemon/src/langfuse-bridge.ts — runs both prompt and output
through redactSecrets() before they reach the trace builder.
- 18 unit tests cover every pattern plus negative cases (Luhn-failing
digit runs, out-of-range IPv4 octets, idempotence on re-redacted
text, ordinary prose passthrough).
- i18n privacyContentHint on en + zh-CN + zh-TW (plus 14 locale
placeholders) enumerates the categories so the consent disclosure
matches the implementation — the GDPR informed-consent requirement.
- spec gains a Pre-send Redaction subsection with the regex shape
table + intentional non-coverage list.
Drive-by: dropped the [privacy] debug logs that traced the now-fixed
bootstrap regression.
* fix(telemetry): make Langfuse reporting resilient
* feat(telemetry): nest Langfuse turn observations
* feat(telemetry): emit Langfuse tool spans
* fix(telemetry): report after finalized message writes
* fix(telemetry): honor persisted terminal status
* fix(web): let consent banner yield page clicks
* fix(telemetry): report current turn prompt only
The board comment popover already short-circuits Save comment for
selectionKind === 'pod' by setting disabled=true, so the button shows
up greyed-out alongside Add note and Send to chat. That looks like a
disabled-because-of-input-state action even though the operation is
not supported on Pods at all, which is the same misleading "shown but
not real" pattern issue #792 asked us to clean up.
Drop the rendering entirely when selectionKind === 'pod' so the
footer collapses to Add note + Send to chat (which are the actual
Pods affordances) and the disabled prop only carries the draft-empty
constraint for non-Pods selections.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
* feat(design-systems): add Slack design system
* fix(i18n): cover slack design system in de/ru/fr fallback lists
The localized-content coverage test scans design-systems/*/DESIGN.md
and asserts every id appears in each locale's designSystems list.
Adding the slack design system without a locale fallback entry breaks
de/ru/fr CI.
---------
Co-authored-by: lefarcen <935902669@qq.com>
* Disclaimer: Changes made using OpenCode with Big Pickle, an AI code assistant.
1. Removed the ↑ button from DesignFilesPanel.tsx (was a "back" button that
closed the preview pane — confusingly placed in the header as if it were for
sorting).
2. Converted the file list to a single <table> with sortable columns:
- Replaced the section-based grouping (Pages, Sketches, Scripts, Images,
Other) with a flat, sortable table
- Added column headers: Name, Kind, Modified — all clickable to toggle
sort direction
- Default sort is by modified time descending (same as previous behavior)
- Sort indicator arrows (↑/↓) show the active sort column and direction
- Live artifacts remain as a separate section above the table
3. Added i18n keys designFiles.colName, designFiles.colKind, designFiles.
colModified to all 17 locale files and the type definitions.
4. Updated CSS with table layout styles (.df-table, .df-file-row, column
width classes, sortable header styles).
Files modified:
- apps/web/src/components/DesignFilesPanel.tsx
- apps/web/src/index.css
- apps/web/src/i18n/types.ts
- apps/web/src/i18n/locales/en.ts
- apps/web/src/i18n/locales/*.ts (all 16 other locale files)
* Updated to preserve keyboard access to sorting
* Fixed keyboard to focus/activate/launch file from Design Files list. Single space bar will show preview, double spare bar will open the file as a tab
* Top pagination bar (above the table):
- "Show" dropdown with options 15, 30 (default), 45, 60, All
- Page range indicator (1–20 of 45)
- Previous / Next buttons
Bottom pagination bar (below the table):
- Previous / Next buttons
- "Go to page" dropdown listing all page numbers
- Same page range indicator
Implementation details:
- All controls use native <select> and <button> elements — fully keyboard
accessible (Tab, arrow keys, Enter/Space)
- Page resets to 0 when page size changes
- safePage clamps to valid bounds when file count changes (e.g. after delete)
- "All" sets page size to total file count (effectively one page)
- Prev/Next buttons show disabled state at boundaries with reduced opacity
* All 46 test files, all 385 tests pass. Here's what the regression test covers:
┌────────────────────┬──────────────────────────────────────────────────────┐
│Test │What it verifies │
├────────────────────┼──────────────────────────────────────────────────────┤
│default page size │500 files → only 30 .df-file-row elements in DOM │
├────────────────────┼──────────────────────────────────────────────────────┤
│page size All │changing per-page to "All" shows all 500 rows │
├────────────────────┼──────────────────────────────────────────────────────┤
│page size 60 │changing to 60 shows 60 rows │
├────────────────────┼──────────────────────────────────────────────────────┤
│Next navigation │clicking Next advances page and shows file-31 (sorted │
│ │by mtime desc) │
├────────────────────┼──────────────────────────────────────────────────────┤
│Prev/Next disabled │Prev disabled on page 0, Next disabled on last page │
│states │ │
├────────────────────┼──────────────────────────────────────────────────────┤
│jump to page │bottom dropdown jumps to page 3 (shows file-91) │
├────────────────────┼──────────────────────────────────────────────────────┤
│page info text │1–30 of 500 → after Next → 31–60 of 500 │
├────────────────────┼──────────────────────────────────────────────────────┤
│render time │renders 500 files in under 2s │
└────────────────────┴──────────────────────────────────────────────────────┘
* Fixed i18n for DesignFiles, and Fixed DesignFilesPanel Test
* Fixed - P3 — .df-thead rule defined but never applied
* Fixed keyboard use for file navigation, focus and button usage
* Fix i18n for x of y in design files pagination
* Fixed SafePage clamping
* Fixed dupe file total count
* Fixed x of y i18n
* Fixed DeleteSelected i18n and missing from Test
* fix effective pagesize issue, and change duplicate file kind to a filesize
* Readded page/everything selection and i18n
* Fixed i18n issues
* Resolved indonesian i18n issue with cloudflare keys
* Fixed unrelated cloudflare i18n issues as requested in Pull Request by reviewer
* Fix e2e test: click filename button instead of row for preview
The DesignFilesPanel was refactored from <button> rows to a <tr> with
a nested <button> for the filename. The e2e test was still clicking
the <tr> which has no onClick handler, so the preview never appeared.
* Remove duplicate formatSize helper, reuse humanBytes instead
Issue #913: in the deploy modal, the primary action button sits flush
against the divider that separates the dialog body from the footer,
which reads as visually cramped because the form above already pushes
content right up to that border.
The shared .modal-foot rule uses 12px vertical padding which works for
shorter dialogs (sketch close, template save) but feels tight in the
deploy flow where the body ends in dense token / domain config rows.
Add a deploy-modal-scoped override that nudges the footer top padding
to 18px so the primary button has the same breathing room as the
content's column gap. Bottom padding stays at the shared 12px so the
overall modal height does not jump.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
Issue #775: when many files are open the tab strip scrolls horizontally
and the active tab can sit fully off-screen, so users lose track of
what they have selected. Design Files is already sticky-pinned at the
left edge from PR #842, but every other tab still vanished once the
overflow grew past the viewport.
Add a small effect keyed on activeTab that finds the .ws-tab.active
element inside the existing tabsBarRef and calls scrollIntoView with
{ block: 'nearest', inline: 'nearest' }. That makes opening a tab via
Cmd+P, clicking a chat file chip, or activating from the design panel
guarantee the tab is visible without yanking the user around when it
already was. The Design Files tab is excluded because the sticky CSS
already keeps it visible.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
Issue #139: clicking Close in the sketch editor immediately discarded
unsaved strokes with no confirmation, even though the toolbar already
tracked dirty state and surfaced the bullet indicator.
Wrap the existing onCancel in a window.confirm() guard, gated on the
dirty flag so a clean sketch still closes in one click. Same pattern
the codebase uses for conversation/design/file delete and the
media-provider Clear button.
i18n: new key 'sketch.closeConfirm' translated across all 17 locales.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
Issue #983: when an assistant turn streamed in, the chat log stopped
auto-scrolling even though the user was sitting at the bottom of the
conversation. Root cause: the auto-scroll effect re-measured
`scrollHeight - scrollTop - clientHeight` AFTER the new chunk had
already grown the element. A single tool-use card or markdown render
adds 100+ px in one tick, so the post-content distance check (`< 80`)
skipped the scroll exactly when the user expected it most.
Switch the gate to the existing `scrolledFromBottom` state. That flag
is maintained by the user-driven scroll listener (only flipped by a
real scroll event, not by content growth), so it carries the user's
pre-content intent through to the effect. New content auto-scrolls
when the user was glued to the bottom; scrollback sessions still
preserve their position.
Existing chat-scroll-preservation tests still pass (6/6); the prior-
state behavior we test there is bottom-pinned vs absolute-restore on
tab switch, which this change does not affect.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
Issue #628: hover and selected rows in Settings → Language used the same
--bg-subtle background, so the currently-selected language was hard to
distinguish from a row being hovered, and keyboard focus had no separate
treatment at all.
- Selected row gets --accent-tint background + accent text + medium
weight so it reads as the active choice even when nothing is hovered.
- Selected + hover lifts to --accent-soft so the cursor still gives
feedback over the active row.
- :focus-visible adds a 2px accent outline (inset) so keyboard users
see exactly where they are while scrolling the list.
CSS-only change; the existing .active class wiring in the React component
already drives this.
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
* fix(web): correct srcdoc injection and deck bridge for JS strings containing HTML tags
- Use indexOf for </head> (real tag precedes JS string occurrences)
- Use lastIndexOf for </body> (real tag follows JS string occurrences)
- Scope deck bridge slide selector to direct children of deck containers
to avoid counting cloned overview thumbnails
- Update .slide-number data attributes and .progress-bar width from
deck bridge's updateDeckChrome so page counter and progress track
with deck navigation
* fix(web): move deck chrome sync into report() for all slide modes
.slide-number and .progress-bar updates were only in updateDeckChrome()
which is called exclusively from setActive(). Scroll decks go through
scrollGo() → report() without ever calling setActive(), so their page
counter and progress bar stayed frozen.
Move the sync logic into report() which is the convergence point for
all navigation paths (class-toggle, scroll, keyboard-dispatch).
* fix(web): use DOMParser for srcdoc injection instead of string matching
Replace all regex/indexOf/lastIndexOf based </head> and </body> matching
with DOM-based injection via domMutate helper. DOMParser correctly
separates raw-text element content from structural markup, so </head>/
</body> inside JavaScript strings no longer hijack injection points.
Also: scope deck bridge slide selector to direct children of deck
containers, and move .slide-number/.progress-bar sync into report()
so all navigation paths update page chrome.
* fix(web): add robust string fallback for srcdoc injection in non-browser environments
When DOMParser is unavailable (Node tests), fall back to structural
boundary matching: lastIndexOf('</head>', before <body) and
lastIndexOf('</body>', before </html>) to skip literal occurrences
inside script/style blocks. Annotate catch blocks.
---------
Co-authored-by: yangjingting <yangjingting@yxqiche.com>
* fix(i18n): rename live artifact tab label in zh-CN and zh-TW
Remove redundant '新建/新建' prefix from live artifact tab and title
labels to match other tab naming convention (e.g., '原型', '幻灯片').
- zh-CN: '新建实时制品' → '实时制品'
- zh-TW: '新建即時成品' → '即時成品'
* fix(i18n): revert titleLiveArtifact to original value
Only tab label should be renamed; title should keep '新建' prefix
to match other title labels (新建原型, 新建幻灯片, etc.).
- Increase .settings-chrome z-index from 3 to 10 to ensure it stays above all modal content
- Remove pointer-events: none from .settings-chrome container to avoid click interception issues
- .settings-autosave already has pointer-events: none, so it won't block clicks
The close button was becoming unclickable after PR #681 introduced the new
Settings layout with Orbit summaries. The issue was likely caused by either
z-index stacking or the pointer-events pattern not working reliably in all
scenarios.
Fixes#905