Skip to content

Widget Registry

How widgets are found, and how a placement becomes the config the shell renders. The code is in app/src/core/registry.ts and app/src/core/widget-types.ts.

Discovery

The registry imports its list of widget configs from a virtual module, virtual:widget-configs, generated by a Vite plugin (app/scripts/widget-configs-plugin.ts):

  • npm run dev: every folder in app/widgets/ that has a widget.config.ts, skipping folders whose name starts with an underscore (such as _template). A placed widget works without a restart, and adding or removing a widget folder reloads the page.
  • npm run build: only the widgets the project places. Before the build processes any files, the plugin reads the project's settings with loadProject (scripts/project-config-plugin.ts), takes placedWidgetIds(settings) (core/placedWidgets.ts: topBarWidgets, sidePanelWidgets, floatingWidgets, mobileWidgets), and imports only those widgets' configs. Unplaced widgets, their components, CSS and libraries never reach the bundle. The log says what was bundled and left out, and a placed name with no widget folder stops the build with the list of known widgets.

The module has the shape import.meta.glob used to return: { '/widgets/<id>/widget.config.ts': { manifest } }. For each entry the registry reads the manifest export and stores it by id. Two checks throw at boot:

  • A file without a manifest export.
  • An id that doesn't match its folder name. The build finds placed widgets by folder, so a widget's folder must be named after its id. This also makes duplicate ids impossible.

The result is widgetRegistry: Record<string, WidgetManifest>. getWidgetManifest(id) looks one up and throws with the list of known widgets if the id doesn't exist.

Discovery only reads widget.config.ts. The rest of each folder follows a convention, not a rule the shell checks: Widget.tsx beside the manifest, with components/, lib/ and assets/ as needed (see app/widgets/WIDGETS.md).

Surface check

assertSurfaceSupported(manifest, surface) throws if a widget is placed on a surface its manifest doesn't list, so a mistake in settings.json stops the app at boot instead of rendering nothing.

Resolution: resolveWidget

resolveWidget(placement, surface) combines a WidgetPlacement from settings.json with its surface and returns the ResolvedWidgetConfig the shell renders. It checks the surface, then merges each field:

manifest defaults ← placement

That gives final values for panelWidth, hasUI, chromeless, order, stayOnMobile and the header. The header has a third level, the global settings.ui.widgetHeader, between the kit default and the placement:

DEFAULT_WIDGET_HEADER ← settings.ui.widgetHeader ← placement.header

The header's titleColor is the exception: its global default comes from settings.theme.widgetTitleColor, because it's a theme setting. showTitle and showDivider follow the chain above.

Icons per surface

iconForSurface(manifest, surface) returns manifest.icons?.[surface], or manifest.icon if there isn't one. mobileIconFor(manifest) picks the mobile carousel icon: a 'mobile' override first, then the 'top-bar' icon (the carousel sits in the top bar, so that shape fits best), then the base icon.

These only choose a shape. The file's color never matters, because shell/WidgetIcon.tsx draws every icon as a mask filled with a theme color (see Theme & Storage). So icons overrides exist only for a different shape. Today the only one is layer-list's simpler mobile icon.

Mobile widgets

resolveMobileWidgets(names) resolves settings.mobileWidgets, the ordered list of names in the mobile carousel. Mobile reuses each widget's component, so there's no separate 'mobile' surface. A name only needs a manifest with at least one surface, and it's resolved against the widget's first surface so header and panelWidth defaults still go through resolveWidget. An unknown name throws the first time the mobile layout renders.