Files
kandev/docs/specs/plugins/system-design/plugin-task-menu-submenus.md
T
2026-09-23 11:00:23 +01:00

10 KiB

status, system, requirements, created, owners
status system requirements created owners
draft plugins
REQ-PLUGINS-TASK-MENU-SUBMENUS-001
REQ-PLUGINS-TASK-MENU-SUBMENUS-002
REQ-PLUGINS-TASK-MENU-SUBMENUS-003
2026-09-23
kandev

Plugin task menu submenus System Design

Purpose and boundaries

This design covers the host half of plugin task-menu submenus: the registration contract, the one boundary that reads plugin values, the entry builder every card and row surface uses, and the flattening that makes submenu children reachable from the command palette and the sidebar's task commands.

It does not cover the plugin-facing authoring guide itself, the plugin-side implementation that consumes the contract, or host-owned menu entries other than plugin actions. Rendering changed no component: KanbanCardMenuEntry already models kind: "submenu" for the built-in Move to / Priority / Link menus and both renderers recurse, so cards, the shared desktop/mobile task-row menu, the preview/detail menus and the palette inherit submenus from one entry model.

Requirement mapping

Requirement Design section
REQ-PLUGINS-TASK-MENU-SUBMENUS-001 Registration contract, Entry building, The reading boundary
REQ-PLUGINS-TASK-MENU-SUBMENUS-002 The reading boundary, Keys and identity, Icon resolution
REQ-PLUGINS-TASK-MENU-SUBMENUS-003 Command surfaces, Shared entry reuse

Registration contract

TaskMenuActionRegistration gains optional items?(context). run stays required, so there is one contract for two host generations: a host predating the field reads only label, icon and run and renders the flat item, and a build whose items() yields nothing usable does the same. The host never awaits a menu item, so items() is synchronous by contract.

TaskMenuSubItemRegistration is deliberately smaller than the parent: id, label, optional icon and disabled, and run. It has no group and no visible, because a child exists only inside its parent's submenu, and its own nesting is not supported.

Entry building

pluginMenuEntry produces exactly one KanbanCardMenuEntry | null per registration: a submenu when pluginSubItems returns usable children, otherwise a flat item, otherwise null for a registration the host cannot render. Both callers — buildPrimaryPluginEntries (group primary) and buildEditMenuEntry (group edit, nested inside the native Edit item) — filter that null.

buildEditMenuEntry builds and filters the plugin entries before choosing between the submenu and the flat item, so a registration list that yields nothing still leaves the native Edit item exactly as it was rather than wrapping it in a submenu with no plugin child (AC-PLUGINS-TASK-MENU-SUBMENUS-002.3).

buildCardPluginEntries builds the two plugin-derived pieces of a card menu once per render from the card's own inputs, and useKanbanCardMenus passes that result to both the dropdown and the context variant, so a plugin's items() runs once per card per render rather than twice.

The reading boundary

pluginSubItems is the single place where host code reads a plugin's items() result, and its whole body is one try. A plugin object may throw from any property access — including the array methods a naive implementation would call and the then lookup a naive thenable check would perform — so:

  • the result is copied with Array.from, which never calls a method on the plugin's own object;
  • every child is read once, inside the guard, into a snapshot of plain values, so a throwing getter or a Proxy fails that child and nothing is read twice;
  • id and label must be non-blank strings after trimming, run must be callable, disabled must be a boolean or null, and icon must be a curated name, a component, a ready-made element or null;
  • duplicate ids keep their first occurrence and the repeat is reported;
  • a throw, a thenable, a non-array, an empty list or an all-unusable list return null, which is AC-PLUGINS-TASK-MENU-SUBMENUS-001.3's flat fallback.

Defects are reported through one helper keyed by action identity and kind, so a registration that fails every render is reported once while a later, different defect on the same action still reports. The helper never interpolates a raw plugin value into a message: template interpolation calls ToString, which throws for a Symbol id or a null-prototype object, and the omission paths are exactly where such a value arrives (AC-PLUGINS-TASK-MENU-SUBMENUS-002.2). The read of the registration's own label, id and icon is guarded for the same reason, and a registration missing any of them contributes no entry.

The registry also reads each action inside a guard when it filters by group and copies the registration. It skips an action whose getter or Proxy throws, reports that registration once, and keeps other actions available. It does not validate the registration when the plugin registers it.

The set of already-reported defects is module state that is never cleared. A defect of the same kind that recurs on the same action after a plugin generation swap therefore stays silent for the life of the page; that is a deliberate tradeoff for a diagnostic line, and it is documented at the helper rather than implied.

Keys and identity

A plugin entry key is "<prefix>-<encoded pluginId>%<encoded action id>", and a child's key is "<parent key>#<encoded child id>". Both ids are plugin-authored strings and the palette flattens every plugin entry into one list where a key is both a React key and cmdk's value, so the encoding has to be injective for any string a bundle can produce.

encodeKeyPart percent-encodes per UTF-16 code unit, escaping everything encodeURIComponent would not leave alone. Two properties matter:

  • it cannot throw. encodeURIComponent raises URIError on a lone surrogate, which slice or [0] produce from an astral character, and that exception leaves the builder a card calls during render with no error boundary above it.
  • it is injective. % is itself escaped, and every escape is exactly four hex digits, which makes each token self-delimiting so no escaped unit can run into the literal characters that follow it — "%0" and "\u0250" must not share a key, and neither may two different (pluginId, actionId) pairs whose ids differ only in how a dash join would spell them.

A child key always contains the # separator and a flat key never does, because encoded ids contain neither # nor a bare %, so a child key can never equal a flat action's key.

Icon resolution

lookupPluginIcon resolves a curated name for strings only, and passes through a component — including React's exotic components. memo and forwardRef return objects that are callable per their types but not at run time, and @tabler/icons-react, the set the curated map itself is built from, produces exactly that shape, so a plain typeof check would reject the host's own icons. lazy is excluded deliberately: it cannot resolve synchronously in a menu and would suspend the menu's content. Elements are excluded here too, because this function's result is passed to createElement by every non-menu surface; the menu's own path returns an element unchanged before reaching it, which is the documented, menu-only element tolerance (AC-PLUGINS-TASK-MENU-SUBMENUS-002.5). The lookup resolves own keys only, so PLUGIN_ICONS["__proto__"] or ["constructor"] cannot resolve through the prototype chain to a non-icon.

Command surfaces

pluginCommandChoices walks a built entry and turns a submenu into one command per selectable child, carrying the trigger's label as the command's context; buildSidebarTaskCommands preserves a command's own context and keeps the task title as the default for everything else. The palette feeds it group primary entries only, so edit stays card-only (AC-PLUGINS-TASK-MENU-SUBMENUS-003.1, AC-PLUGINS-TASK-MENU-SUBMENUS-003.2). A command's id is the entry key, which is what makes the two properties above matter to the palette as well.

Shared entry reuse

BuildKanbanCardMenuEntriesArgs accepts prebuilt plugin entries so one card render can share them between its variants. Because those entries carry a disabled state, an edit handler and a menu context, forceFlatEdit outranks a prebuilt set inside buildKanbanCardMenuEntries, and the non-card menu tiers additionally drop any set they are handed: a surface that forces the flat Edit form must never render the nested Edit submenu those entries could contain (AC-PLUGINS-TASK-MENU-SUBMENUS-003.3). The field is documented as valid only for a call whose inputs match the ones the entries were built from; it is not runtime-guarded, because no caller passes a mismatch today and the guard would need the inputs carried alongside for a hazard that does not exist yet.

Compatibility and failure behavior

  • A host predating items ignores it: the field is additive and run is unchanged, so every existing plugin keeps its exact behavior.
  • A registration the host cannot render contributes nothing and is reported, rather than rendering a broken row.
  • A child the host cannot render is dropped, reported once, and the remaining children still render; if none remain the action falls back to its flat item.
  • An exception anywhere in the plugin's items(), or in reading a registration's own fields, degrades at the boundary instead of propagating out of a render.

Amendment log

Amendment 1 (2026-09-23), from the adversarial review loop on PR #3874. The first implementation of this design used encodeURIComponent for the key parts and a variable-width escape for the fallback encoder, and reported defects with a raw template interpolation of the action's identity. Three defects came out of review, each with a reproduction: an id containing a lone surrogate threw URIError out of the card's render; "%0" and "\u0250" collided because a two-digit escape is not self-delimiting; and a Symbol or null-prototype id made the defect report itself throw. The contract is unchanged; the implementation now escapes per code unit at a fixed width, reads plugin values defensively on every path that reports them, admits memo/forwardRef component objects while excluding lazy and elements, treats null as absent for both icon and disabled, and keeps the native Edit item flat when no plugin entry wraps it.