Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
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
2026-10-25 13:11:03 +00:00

16 KiB

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) 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 too: they apply to every feature.

A feature in 30 lines

app/ui/features/scene-count/index.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

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:

"./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

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) 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.