# Writing a UI feature A UI feature is one folder of JavaScript that adds one thing to the screen: a button, a panel, a timeline lane, a Settings section, a menu entry. Features are plain ES modules. There is no build step, no framework and no bundler, so what you write is what the browser runs. Before you start, decide whether you need one. A **module setting** (see [Writing a module](writing-a-module.md)) is drawn for you in Settings > Engine and needs no JavaScript. Write a feature only when the screen needs something a setting cannot say. Read [UI design guidelines](ui-design-guidelines.md) too: they apply to every feature. ## A feature in 30 lines `app/ui/features/scene-count/index.js` ```js // The number of scenes in the project, beside the dock buttons. import { composer as c } from "../../composer/composer.js"; export default { id: "scene-count", mount: "timeline.status", needs: ["project"], setup({ host, app }) { const label = c.text.xs({ text: "" }); const draw = () => { const n = app.project.scenes.length; label.setText(`${n} ${n === 1 ? "scene" : "scenes"}`); }; draw(); host.append(label.node); const off = app.on(draw); return () => { off(); label.destroy(); }; }, }; ``` `app/ui/features/scene-count/hub.test.js` ```js import test from "node:test"; import assert from "node:assert/strict"; import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; import { offer, _reset } from "../../shell/mounts.js"; import { loadFeatures } from "../../shell/features.js"; let hub; test.before(async () => { setupDom(); hub = (await import("./index.js")).default; }); test.after(() => teardownDom()); test("shows the number of scenes, follows edits, and leaves nothing behind", async () => { _reset(); const host = document.createElement("div"); offer("timeline.status", host); const heard = new Set(); const app = { project: { scenes: [{ id: "a" }] }, on: (fn) => { heard.add(fn); return () => heard.delete(fn); } }; const { mounted, hidden } = await loadFeatures(["x"], { load: async () => ({ default: hub }), app, has: (n) => n in app }); assert.deepEqual(hidden, []); assert.match(host.textContent, /1 scene$/); app.project.scenes.push({ id: "b" }); heard.forEach((fn) => fn("change")); assert.match(host.textContent, /2 scenes$/); mounted[0].teardown(); assert.equal(host.textContent, ""); assert.equal(heard.size, 0); }); test("without a project service the feature is hidden, not broken", async () => { _reset(); offer("timeline.status", document.createElement("div")); const { mounted, hidden } = await loadFeatures(["x"], { load: async () => ({ default: hub }), app: {}, has: (n) => n in {} }); assert.equal(mounted.length, 0); assert.match(hidden[0].why, /needs project/); }); ``` Then add one line to `app/ui/modules.js`: ```js "./features/scene-count/index.js", ``` Run it with `cd app && npm test`. That is the whole job. ## The hub A feature file's default export is one hub, or an array of hubs. | Field | Needed | Meaning | |---|---|---| | `id` | yes | Unique name, shown in the console when the feature is hidden. | | `mount` | yes | The region to put it in. See the table below. | | `setup({ host, app })` | yes | Builds the feature. `host` is the region's element (or, for `settings`, an object with `add`). May return a teardown function. | | `needs` | no | Names that must exist on `app` (`["project", "api"]`). | | `modules` | no | Backend module ids that must be installed (`["render", "continuity"]`). A button whose backend did not load would only fail when pressed. | The order in `modules.js` is the order features are set up, so a feature that reads something another registers (`app.sceneSections`, `app.mediaMenu`) should come after it. ### When a feature is hidden Nothing of it reaches the screen. The reason is written once to the browser console (`[FunUI] hidden: `) and returned by the loader. A feature is hidden when: - its file does not load (syntax error, bad import); - `mount` names a region nobody offers (the message lists what is offered); - anything in `needs` is missing from `app`; - anything in `modules` is not installed on the server (if the server cannot be asked, nothing is hidden for this reason); - `setup` throws. Whatever it appended to `host` and whatever it subscribed to through `app.on` is removed again. The rest of the app loads normally in every case. This is the "hide, don't warn" rule at work: do not draw a disabled button with an apology. ### What you must clean up yourself The loader only undoes `host` children and `app.on` subscriptions, and only when `setup` throws. Everything else is yours, in the function `setup` returns: - listeners you added through `app.dom.onPage` / `onWindow` (each returns its off switch), timers; - entries you pushed onto `app` lists (see below), keys you bound, actions you offered; - classes you put on ``; - overlays, popovers and modals you opened (`handle.destroy()` or `.close()`). Do the steps that can throw first and the registrations last, so a `setup` that fails halfway leaves nothing registered. ## Regions (`mount`) The shell offers these. A region is announced by the code that creates it, so the list is whatever `shell/frame.js` and `shell/settings.js` offer. | `mount` | Where | |---|---| | `menubar.menus` | the menu bar, after the brand. File, Edit, View style menus go here. | | `menubar.right` | the far end of the menu bar | | `assets` zone: `assets.actions`, `assets.status`, `assets.projects`, `assets.media` | the Assets panel: header actions, footer, the project list, the media bin | | `preview`, `preview.actions`, `preview.status` | the monitor panel: its body, header actions, footer | | `inspector`, `inspector.actions`, `inspector.status` | the Properties panel | | `timeline` | the timeline stage | | `timeline.toolbar`, `timeline.toolbar.end` | the row over the stage: what acts on the cut, and zoom/hints | | `timeline.actions`, `timeline.status` | the Timeline panel's header and footer | | `settings` | the Settings window. Not a DOM region: `host.add(section)` | A body region (`preview`, `inspector`, `timeline`, the two `assets.*`) is a stack that takes children. Other regions are toolbars. ### A Settings section ```js export default { id: "my-settings", mount: "settings", needs: ["api"], setup({ host, app }) { host.add({ id: "mine", group: "System", title: "My thing", subtitle: "One sentence.", icon: "◎", tone: "neutral", keywords: "search words", mount({ setFooter, close }) { return { node: someHandle.node, destroy() {} }; }, }); }, }; ``` `group` is one of `""`, `"Generation"`, `"Learning"`, `"System"`, in that order. The window searches `title` and `keywords`. `mount` returns a handle (`node`, `destroy`); `setFooter({ actions, note })` puts buttons below the scrolling body. A small preference belongs on the **Editor** page instead: push a function that returns a kit handle onto `app.editorSettings` (see `features/autosave`). ## What `app` offers `app` is built in `app/ui/boot.js`. Only a name listed in `needs` is guaranteed; check the table for what each is. Where a method is not listed here, read its file header: each one starts with a comment that says what it is for. | Name | What | File | |---|---|---| | `project` | The open project: `scenes`, `selectedId`, `edit(fn)`, `undo()`, `redo()`, `pref(key, fallback)`, `setPref(key, value)`, `flush()`, `unsaved`. Saved to the server. | `shell/project.js` | | `selection` | Which scenes are picked: `ids`, `focus`, `pick`, `on(fn)` | `shell/selection.js` | | `playhead` | `at` (seconds), `set(sec)`, `on(fn)`, `playing` | `shell/playhead.js` | | `pipeline` | The live pipeline and module values: `slots()`, `activeModules()`, `currentValues()`, `setModuleValue(id, key, value)`, `usefulness()`, `subscribe(fn)`, `ensureLoaded()`, `allOff()` | `shell/pipeline_state.js` | | `generate`, `runner` | Starting runs. `runner.units(ids, times)` is set by the Generate feature. | `shell/generate.js` | | `api` | Every server call, one method each. Use it; do not call `fetch`. | `shell/api.js` | | `keys` | `bind("mod+z", fn)` returns an unbind. Return `false` to let the key through. Typing in a field is never intercepted. | `shell/keys.js` | | `dom` | The page, as a service. A feature never names `document` or `window`. `typingIn(node, only?)` (the caret is in a field inside `node`: skip a redraw), `focusOn(node)`, `holdsFocus(node)`, `blur()`, `onPage(type, fn, opts)` and `onWindow(...)` (each returns its off switch), `dialogOpen()`, `selectingIn(node)`, `download(url, name)`, `saveBlob(blob, name)`, `openTab(url)`, `pickFile(accept)` (a `File` or `null`), `length(kind, src)` (seconds, or `null`), `landmark(name)` (`menubar`, `main`, `zone:0`..`zone:3`, `button: