Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
246 lines
16 KiB
Markdown
246 lines
16 KiB
Markdown
# 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] <id> hidden: <why>`) 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 `<html>`;
|
|
- 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:<label>`), `body`. | `shell/dom.js` |
|
|
| `peek` | The timeline auto-hide: `set(on)`. The behaviour changes the frame's layout, so it lives in the shell; `features/peek` is only its button. | `shell/peek.js` |
|
|
| `monitor` | An object the preview fills: `look({ transform, filter, fit, opacity })` draws on the monitor's picture. Absent until the preview is up, so check it. | `features/preview` |
|
|
| `dock` | Which of Assets, Preview, Properties show | `shell/dock.js` |
|
|
| `theme` | `window.ComposerTheme` (`apply`, `get`, `resolved`) | `shell/theme.js` |
|
|
| `maintenance` | Update, switch branch, restart. Ends in a page reload. | `shell/maintenance.js` |
|
|
| `on(fn)`, `say(name)` | The app's event line. `fn` hears `"open"` (another project was opened), `"change"` (an edit), `"select"`, and named events other features `say` (`"zoom.in"`, `"play.toggle"`, `"timeline.lanes"`, `"media.peek"`). It returns an unsubscribe. | `boot.js` |
|
|
| `hasModule(id)` | Whether the server has that backend module. `true` for everything if the server is unreachable. | `boot.js` |
|
|
| `openSettings(id)` | Opens Settings at a section | `shell/settings.js` |
|
|
| `title(mount, text)` | Sets a zone's title (`mount` is the zone's name: `assets`, `preview`, `inspector`, `timeline`) | `shell/frame.js` |
|
|
| `resetLayout()` | Restores the default layout | `boot.js` |
|
|
|
|
A feature never imports another feature. It reaches the others through `app`, and the rest of the app is reached through a service. (Two existing imports break this: `sampler` uses `settings/field.js` and `settings/models.js`, and `settings/modules.js` uses `addons`. Do not add more: move the shared helper into `shell/` instead.)
|
|
|
|
### Lists other features read
|
|
|
|
A feature that adds to the app pushes onto one of these lists and removes its entry in teardown. The reader wraps every entry in a `try`, so one broken entry does not stop the rest.
|
|
|
|
| List | An entry is | Read by |
|
|
|---|---|---|
|
|
| `app.editorSettings` | `() => handle` for the Editor settings page | `settings/editor.js` |
|
|
| `app.timelineLanes` | `{ owns(id), lanes(project), select/move/trim(id, ...) }`; `app.say("timeline.lanes")` after a change | `timeline` |
|
|
| `app.mediaMenu` | `(item, raw) => [{ id, label, run }]` entries for a media tile's menu | `media` |
|
|
| `app.mediaPeek` | an object: `media` sets `item` and says `"media.peek"`, `preview/peek.js` opens it | `preview` |
|
|
| `app.sceneSections` | `{ rows(scene, project), key(scene, project) }` rows under a scene's own in Properties | `inspector` |
|
|
| `app.clipTags` | `(scene) => tags` shown on a clip | `timeline` |
|
|
| `app.actions` | `{ id, label, icon, run, when?, optIn? }`. Add with `offer(app, action)` from `shell/actions.js`; it returns the remover. The wheel and menus list them. | `wheel` |
|
|
| `app.inputHooks` | `async ({ project, scene, slots }) => ({ inputs, notes })`: put values into the run's inputs; `notes` are shown | `generate` |
|
|
| `app.promptPrefix` | `(scene, project) => [text, ...]` lines put before the scene's prompt at run time (never written into the prompt box) | `generate`, `composer` |
|
|
| `app.beforeRun` | `async () => {}` awaited before a run is built, so an edit still on its way lands first | `generate` |
|
|
|
|
To add a new list, add it in `boot.js` and name it in this table. Keep the entry shape small.
|
|
|
|
A feature is handed a **copy** of `app`, so `app.thing = ...` inside `setup` is invisible to every other feature. To offer something, put an empty object or array on `app` in `boot.js` and fill that (`app.monitor.look = fn`, `app.mediaMenu.push(fn)`), and remove your entry in teardown. A feature that reads it checks it is there.
|
|
|
|
## Pairing with a backend module
|
|
|
|
A feature that exists only because a backend module exists names it: `modules: ["continuity"]`. If the module is not installed or failed to load, the feature is not drawn. Pair the two ids in a test the way `core/tests/test_ui_module_ids.py` does: it fails the build when a feature names a module id that does not exist.
|
|
|
|
When the backend module offers routes, call them through `app.api` (add the method in `shell/api.js`, and test it in `shell/tests/api.test.js`). The path is `/fun/api/m/<module id>/...`.
|
|
|
|
## Building the screen
|
|
|
|
- **Use kit elements.** `composer.button.md({ label, onClick })`. Every element returns a handle `{ node, destroy, ...setters }`. Put `handle.node` in the page and call `handle.destroy()` when done. Containers take **handles**, not nodes.
|
|
- **Content is text.** Pass strings. The kit sets them as text, never as markup, and throws on anything else.
|
|
- **Do not style, and do not touch the page.** No `style`, `classList`, `className`, `cx-` or `fp-` strings, colours or pixel sizes, and no `document` or `window`. `tools/kit_lint` `FEATURE_RULES` fails the build on all of them. Need the page (focus, a download, a file chooser, a document listener)? Ask `app.dom`. Need a look the kit lacks (a tile, a card, a layer over the picture)? Add an element to the kit (see [UI kit reference](ui-kit-reference.md#adding-an-element)) and use that; do not work around it.
|
|
- **Frame-level behaviour belongs to the shell.** Something that rewrites the page frame itself (the timeline auto-hide) lives in `app/ui/shell/` and the feature is only its button.
|
|
- **A redraw replaces content, not controls.** `region.stack` has `set(children)`; it keeps the caret in a field being typed in. Do not redraw while a field has focus (the Engine page skips redraws while typing).
|
|
- **The screen is the truth.** Generate reads live UI state and never waits for saves. Autosave must not disturb typing. Do not cache generated content across requests.
|
|
- **Per-browser preferences** (a collapsed section, the dock) go in `localStorage`, wrapped in `try`/`catch`, and the feature must work when storage throws. Anything that should follow a project goes in `app.project.setPref`.
|
|
|
|
### Elements that are not in the kit
|
|
|
|
If you ask for one that does not exist (`composer.hologram`), it throws `UnknownElement` naming what does exist. In `setup` that makes the feature hidden, which is the intended result: better nothing than something that looks like it works.
|
|
|
|
## Tests
|
|
|
|
Put `hub.test.js` (or `<name>.test.js`) beside the code. `npm test` finds every `*.test.js` under `app/`.
|
|
|
|
- Use jsdom through `composer/tests/_dom.js` (`setupDom`, `teardownDom`, `fire`, `key`). jsdom has no layout, so anything that depends on measured size belongs in the browser, not here.
|
|
- Load the feature through `loadFeatures` with a fake `app` that has only what `needs` names. That tests the real hiding rules too.
|
|
- Test teardown: nothing left in `host`, no listener left in `app`.
|
|
- Tests may use `document`; features may not. A feature that lists `dom` in `needs` gets `dom: createDom()` (from `shell/dom.js`) in the fake `app`.
|
|
- Use real objects (a real `project` store, a real `composer`) over hand-made stand-ins where you can.
|
|
|
|
## Before you open a pull request
|
|
|
|
- [ ] One folder under `app/ui/features/`, one line in `app/ui/modules.js`.
|
|
- [ ] `needs` and `modules` name everything it depends on.
|
|
- [ ] Built from kit elements only. No style, no raw HTML, no colour, no pixel size, no `document` or `window` (`FEATURE_RULES` checks).
|
|
- [ ] Teardown removes every listener, timer, list entry and class.
|
|
- [ ] A control that changes the output is visible and switchable in the app in the same change.
|
|
- [ ] Works in light and dark, and with keyboard only.
|
|
- [ ] A test for the hub: mounts, follows changes, leaves nothing behind, hides when a need is missing.
|
|
- [ ] `cd app && npm test` passes.
|