Repository navigation
feat(ui): mention tokens render as chips in the comment composer; ui 0.44.0 - #1577
Merged
Merged
Conversation
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.
…ly today, and the prefix-label id caveat
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ComposerTextareaalready 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@Labeltoken, built on the mention id model (mentionToken/survivingMentions), never a regex over arbitrary@words.mergeTokenRanges(text, groups)—groupsin priority order; drops ranges outside[0, text.length)and empty/inverted ones, then earlierstartwins, 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 inrenderTokenSpan, both so the class scanner still sees them and so the metric rule is read beside the classes it governs.Change
<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.renderTokenSpan, ontokenClassName, 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 withbox-shadow: 0 0 0 Npx <background>.MentionSource.tokenClassName?: string— appended verbatim to each chip, with the metric rule stated as the host's responsibility.useMentionAutocompletealso returns the survivingmentions(people, not just ids); same frozen empty array asmentionIdswith no source.mentionChipsis 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.Viewer/HtmlViewerneeded no change — they already forwardmentionSource(0.43.1); pinned by test.onMentionsChangereports[]for a restored draft today.No-op guarantee
The same components were mounted on
origin/mainand on this branch in one harness and theirouterHTMLdiffed:addEventListenersetTimeoutmentionSource, noskillReferencesskillReferencesonlymentionSource(host-only)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 whenskillReferencesis on, so a skill composer's overlay keeps its exact attribute list; a mentions-only overlay is found bydata-pn-mobile-editable-mirror.The default chip deliberately reuses classes the package already emitted (an earlier draft added a
box-shadowring and grew the generated CSS by 632 B). Consequence: the portable guide viewer's build is byte-identical —viewer.48O72WuZ.js/viewer.BZXDoJTm.cssand their SRI unchanged —check:manifestreports in sync, and@plannotator/coreis untouched at 0.25.5.@plannotator/ui0.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 theDOM_TESTSstep in.github/workflows/test.yml(scripts/dom-test-allowlist.test.tspasses): 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,tokenClassNameappended not replacing, composition hiding the overlay, the expanded/dialog composer,Mod+Enterstill carrying the ids, and a viewer-shaped source.Counts (macOS, this branch):
bun test packages/ui packages/editor packages/review-editorDOM_TESTS=1DocBadges open-in placement, 1×artifactContentProxyUrlbun test(full)bun run typecheckBrowser 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):scrollTop40)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
box-shadowring 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 aguide-viewer-manifest.tsregeneration and a core content change. Dropping it keeps core untouched and the viewer build byte-identical; the technique is documented for hosts, andtokenClassNameis the way to a pill.chips-wtworktree is left in place for review;chips-basehas been removed.