muxy-extension
GitHubMuxy 扩展开发最佳实践指南,涵盖 UI/UX 设计原则、主题适配、尺寸规范及表面选择(HomeView/Sidebar等),旨在使扩展呈现为原生应用的一部分。
Trigger Scenarios
Install
npx skills add muxy-app/muxy --skill muxy-extension -g -y
SKILL.md
Frontmatter
{
"name": "muxy-extension",
"description": "Best-practice guide for authoring a Muxy extension — how it should look and behave so it reads as a native part of the app. Covers theming (follow the theme, never hardcode colors), the sizing scale, and which surface to use. Mechanics (manifest fields, permissions, the window.muxy API) live in the linked docs."
}
Muxy Extension Guide
A Muxy extension is an npm + Vite project: source under src/, an entry HTML that vite build emits into dist/, and Muxy reads dist/ when present (otherwise the project folder). The manifest is the "muxy" object in package.json. There is no fixed folder layout — every entry/background/icon path is an arbitrary relative path inside the build output (the vanilla starter kit emits its panel to panel/index.html); package.json and dist/ are the only names Muxy fixes. During development you don't copy into the config folder — Load Unpacked in the Extensions modal points Muxy at any folder (your git checkout is the install).
The build script must copy package.json into dist/. The publish pipeline ships only dist/, and the app reads the manifest from the install root — so the manifest has to be inside the build output. vite build alone emits your entry/asset paths but not the manifest, so use "build": "vite build && node scripts/copy-manifest.mjs" where copy-manifest.mjs copies package.json into dist/. Easy to miss because Load Unpacked falls back to the root package.json in dev, so it loads locally but fails validation/install when published. The vanilla starter kit already wires this up.
This skill is the guidance layer — how an extension should look and behave. For the API and manifest mechanics (every field, the permission strings, the full window.muxy surface, events, scripts), read the reference docs. Start from the LLM-friendly index, which lists every page and links to its raw Markdown source:
Append /plain to any docs URL for the raw Markdown of that page (e.g. https://muxy.app/docs/extensions/manifest/plain).
The goal of everything below: an extension should be indistinguishable from a native Muxy surface. Match the theme and match the scale, and it will be.
Pick the right surface
- A global full-window destination outside project tab restoration → a
homeView. Use it for an overview or launch surface that spans projects and worktrees; see Home Views for current presentation availability. - Showing something inside a project workspace or transient app chrome → a UI page (tab, panel, or popover). Page scripts get the full
window.muxyAPI. - A persistent, full-height navigation or control surface that replaces the built-in left sidebar → a
sidebar(one per extension; the user selects it in Settings → Sidebar). It fills the entire region — the project list and the footer — so own your own navigation. Samewindow.muxyAPI and theme variables as a panel. - Reacting durably to events, coordinating multiple webviews, or running shell commands headlessly → a
background.jsscript. It can also callmuxy.tabs.opento show a result in the active workspace. Most extensions don't need one. - One-shot logic from the palette → a
runScriptcommand, not a hidden tab. Itsmuxy.*calls are synchronous except for the Promise-basedmuxy.execAsync. It hastabs/panes/projects/worktrees/browser/agents/files/git/exec/execAsync/dialog/modal/topbar/statusbar/notifications, but nothttp,events,remote,panels, orpopover. It can open a modal and act on the choice inline — no page or background listener needed. - Your own HTML in a modal on a keypress (a form, info, a list, or mixed — not just forms) → a webview modal (
muxy.modal.openWebview). A top-centered omnibox-style overlay rendering your HTML. Reach for it over the nativemuxy.modal.openpicker when a list won't express what you need. Prefer opening it frombackground.js— it's always running, so a shortcut works with nothing else on screen, and you can pass dynamicdataandawaitthe result. Use the declarativeopenModalcommand action only for a static, self-contained modal that needs no result and nobackground.js. The modal returns a value viamuxy.modal.submitWebview(value)only if the opener wants one; an informational modal just callsmuxy.lifecycle.close().
Don't open a hidden tab to run logic, and don't put durable event-driven work in tab JS where closing the tab loses it. Use muxy.events.emit('extension.<name>', payload) plus a background listener when a webview needs to ask background.js for shared or long-lived work.
Theme — follow it, never hardcode
Muxy ships paired light/dark themes and a user-chosen accent. Every extension webview inherits CSS custom properties on document.documentElement that track the live theme and update automatically when the user switches it.
Rules:
- No hex literals for chrome. Use
var(--muxy-…)for every color. The only exception is decorative art meant to be theme-independent. - The variables already invert for light/dark — never sniff the color scheme to pick a color. Only branch on
muxy.theme.colorSchemefor things a variable can't express (e.g. swapping a logo image). --muxy-accentis the only saturated color. Use it sparingly — primary action, focus ring, one key number — so it stays distinctive. Text on an accent fill must use--muxy-accent-foreground, which is resolved for contrast against the active theme's accent.- Use
--muxy-surface-solidfor component backgrounds. It is the native surface overlay precomposited over the active theme background, so cards, inputs, code blocks, and buttons match Muxy without letting content show through.--muxy-surface,--muxy-border,--muxy-hover, and--muxy-accent-softare translucent overlays for material-relative effects. Do not apply additional opacity to these tokens or to an entire control. - Re-read the theme for JS-drawn color. Canvas/SVG that doesn't pick up CSS variables must redraw in
muxy.onThemeChange(theme => …). - Popovers leave the body transparent (
body { background: transparent; }) — they sit over native macOS popover material that is already light/dark-aware. Tabs and panels do paint--muxy-backgroundon the body.
The variables (the complete injected set):
| Variable | Use for |
|---|---|
--muxy-background |
Page background |
--muxy-foreground |
Primary text |
--muxy-foreground-muted |
Secondary text, labels, captions |
--muxy-surface-solid |
Opaque cards, inputs, code blocks, buttons |
--muxy-surface |
Translucent surface overlay for popovers and intentional layering |
--muxy-border |
Translucent 1px borders and dividers |
--muxy-hover |
Translucent hover state for buttons / rows |
--muxy-accent |
Primary action, links, focus rings |
--muxy-accent-foreground |
Text and icons on an accent fill |
--muxy-accent-soft |
Translucent accent for badges/highlights |
--muxy-diff-add / --muxy-diff-remove / --muxy-diff-hunk |
Diff / success / error / hunk colors |
--muxy-topbar-height |
The app's tab-bar height (see Sizing) |
(muxy.theme.colorScheme gives "light"/"dark" in JS; there is no --muxy-color-scheme CSS var.)
Sizing — match the app's scale
Muxy's native views are built from one scale of values, and all of them scale with the user's interface-scale setting (Settings → Interface). Pick from this scale rather than inventing numbers, so your surface tracks scale changes the way native views do. These are the base (100%) values in px:
Spacing (padding, gap, margin) — 2 · 4 · 6 · 8 · 10 · 12 · 16 · 20 · 24 · 32. No in-between values. Panel rows and content pad 10px left/right; an icon-and-label gap is 8px; adjacent icon buttons sit 4px apart.
Font sizes — 10 caption · 11 footnote/section labels (often uppercased) · 12 body (paths, row text) · 13 controls · 14 titles (weight 600) · 16+ headings. Body is 12, not 13. Use the system font for UI; "SF Mono", Menlo, monospace for code, counts, and hashes.
Icons — 12–14px glyphs at weight 600 (a thinner default weight is the most common reason an extension's icons look foreign). Custom SVG strokes are 1.5px, round caps/joins.
Controls — an icon button is a 24×24 hit target wrapping a 13–14px glyph; text buttons are 28px tall with 10px horizontal padding.
Radii — 4 chips/badges · 6 buttons/inputs · 8 cards/panels · 10 large containers. Buttons are 4–6, not 5.
Topbar height is the exception — never hardcode it. It scales with interface scale and is injected pre-scaled as --muxy-topbar-height. A tab fills its whole pane, so render your own topbar to match native surfaces (so sibling panes line up): use that variable for the height and keep box-sizing: content-box so the 1px border-bottom lands on the same line as native tabs. Split-child tabs appear as bordered panes inside their owner and do not have a local tab strip. Omit the topbar for edge-to-edge content.
Declare the scale once at the top of your stylesheet and reference it everywhere, so there are no stray magic numbers:
:root {
--s1:2px; --s2:4px; --s3:6px; --s4:8px; --s5:10px;
--s6:12px; --s7:16px; --s8:20px; --s9:24px; --s10:32px;
--font-caption:10px; --font-footnote:11px; --font-body:12px;
--font-emphasis:13px; --font-title:14px;
--icon-sm:12px; --icon:14px; --control:24px;
--radius:6px; --radius-card:8px; --row-height:34px;
}
Behavior
- Least privilege. Declare a permission only when you add the call that needs it.
- Panel float/dock preferences are per panel.
modesets a panel's initial mode, then Muxy persists the user's choice independently for that extension and panel ID while the nativepincontrol is available. HidingpinwithhiddenControls, or all native chrome withhideTopbar, keeps the panel at its declared mode; do that only when the extension intentionally owns the behavior. - Workspaces can be remote. When the active workspace is a remote (SSH) workspace,
muxy.exec,muxy.execAsync,muxy.git.*, and worktree work run on the remote server with the selected SSH device's environment, and paths are remote paths. Write extensions against the active workspace, not a hardcoded local machine — the same code works for local and remote because Muxy brokers the SSH connection. See Scripts. - Use
muxy.execAsyncfor cancellable long-running runScript commands — it returns{ id, result, cancel() };resultresolves to the same shape asmuxy.exec, and cancellation rejects witherror.code === "cancelled"/error.cancelled === true. Local cancellation terminates the command process group; remote cancellation closes the SSH command channel, so detached remote processes may outlive the job. Keepmuxy.execfor short synchronous calls. An extension may run at most 32 commands concurrently across both APIs — beyond that, starting a command fails. See Scripts. - Use
muxy.gitfor repository work (status, diff incl.{ raw: true }, repoInfo, log, branches, PRs incl.pr.number/pr.diff, tags, init, checkout/cherryPick/revert, branchdelete/deleteRemote, worktrees incl.worktree.switchToandpr.checkoutWorktree) instead of shelling out viamuxy.exec— it's the app's own git core, returns structured data, and caches reads. Reads needgit:read; writes needgit:writeand prompt for consent. Reads are cached per project/worktree (HEAD/index aware); pass{ fresh: true }to bypass. Available to tabs, panels, popovers,runScriptcommands, and background scripts. See Git. - Get the signed-in GitHub user with
muxy.gh.user()instead of shelling out togh api user— it returns{ login, name, avatarUrl }from the localghCLI login, needsgh:read, and caches for five minutes so a header/badge can call it on every render. The account is global to theghlogin, so no{ project }argument; it always reflects the local login, even on a remote (SSH) workspace. See GitHub. - Manage the project list with
muxy.projectswrite verbs instead of editingprojects.json—add(path)registers an existing folder as a project, makes it the active project, and returns its project id,rename(identifier, name),setColor/setIcon(identifier, value),setLogo(identifier, storedLogoFilename)(passnullto clear), andreorder(identifiers)(all local non-home project ids in the new order, each exactly once). All needprojects:writeand mutate Muxy's live project store, so the native sidebar updates immediately.addonly accepts an existing directory; create the folder first viamuxy.files/muxy.execif needed. Subscribe to theprojects.changedevent (declareevents: ["projects.changed"], grantprojects:read) to notify a webview/sidebar that can refetch viamuxy.projects.list()after any change, whether made by your extension or Muxy itself. The home project cannot be renamed, recolored, re-iconed, or reordered. See Permissions. muxy.projects.create(path, { createIfMissing, name, workspace })creates or opens a project in one call — unlikeadd, it can create the directory (createIfMissing: true, which prompts the user for runtime consent before writing to disk, likemuxy.files.mkdir), optionally rename it, and optionally place it directly into a workspace by name or id. It rejects if the givenworkspacedoes not exist or is a remote SSH workspace, and never resetsworktreesEnabledon a project that already existed.muxy.projects.attach(identifier, workspace)moves an existing project into a workspace (same workspace rules ascreate);muxy.projects.detach(identifier)removes a project from all workspaces. All three needprojects:write; none accept the home or a remote project asidentifier. See Permissions.muxy.projects.delete(identifier)deletes a project and is irreversible — it cleans up the project's worktrees, branches, and directories on disk. It needs theprojects:deletepermission (separate fromprojects:write) and prompts the user for confirmation on every call. The home project cannot be deleted. Reserve it for explicit user-driven actions, never silent cleanup. See Permissions.- Manage workspaces with
muxy.workspaces—list()returns each workspace's id, name, project count, and whether it's active;create(name)adds one and makes it active;switchTo(identifier),rename(identifier, name), anddelete(identifier)accept a name or id.deletefails while the workspace still has projects in it — detach or move them first withmuxy.projects.detach/attach.listneedsprojects:read;create/switchTo/rename/deleteneedprojects:write. See Permissions. - Use
muxy.filesfor workspace filesystem work (list, read, stat, write, mkdir, rename, move, delete) instead ofmuxy.exec— paths are sandboxed to the active worktree root and returned relative to it. The root is valid for list/stat and as a move destination, but cannot itself be mutated. Reads and UTF-8 writes are capped at 5 MiB. Rename rejects collisions; move uniquifies them. Reads needfiles:read; writes needfiles:writeand prompt for consent. Pair with thefile.changedevent to stay reactive (e.g. a file tree). See Files. - Persist your own state with
muxy.storageinstead of shelling out to a config file —set(key, value)/get(key)(any JSON value;getreturnsnullwhen absent) /delete(key)/keys(). Storage is isolated per extension and shared across that extension's surfaces (a panel and itsbackground.jssee the same keys), and survives restarts. Needsstorage:read(get/keys) /storage:write(set/delete); a key is ≤256 chars, a value ≤1 MB. Good for layout/collapse/preferences. See Storage. - Ask for a value or a folder with
muxy.dialog.prompt/muxy.dialog.pickFolderinstead ofosascript—prompt({ title, message, default?, placeholder?, confirm?, cancel? })resolves the entered string (ornull),pickFolder({ title?, message?, default? })resolves an absolute path (ornull). Same surfaces and no-permission rule asconfirm/alert. See Dialogs. - React to branch changes with the
worktree.headChangedevent instead of pollinggit.worktrees()— it fires when a worktree's checked-out branch changes (e.g. agit checkoutin a terminal), with the newbranchand worktreepath. Declareevents: ["worktree.headChanged"]and grantworktrees:read. See Events. - Give large worktree removals an explicit budget.
muxy.git.worktree.remove({ path, force?, timeoutMs? })defaults to 30 seconds, allows up to one hour, and returns{ path, dirRemoved }. The budget covers path resolution, teardown, Git removal, and final verification. Muxy-managed local worktrees run teardown hooks; remote and external worktrees do not run local hooks. TreatdirRemoved: falseas a residual-directory warning anddirRemoved: nullas an unknown result because verification exhausted the deadline. - React to AI agent activity with the
agent.statusevent instead of polling — it reports an agent's lifecycle per worktree (working>waiting>idle, withproviderIDand the owningpaneID), driven by the provider's hooks (Claude Code, Cursor, Codex, Droid, Grok, OpenCode, Pi).idlecovers a finished, cancelled, or ended turn; Muxy's native finished indicator is separate UI state. Coverage depends on what each CLI's hooks expose — Pi and Cursor do not reportwaiting; the other providers report all three states. It fires only when a worktree's status changes and turnsidlewhen the last agent pane closes. Declareevents: ["agent.status"]and grantagents:read(both the event subscription andmuxy.agents.list()need it); pair the event withmuxy.agents.list()to hydrate current statuses on load. Good for a live per-worktree indicator. See Events. - React to an entirely sleeping worktree with the
worktree.offlineevent when a resource belongs to the whole worktree (a dev server, a container, a watcher) — it reportsoffline: "true"only when every terminal pane in that worktree is offline, andoffline: "false"again when any pane wakes, a new terminal pane is created there, or the last terminal pane closes, so every suspend is paired with a resume. It carriesprojectID,worktreeID,worktreePath, andoffline, so abackground.jslistener can act on the directory directly (background scripts have nomuxy.worktrees.list()). Declareevents: ["worktree.offline"]; no permission is required. See Events. - Use
muxy.http.fetchto call external APIs from a tab/panel/popover instead of the webview'sfetch()— the request goes out via native code, so it is not CORS-blocked, and a panel needs nobackground.js(no subprocess) just to reach the network. Pass(url, { method?, headers?, body?, timeoutMs? })andawait{ status, headers, body, truncated }. No manifest permission; the first call to a host prompts for consent, "Allow & remember" whitelists that host. Private/loopback hosts (localhost,127.*,192.168.*,169.254.*,.local, …) are blocked.muxy.httpis a webview-only surface — neither background scripts norrunScriptcommands havefetch; they shell out viamuxy.exec(['curl', …]). See HTTP. - Use
muxy.modal.openfor a list picker (the native searchable picker overlay) instead of building your own — pass{ items: [{ id, title, subtitle? }], placeholder?, onSelect(choice) }; the choice (ornullif dismissed) arrives inonSelect. Muxy owns the search, navigation, and open/close. No permission needed. Available on every surface: onrunScript/backgroundmodal.openreturns immediately and you read the result inonSelect; on webview pages you can alsoawaitit. It has no shortcut of its own: bind a palettecommandwith adefaultShortcut(its action can be therunScriptthat opens the modal, or aneventabackground.jslistener reacts to). PasssearchToolbar: trueonly when the picker should show the footer search option toggles (Aa,W,.*). For large lists (a file picker over a big repo), passitemsas a functionitems(emit)instead of an array — the picker opens instantly and you stream rows withemit(batch)while Muxy filters them natively, so typing never calls back into your code and the UI can't hang. For results that depend on the query (server-side/async search), passonQuery(query, emit)— Muxy debounces the field and calls it per query so you supply a fresh list, dropping responses for superseded queries; native filtering still runs on top. See Modal. - Bind keyboard shortcuts to your extension — for a static binding declare a palette
commandwith adefaultShortcut("cmd+shift+e"); for a runtime one (e.g. configurable in your settings) callmuxy.shortcuts.register({ id, combo })frombackground.jsand subscribe to the samecommand.<id>event.registerreturns{ ok, conflict? };unregister(id)andlist()round it out. Runtime shortcuts needshortcuts:register, are not persisted (re-register on launch), and reject anidthat collides with a manifest command. See Palette Commands. - Build your own session restore from
background.js— Muxy has no built-in session restore, so an extension owns it. Record sessions by subscribing to the enrichedtab.*events (which now carrykind/projectID/worktreeID/areaID/cwd/data), then recreate each terminal withmuxy.tabs.open({ kind: 'terminal', directory, command }), which resolves the new tab's id (forextensionWebViewtabs, the instance id usable withsetTitle/setIcon).directorystays inside the worktree root;commandadds a one-time runtime consent on top oftabs:write. See Events and Tabs. - Use
extension.*events for webview ↔ background communication — pages and background scripts canmuxy.events.subscribe('extension.<name>', handler)andmuxy.events.emit('extension.<name>', payload). These events are same-extension only, need no permission, and are not listed in the manifesteventsarray. A webview emit is relayed through the extension'sbackground.js, so it rejects when no background script is running — webviews can't reach each other directly. Workspace events (pane.*,file.changed, etc.) still require manifestevents. - Update bar items live with
muxy.topbar.set/muxy.statusbar.set— pass{ id, icon?, visible? }(topbar) or{ id, icon?, text?, visible? }(statusbar) frombackground.jsor any page to swap the icon/text or show/hide without reloading;text: nullclears back to the manifest value. Decide visibility at runtime: declare the item with"visible": falseand callmuxy.topbar.show(id)/.hide(id)(ormuxy.statusbar.show(id)/.hide(id)) when it applies. The item must be declared intopbarItems/statusBarItems; needspanels:write. Good for live indicators (e.g. a PR badge that only appears inside a repo). See Topbar / Status bar. - Retitle a tab live with
muxy.tabs.setTitle(title)/muxy.tabs.setIcon(icon)from the tab's own page to reflect changing state (e.g. an editor showing the open file).iconis"<sf-symbol>",{ symbol }, or{ svg };setTitle("")/setIcon(null)reset to the manifest defaults. Needstabs:write; runtime-only (resets on restart, so set it again on load). See Tabs. - Autofocus your input on
muxy.onFocusfrom a tab/panel/popover page so the surface behaves like a native one — when its tab is opened or switched back to, move keyboard focus into your editor or search field. The callback receivestrueon focus gained,falseon lost;muxy.focusedreads the current state. No permission, no manifest field. See Tabs. - Register project file handlers with
fileOpeners. Users choose extension-provided openers only in Settings; they never appear in the topbar project-target control. If an opener becomes unavailable, Muxy preserves the selection and resumes using it when the extension is available again. The referenced tab receivessource: "terminal", the project-relativefilePath, and optionalline/columnthroughmuxy.dataormuxy.onDataChangewhen its singleton tab is reused. See Tabs. - Provide app translations with
localizations. Keep only the provider metadata inpackage.json; put the full translation catalog in a resource-only.bundlecopied intodist/. The bundle must not declareCFBundleExecutableand needs<language>.lproj/Localizable.stringsorLocalizable.stringsdict. Every translated value must keep the format placeholders of its key at the same argument positions — use%1$@/%2$lldto reorder — or Muxy rejects the bundle. Categorize published providers aslocalizationso users can discover them from Settings → Interface → Language → Browse Language Extensions…. After installation and enablement, their providers appear automatically in the language picker; a missing provider temporarily falls back to built-in English while preserving the selection. See Localizations. - Guard a close with
muxy.lifecycle.onBeforeClose(handler)from a tab/panel/popover page when closing could lose work (a dirty editor). Return/resolvetrue(or{ prevent: true }) to prevent the close, anything else to allow it; the handler may beasync, soawait muxy.dialog.confirm(...)and decide. Closing a top-level tab asks its own and every split-child surface in parallel; any veto cancels the entire hierarchy close. Callmuxy.lifecycle.close()to finish the close yourself without re-asking. No permission, no manifest field — registering the handler is the opt-in, and it fails open (no handler / timeout / throw ⇒ closes). It does not fire on app quit, project switch (panels are torn down and restored per project), or an outside-click popover dismiss; for those, persist reactively instead. Open panels are session-scoped per project — switching projects recreates webviews, so usemuxy.storage/project.switchedfor durable panel state. To merely react after a close, subscribe totab.closed/panel.closed/popover.closed. See Lifecycle and Panels. - Drive and automate the built-in browser with
muxy.browser.*. Tabs:open(url, { split })returns the tab ID;navigate(tabId, url),reload/back/forward(tabId),list(),read(tabId)({ title, url, text }, ~1 MB cap),close(tabId). Automation:eval(tabId, script)(returns the parsed JS result),click,type(…, { submit }),fill,press(tabId, key, selector?),select,hover,scrollIntoView,setChecked. Waiting:wait(tabId, { selector|text|urlContains|function, timeoutMs }),waitFor,waitForNavigation. Inspection:getText/getHTML/getValue/getAttribute/getCount,is(tabId, property, selector),find(tabId, kind, value),snapshot(tabId, selector?)(visible interactive elements — let an agent "see" the page),screenshot(tabId)(base64 PNG). State:storage.get/set/clear(tabId, key, value?, kind)for local/session storage;cookies.get/set/delete/clear(tabId, ...)per profile. Reads and JS-running calls (eval,click,type,waitFor,get*,screenshot,storage.*) needbrowser:read/browser:write;navigate,cookies,listdo not. All of them work headlessly on any open tab in the active project — the tab need not be visible or focused (screenshotrenders off-screen). Every call fails when the user disables the built-in browser. Capture the tab ID fromopen/listand reuse it. See Browser. - Make hover and active states visible in both light and dark —
background: var(--muxy-hover); border-color: var(--muxy-accent);is the standard pattern. Change the relevant color instead of lowering the whole control'sopacity, which also fades its content. - Respect
prefers-reduced-motion— Muxy users opt into Reduce Motion at the OS level; avoid long transitions, large translations, autoplay. - No hardcoded
~/.config/muxypaths from inside the extension — rely on the working directory Muxy sets, or passcwdtoexec.
Checklist
- Every color is
var(--muxy-…);muxy.onThemeChangewired for any JS-drawn color. - Component backgrounds use
--muxy-surface-solid; translucent overlay tokens are only used intentionally. - Spacing, font, icon, control, and radius values come from the scale above — no off-ramp numbers (rows pad
10px, body is12px, icons12–14pxat weight 600). - Tab topbar uses
--muxy-topbar-heightwithbox-sizing: content-box. - Hover/active states are visible in both themes.
-
permissionsdeclares only what is used. - Durable event-driven work is in
background.js, not tab JS. Webview coordination usesextension.*events. No background script unless events, shared state, or backgroundexecare needed. -
buildcopiespackage.jsonintodist/(e.g.vite build && node scripts/copy-manifest.mjs) — onlydist/ships, so the manifest must be inside it. - Every declared localization bundle is copied into
dist/, contains only resources, includes the catalog for its declared language, and preserves each key's format placeholders. - Built with
npm run build, then Reload in the Extensions modal (a Reload alone won't pick up unbuilt source).
Version History
- 52b69b6 Current 2026-08-19 21:40
- a32a179 2026-07-24 21:10


