f8708da1e0
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
193 lines
10 KiB
Markdown
193 lines
10 KiB
Markdown
---
|
|
status: draft
|
|
system: plugins
|
|
requirements:
|
|
- REQ-PLUGINS-TASK-MENU-SUBMENUS-001
|
|
- REQ-PLUGINS-TASK-MENU-SUBMENUS-002
|
|
- REQ-PLUGINS-TASK-MENU-SUBMENUS-003
|
|
created: 2026-09-23
|
|
owners:
|
|
- 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](#registration-contract), [Entry building](#entry-building), [The reading boundary](#the-reading-boundary) |
|
|
| `REQ-PLUGINS-TASK-MENU-SUBMENUS-002` | [The reading boundary](#the-reading-boundary), [Keys and identity](#keys-and-identity), [Icon resolution](#icon-resolution) |
|
|
| `REQ-PLUGINS-TASK-MENU-SUBMENUS-003` | [Command surfaces](#command-surfaces), [Shared entry reuse](#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.
|