Skip to content

feat(ui): mention tokens render as chips in the comment composer; ui 0.44.0 - #1577

Merged
backnotprop merged 4 commits into
mainfrom
feat/ui-mention-token-chips
Sep 19, 2026
Merged

backnotprop merged 4 commits into
mainfrom
feat/ui-mention-token-chips

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Problem

0.43.0–0.43.2 gave the comment composer an @ picker, but the token it inserts is then plain text in the textarea. A tag therefore looks like three different things: a styled row in the picker, bare prose while you type, and a rendered mention in the host's posted comment. The host asked for the middle one to match.

Approach — and why one overlay

ComposerTextarea already implements exactly the right technique for skill references: with the feature off it renders the plain pre-feature <textarea>; with it on, a mirrored aria-hidden overlay sits behind a transparent-text textarea, sharing font/padding/wrapping metrics, mirroring scroll, and hiding during IME composition (.pn-ref-composing).

Chips do not add a second overlay. Two mirrored layers could never stay pixel-aligned with each other, and only one of them could own the scroll sync. Instead that overlay became a token highlight layer fed by a merged list of ranges from one or more sources, turning on when either source is active.

The range computation moved out of the render loop into a pure module, packages/ui/utils/composerTokens.ts (no DOM, no styling, no React):

  • skillTokenRanges(tokens) — today's positioned occurrences, unchanged.
  • mentionTokenRanges(text, people) — every occurrence of each surviving person's @Label token, built on the mention id model (mentionToken / survivingMentions), never a regex over arbitrary @words.
  • mergeTokenRanges(text, groups) — groups in priority order; drops ranges outside [0, text.length) and empty/inverted ones, then earlier start wins, longer wins at the same start, earlier group wins at the same start and length, and anything beginning inside a kept range is dropped. Nothing nests.

With a single skill source this reproduces the pre-refactor loop exactly. The component keeps the Tailwind classes and data-* attributes in renderTokenSpan, both so the class scanner still sees them and so the metric rule is read beside the classes it governs.

Change

  • Chips. A picked person's surviving token renders as <span data-mention-token="<person id>" data-mention-kind="user|agent">@Marcus Chen</span> inside the existing overlay. Default look: text-primary bg-primary/15 rounded-[3px] — the skill-reference treatment one shade stronger, so the two token kinds in one overlay read as siblings.
  • Metric rule (documented on renderTokenSpan, on tokenClassName, in HANDOFF and README): a chip may change color, background, border-radius, box-shadow and text-decoration ONLY. Padding, margin, border width, font-weight, letter-spacing and font-size all move a glyph and drift the caret off the painted text. A pill's breathing room is faked with box-shadow: 0 0 0 Npx <background>.
  • MentionSource.tokenClassName?: string — appended verbatim to each chip, with the metric rule stated as the host's responsibility.
  • useMentionAutocomplete also returns the surviving mentions (people, not just ids); same frozen empty array as mentionIds with no source.
  • mentionChips is present for the whole life of a mention composer, not only once somebody is tagged, so a pick never swaps the textarea element under the caret.
  • Both composer layouts (popover and expanded/dialog) get it through the one component. Viewer / HtmlViewer needed no change — they already forward mentionSource (0.43.1); pinned by test.
  • Two inherited limitations, documented rather than papered over: two people whose labels sanitize to the same token are indistinguishable in a plain-text body (first listed owns every occurrence, never throws), and a restored draft has no chips until the author picks again — the same reason onMentionsChange reports [] for a restored draft today.

No-op guarantee

The same components were mounted on origin/main and on this branch in one harness and their outerHTML diffed:

config popover overlay addEventListener setTimeout
no mentionSource, no skillReferences 3192 B, identical none on either side 287 = 287 1 = 1
skillReferences only 4821 B, identical 634 B, identical 150 = 150 1 = 1
mentionSource (host-only) 3192 → 3624 B none → 302 B 148 → 149 1 = 1

The one extra listener on a mention composer is the textarea's scroll — the one that mirrors the layer, which a skill composer has always paid. data-skill-ref-overlay="true" is written only when skillReferences is on, so a skill composer's overlay keeps its exact attribute list; a mentions-only overlay is found by data-pn-mobile-editable-mirror.

The default chip deliberately reuses classes the package already emitted (an earlier draft added a box-shadow ring and grew the generated CSS by 632 B). Consequence: the portable guide viewer's build is byte-identical — viewer.48O72WuZ.js / viewer.BZXDoJTm.css and their SRI unchanged — check:manifest reports in sync, and @plannotator/core is untouched at 0.25.5. @plannotator/ui 0.44.0 publishes alone.

Tests

  • packages/ui/utils/composerTokens.test.ts — 16, DOM-free: single / multiple / adjacent tokens, start and end of text, a deleted token, a one-byte edit un-chipping, duplicate labels, a label that sanitizes to nothing, longest-wins over a prefix label, skill+mention coexistence, both overlap directions, stale/empty/inverted ranges, and the single-skill-source parity pin.
  • packages/ui/components/CommentPopover.mentionChips.test.tsx — 11, DOM-gated, added to the DOM_TESTS step in .github/workflows/test.yml (scripts/dom-test-allowlist.test.ts passes): no overlay without a source, overlay on before the first pick, one chip whose text is exactly the token, two picks in document order, a deleted character un-chipping and dropping the id, skill + mention in ONE layer, tokenClassName appended not replacing, composition hiding the overlay, the expanded/dialog composer, Mod+Enter still carrying the ids, and a viewer-shaped source.

Counts (macOS, this branch):

run result
bun test packages/ui packages/editor packages/review-editor 1582 pass / 0 fail / 1111 skip (2693 across 298 files)
same with DOM_TESTS=1 2678 pass / 5 fail (2684 across 298 files) — the known pre-existing set: 4× DocBadges open-in placement, 1× artifactContentProxyUrl
bun test (full) 4949 pass / 0 fail / 1159 skip (6108 across 530 files)
bun run typecheck clean

Browser evidence

Headless Chromium over a throwaway Vite harness (scratchpad, not committed). Typed Nice catch @ma, picked Marcus from the real picker, kept typing, then measured the chip's rect against the textarea's own glyph geometry for that token (measured independently through a metrics-cloned mirror + Range.getClientRects):

scenario dx dy dw caret vs span end
420px 0.000 0.000 0.000 0.016 px
1200px 0.000 0.000 0.000 0.016 px
after a wrap (3 lines above the token) 0.000 0.000 0.000 0.016 px
after a scroll (scrollTop 40) 0.000 0.000 0.000 0.016 px

Computed style asserted paint-only in every scenario (padding: 0px, margin: 0px, border-width: 0px, letter-spacing: normal). Screenshots and the raw results are in the scratchpad: evidence/chip-wide.png, chip-narrow.png, chip-wrapped.png, chip-scrolled.png, chip-proof.json.

Deviations

  • No box-shadow ring in the default chip. The brief offered it as the sanctioned way to fake pill padding. It worked and measured clean, but it was the one new Tailwind class in the change and it moved the portable viewer's CSS hash (+632 B), which would have forced a guide-viewer-manifest.ts regeneration and a core content change. Dropping it keeps core untouched and the viewer build byte-identical; the technique is documented for hosts, and tokenClassName is the way to a pill.
  • The chips-wt worktree is left in place for review; chips-base has been removed.

backnotprop and others added 4 commits September 19, 2026 13:37
The comment composer's highlight overlay knew about exactly one kind of
token (skill references) and computed its spans inline while rendering.
A second source needs the same layer — two mirrored overlays could never
stay pixel-aligned with each other, and only one of them could own the
scroll sync — so the range computation moves into pure functions and the
component becomes a thin renderer over the merged list.

`utils/composerTokens.ts`: `skillTokenRanges` (today's tokens,
unchanged), `mentionTokenRanges` (built on the mention id model, not a
regex over `@words`), and `mergeTokenRanges`, which drops stale ranges
and resolves overlaps deterministically (earlier start, then longer,
then earlier group; nothing nests). With a single skill source this
reproduces the pre-refactor loop exactly.

No behavior change: mounted against origin/main in the same harness, the
plain composer's `outerHTML` (3192 bytes) and the skill-reference
composer's popover (4821) and overlay (634) are byte-identical, with the
same listener and timer counts.
A host that supplies `mentionSource` sees the `@Label` tokens the picker
inserted painted as chips while typing, so a tag looks the same in the
picker row, in the input, and in the comment the host posts. They ride
the SAME overlay skill references already use — the mention source adds
its ranges to the merged list, and the overlay turns on when either
source is active.

- The chip span carries `data-mention-token="<person id>"` and
  `data-mention-kind="user|agent"` for host CSS, plus the optional
  `MentionSource.tokenClassName` appended verbatim.
- The default look is paint-only: `text-primary bg-primary/15`, a 3px
  radius and a 2px `box-shadow` ring in the same wash standing in for
  horizontal padding. No padding, margin, border, weight, tracking or
  size, because any of those would move a glyph and drift the caret
  away from the painted text; the metric rule is documented on
  `renderTokenSpan` and on `tokenClassName` for hosts.
- Only a picked person whose token still survives is a chip: the ranges
  are built from the hook's mention id model (`useMentionAutocomplete`
  now also returns the surviving `mentions`), never a regex over
  `@words`, so editing one byte of a token un-chips it in the same
  render that untags the person. Two people with the same label are
  indistinguishable in a plain-text body; the first listed owns every
  occurrence.
- `mentionChips` is present for the whole life of a mention composer,
  not only once somebody is tagged, so a pick never swaps the textarea
  element under the caret.

Plannotator is unchanged: with no `mentionSource` and no
`skillReferences` the composer's `outerHTML` is byte-identical to
origin/main (3192 bytes, same listeners and timers), and with
`skillReferences` only the overlay is byte-identical too (634 bytes).
Verified in headless Chromium at two widths, after a wrap and after a
scroll: the chip's rect and the textarea's own text run for that token
agree to 0.000px, and the caret after the token sits 0.016px from the
span's end.
HANDOFF gains "Mention token chips in the composer (0.44.0)" — the one
overlay and the merged-range refactor, the `data-mention-token` /
`data-mention-kind` attributes, `MentionSource.tokenClassName`, the
metric rule for host CSS, the two inherited limitations (duplicate
labels, a restored draft with no chips), and the no-op pins with their
byte counts. README gains the seam line, and `utils/composerTokens`
joins the supported-module table.

`@plannotator/ui` 0.44.0; `@plannotator/core` is unchanged at 0.25.5 and
publishes nothing. The default chip deliberately reuses classes the
package already emitted, so the portable guide viewer's bundle is
byte-identical and `guide-viewer-manifest.ts` needed no regeneration.
@backnotprop
backnotprop merged commit 00eb880 into main Sep 19, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant