@plannotator/ui 0.43.2 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HANDOFF.md CHANGED
@@ -215,6 +215,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
215
215
  | `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A toggles annotate mode, Mod+Shift+X shows/hides the tools). Pure: React plus `utils/platform`; no backend. |
216
216
  | `utils/selectionActions` + `components/SelectionActionsDropdown` | The host selection-actions seam: `SelectionAction`, `SelectionActionContext`, the pure `buildSelectionActionContext`, and the dropdown `AnnotationToolbar` opens. Pure React; no backend. *(Blessed in 0.43.0.)* |
217
217
  | `utils/mentions` + `components/MentionPicker` + `hooks/useMentionAutocomplete` | The `@` mention seam behind `CommentPopover`'s `mentionSource` (and, since 0.43.1, `Viewer`'s and `HtmlViewer`'s): the pure grammar (`mentionTrigger`, `mentionMatches`, `applyMentionPick`, `survivingMentions`), the portaled picker, and the keyboard state machine. Pure React; no backend. *(Blessed in 0.43.0.)* |
218
+ | `utils/composerTokens` | The comment composer's token highlight ranges: `skillTokenRanges`, `mentionTokenRanges`, `mergeTokenRanges` and `ComposerTokenRange`. Pure (no DOM, no styling) — the one place that decides which bytes of a composer's text are a token and which source wins when two claim the same ones. *(Blessed in 0.44.0.)* |
218
219
  | `utils/inputMethod` (`getInputMethod`, `saveInputMethod`, `refreshInputMethodStamp`) | The per-surface pinpoint/drag input-method preference with its TTL. Persists through the `storageBackend` seam; no backend of its own. |
219
220
  | `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
220
221
  | `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
@@ -1171,8 +1172,158 @@ global composer through its button — and
1171
1172
  HtmlViewer through a bridge pinpoint message and its global button. Plus the two DOM-free pins in
1172
1173
  §3 above.
1173
1174
 
1175
+ ## Mention token chips in the composer (0.44.0)
1176
+
1177
+ 0.43.0-0.43.2 gave the composer an `@` picker; the token it inserted was
1178
+ then plain text in the textarea. 0.44.0 paints it as a **chip**, so a tag
1179
+ looks the same in the picker row, in the input, and in the comment the host
1180
+ posts. Same ruling as the three releases before it: an opt-in host
1181
+ capability that changes nothing for Plannotator's own users when it is not
1182
+ supplied. `packages/editor` and `packages/review-editor` are untouched; core
1183
+ is UNCHANGED at `0.25.5`, so **ui 0.44.0 publishes alone**.
1184
+
1185
+ ### One overlay, two sources (the refactor)
1186
+
1187
+ `ComposerTextarea` already used exactly the right technique for skill
1188
+ references: a mirrored, aria-hidden overlay rendered BEHIND a
1189
+ transparent-text textarea (a textarea cannot style substrings), sharing the
1190
+ font/padding/wrapping metrics and mirroring scroll, with `.pn-ref-composing`
1191
+ hiding it during IME composition. Chips do not add a second overlay — two
1192
+ mirrored layers could never stay pixel-aligned with each other, and only one
1193
+ of them could own the scroll sync. Instead the overlay became a TOKEN
1194
+ HIGHLIGHT LAYER fed by a merged list of ranges, and it turns on when EITHER
1195
+ source is active.
1196
+
1197
+ The range computation moved out of the render loop into
1198
+ `utils/composerTokens` (pure — no DOM, no styling, no React):
1199
+
1200
+ - `skillTokenRanges(tokens)` — today's positioned occurrences, unchanged.
1201
+ - `mentionTokenRanges(text, people)` — every occurrence of each surviving
1202
+ person's readable `@Label` token.
1203
+ - `mergeTokenRanges(text, groups)` — `groups` in priority order; drops
1204
+ ranges outside `[0, text.length)` and empty/inverted ones, then keeps
1205
+ earlier `start`, longer at the same start, earlier group at the same start
1206
+ and length, and drops anything beginning inside a range already kept.
1207
+ Nothing nests, so the overlay stays a flat sequence of spans.
1208
+
1209
+ With a single skill source this reproduces the pre-refactor loop exactly
1210
+ (which dropped a token whose `start` fell behind the cursor or whose `end`
1211
+ ran past the text). The component keeps the Tailwind classes and the `data-*`
1212
+ attributes in `renderTokenSpan`, both so the class scanner still sees them
1213
+ and so the metric rule below is read with the classes it governs.
1214
+
1215
+ ### The chip
1216
+
1217
+ A chip is painted only for a person the author PICKED whose token still
1218
+ survives — `useMentionAutocomplete` now also returns those survivors as
1219
+ `mentions` (frozen-empty with no source, the same treatment `mentionIds`
1220
+ gets), so the ranges come from the mention id model and never from a regex
1221
+ over arbitrary `@words`. Editing one byte of a token un-chips it in the same
1222
+ render that drops the id from `onMentionsChange`, so a chip follows the body
1223
+ rather than a stale pick. The one case where the chips and the reported IDS
1224
+ can still part company is the prefix case in the limitations below, which
1225
+ the chips inherit rather than introduce.
1226
+
1227
+ ```
1228
+ <span data-mention-token="user_1" data-mention-kind="user" class="…">@Marcus Chen</span>
1229
+ ```
1230
+
1231
+ - `data-mention-token` is the opaque host id; `data-mention-kind` carries
1232
+ `MentionPerson.kind` verbatim — the host's styling hook. Know what that
1233
+ means today: the picker offers USERS ONLY (`mentionMatches` drops every
1234
+ person whose `kind !== 'user'`), so only a user can be picked, only a user
1235
+ can be tagged, and the attribute only ever reads `user`. `agent` is the
1236
+ reserved value for the day agents become taggable — a host rule for
1237
+ `[data-mention-kind="agent"]` matches nothing until then.
1238
+ - `MentionSource.tokenClassName?: string` (new, optional) is appended to the
1239
+ span verbatim for a host that wants its own look.
1240
+ - The package default is `text-primary bg-primary/15` and a 3px radius —
1241
+ the skill-reference treatment one shade stronger, so the two token kinds in
1242
+ one overlay read as siblings. Deliberately no ring: every class it uses is
1243
+ one the package already emitted, so a host's generated CSS is unchanged
1244
+ (and so is the portable guide viewer's bundle — `guide-viewer-manifest.ts`
1245
+ needed no regeneration, which is why core is untouched).
1246
+
1247
+ **THE METRIC RULE (and it is the host's too).** A chip may change COLOR,
1248
+ BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Padding,
1249
+ margin, border width, font-weight, letter-spacing and font-size all move a
1250
+ glyph, and the overlay's glyphs must coincide with the textarea's own layout
1251
+ or the caret drifts away from the text it is painting. A pill's horizontal
1252
+ breathing room is faked with `box-shadow: 0 0 0 Npx <background>`, which
1253
+ paints without occupying space — that is the way to a pill look through
1254
+ `tokenClassName`, and the reason the rule bans padding rather than the
1255
+ appearance.
1256
+
1257
+ ### What did NOT change
1258
+
1259
+ The overlay exists for the whole life of a mention composer, not only once
1260
+ somebody is tagged, so the first pick never swaps the textarea element under
1261
+ the caret. IME composition, scroll sync, the resize gutter, the placeholder,
1262
+ the `/` + `$` skill autocomplete and its menu, the Alt-typing path, drafts
1263
+ (`initialDraft` / `draftKey`), image attachments and `Mod+Enter` submit are
1264
+ all untouched, and `onSubmit(text, images?, mentions?)` is the same call.
1265
+ `Viewer` and `HtmlViewer` needed no change at all: they already forward
1266
+ `mentionSource` (0.43.1), and the chips are inside the composer it reaches.
1267
+
1268
+ Three inherited limitations are worth stating rather than fixing here:
1269
+
1270
+ - **Two people whose labels sanitize to the same token** are
1271
+ indistinguishable in a plain-text body, so the FIRST of them listed owns
1272
+ every occurrence of it. That is the same first-match rule
1273
+ `survivingMentions` already applies to the ids; it renders and never
1274
+ throws.
1275
+ - **A restored draft has no chips** until the author picks again, because
1276
+ the survivors come from the picks made in THIS composer — exactly the same
1277
+ reason `onMentionsChange` reports `[]` for a restored draft today (0.43.x
1278
+ behavior, unchanged). It reports no STALE ids either: a reopened draft
1279
+ starts with nobody tagged, so the chips and the ids agree on "none".
1280
+ - **A label that is a prefix of another label** (`Ann` and `Anna Lee`, both
1281
+ picked): the CHIPS are right — longest-wins means `@Anna Lee` is painted
1282
+ whole and is never half-covered by an `@Ann` chip. The IDS are the loose
1283
+ end: delete `@Ann` from a body that still reads `@Anna Lee` and
1284
+ `survivingMentions` keeps reporting Ann, because it asks
1285
+ `text.includes('@Ann')`. So the id list can outlive the chip. That is
1286
+ 0.43.x behavior in `survivingMentions`, unchanged here — the chips only
1287
+ make it visible.
1288
+
1289
+ ### The no-op guarantee, and how it is pinned
1290
+
1291
+ The same components were mounted on `origin/main` and on this branch in one
1292
+ harness and their `outerHTML` diffed:
1293
+
1294
+ - **No `mentionSource`, no `skillReferences`:** the composer is
1295
+ byte-identical — 3192 bytes, and the same `addEventListener` and
1296
+ `setTimeout` counts (287 / 1 in that harness). No overlay element exists
1297
+ at all.
1298
+ - **`skillReferences` only:** the popover (4821 bytes) and the overlay
1299
+ itself (634 bytes) are byte-identical, same listener and timer counts
1300
+ (150 / 1). `data-skill-ref-overlay="true"` is written only when
1301
+ `skillReferences` is on, so a skill composer's overlay keeps the exact
1302
+ attribute list it had; a mentions-only overlay is found by its
1303
+ `data-pn-mobile-editable-mirror` attribute instead.
1304
+ - A mention composer's overlay costs exactly one extra listener (the
1305
+ textarea's `scroll`, which is what mirrors the layer) — the same one a
1306
+ skill composer has always paid.
1307
+ - **The portable guide viewer's build is byte-identical** (`viewer.*.js` and
1308
+ `viewer.*.css` hashes and their SRI unchanged against `origin/main`), so
1309
+ `packages/core/guide-viewer-manifest.ts` is in sync and core is untouched.
1310
+ That is also why the default chip reuses classes the package already
1311
+ emitted instead of introducing one.
1312
+
1313
+ **Real-browser metric proof** (headless Chromium, throwaway Vite harness):
1314
+ typing `Nice catch @ma`, picking Marcus and continuing to type, the chip's
1315
+ bounding rect and the textarea's own text run for that token agree to
1316
+ **0.000px** on both axes and in width — at 420px and 1200px, on a wrapped
1317
+ line (3 lines above it) and with the textarea scrolled (`scrollTop` 40) —
1318
+ and the caret x after the token is **0.016px** from the span's end.
1319
+
1320
+ Tests: `utils/composerTokens.test.ts` (16, DOM-free) and
1321
+ `components/CommentPopover.mentionChips.test.tsx` (11, DOM-gated, in the
1322
+ workflow's DOM_TESTS step).
1323
+
1174
1324
  ## Publishing & versioning
1175
1325
 
1326
+ - **ui 0.44.0 (mention token chips in the composer): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive over 0.43.2: the `@Label` tokens a `mentionSource` composer inserted render as chips in the composer's existing highlight overlay, `MentionSource.tokenClassName?` lets a host restyle them (under the metric rule), `useMentionAutocomplete` also returns the surviving `mentions`, and `utils/composerTokens` joins the supported-import list. Nothing is removed, no export-, share- or archive-visible change, and Plannotator passes none of it — with neither `mentionSource` nor `skillReferences` the composer is byte-identical to 0.43.2. See "Mention token chips in the composer (0.44.0)".
1176
1327
  - **ui 0.43.1 (`mentionSource` on the viewers): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive over 0.43.0: `mentionSource` on `Viewer` and `HtmlViewer` (forwarded to every comment composer each mounts) and the optional `Annotation.mentions` field the picked ids land on, set only when a source was supplied and a token survived. Nothing is removed, no new modules, no export-, share- or archive-visible change, and Plannotator passes none of it. See "`mentionSource` on the viewers (0.43.1)".
1177
1328
  - **ui 0.43.0 (host toolbar seams): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive: `selectionActions` + `quickLabels` on `AnnotationToolbar` (forwarded by `Viewer`; `selectionActions` also by `HtmlViewer`), `mentionSource` on `CommentPopover`, an optional third `mentions` argument on that component's `onSubmit`, and the new supported modules `utils/selectionActions`, `utils/mentions`, `components/SelectionActionsDropdown`, `components/MentionPicker`, `hooks/useMentionAutocomplete`. Nothing is removed and Plannotator passes none of it. See "Host toolbar seams (0.43.0)".
1178
1329
  - **core 0.25.5 / ui 0.42.0 (diagram FILES, `.mmd`/`.mermaid`/`.dot`/`.gv`): additive on both packages, so the next publish is core-first.** `@plannotator/core/annotatable` gains `DiagramRenderKind`, `diagramRenderKindForPath`, `isDiagramRenderKind` and `annotateDiagramRenderKind`, and its built-in annotatable sets now include the four diagram extensions (`shouldStripFrontmatter` returns false for them — Mermaid's `--- … ---` config block is content — and they can no longer be registered through `markdownExtensions`). `@plannotator/ui` gains `diagramDocumentBlocks(text, kind)` on `utils/parser` (the ONE `code` block a whole-file diagram source renders as), `shareableDocumentMarkdown(markdown, renderAs)` on `utils/sharing`, the `DocumentRenderAs` type on `types` (`'markdown' | 'html' | DiagramRenderKind`, re-exporting core's kind), and an optional `Block.diagramSourceLineOffset` that `DiagramBlock` prefers over `Block.startLine` when resolving a diagram comment's `sourceLine` (unset on every parser-produced fence, so fences are byte-identical). `useLinkedDoc`'s `renderAs`/`setRenderAs`/`LinkedDocLoadData.renderAs` widen from `'markdown' | 'html'` to `DocumentRenderAs` — source-compatible for a host that only ever passes the old two, but a host whose own state is typed `'markdown' | 'html'` must widen its setter. Since core changes, **publish `core` first** and update UI's exact core dependency before packing ui.
package/README.md CHANGED
@@ -258,6 +258,24 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
258
258
  no-access dialog) and inserts normally when you do not. Not wired to any
259
259
  Plannotator data and not a `configurePlannotatorUI` seam — pass it where you
260
260
  render the composer.
261
+ - **`CommentPopover` mention chips** (0.44.0): with a `mentionSource`, the
262
+ `@Label` tokens the picker inserted render as chips in the composer's text —
263
+ the same mirrored overlay skill references use, never a second layer. Each
264
+ chip span carries `data-mention-token="<person id>"` and
265
+ `data-mention-kind`, which is `MentionPerson.kind` verbatim — today that is
266
+ always `user`, because the picker offers users only; `agent` is reserved
267
+ and matches nothing yet. `MentionSource.tokenClassName?` is appended to the
268
+ span for your own look. **Metric rule, yours to keep too:** a chip
269
+ may change color, background, border-radius, box-shadow and text-decoration
270
+ ONLY — padding, margin, border width, weight, tracking or size move a glyph
271
+ and drift the caret off the painted text (want a pill? add
272
+ `box-shadow: 0 0 0 Npx <background>`, which paints without taking space).
273
+ Only a person you supplied and the author picked is a chip, and editing a
274
+ byte of the token un-chips it in the same render that drops the id. Two
275
+ inherited limits: two labels that sanitize to the same token are one token
276
+ in a plain-text body, so the first person you list owns every occurrence of
277
+ it; and a restored draft has no chips (and reports no ids) until the author
278
+ picks again. No source → no overlay element at all.
261
279
  - **`Viewer` / `HtmlViewer` `mentionSource`** (0.43.1): the same prop on the two
262
280
  viewers, forwarded to every comment composer each of them mounts, so a host
263
281
  wires mentions once per surface instead of per composer. The ids the author
@@ -268,7 +286,10 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
268
286
 
269
287
  See HANDOFF.md § "Host toolbar seams (0.43.0)" for the grammar, the keyboard
270
288
  rules and the threading points, and § "mentionSource on the viewers (0.43.1)"
271
- for the two viewer props and the annotation field.
289
+ for the two viewer props and the annotation field. § "Mention token chips in
290
+ the composer (0.44.0)" covers the chips, the metric rule, the merged-range
291
+ refactor behind them, and the three inherited limits (duplicate labels,
292
+ restored drafts, and a label that is a prefix of another label).
272
293
 
273
294
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
274
295
 
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef, useCallback, useId } from 'react';
1
+ import React, { useState, useEffect, useMemo, useRef, useCallback, useId } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import type { ImageAttachment } from '../types';
4
4
  import { AttachmentsButton } from './AttachmentsButton';
@@ -9,9 +9,15 @@ import { hasUnsavedCommentContent } from '../utils/commentContent';
9
9
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
10
10
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
11
11
  import type { SkillReferenceToken } from '../utils/skillReferences';
12
+ import {
13
+ mentionTokenRanges,
14
+ mergeTokenRanges,
15
+ skillTokenRanges,
16
+ type ComposerTokenRange,
17
+ } from '../utils/composerTokens';
12
18
  import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
13
19
  import { MentionPicker } from './MentionPicker';
14
- import type { MentionSource } from '../utils/mentions';
20
+ import type { MentionPerson, MentionSource } from '../utils/mentions';
15
21
  import {
16
22
  hasPrimaryCoarsePointer,
17
23
  shouldUseExpandedComposer,
@@ -474,6 +480,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
474
480
  source: mentionSource,
475
481
  });
476
482
 
483
+ // The mention half of the composer's highlight layer: present for the whole
484
+ // life of a mention composer (so the first pick never swaps the textarea
485
+ // element), null without a source (so the overlay does not exist at all).
486
+ const mentionChipPeople = mentionAc.mentions;
487
+ const mentionTokenClassName = mentionSource?.tokenClassName;
488
+ const mentionChips: ComposerMentionChips | null = useMemo(
489
+ () =>
490
+ mentionSource
491
+ ? { people: mentionChipPeople, tokenClassName: mentionTokenClassName }
492
+ : null,
493
+ [mentionSource, mentionChipPeople, mentionTokenClassName],
494
+ );
495
+
477
496
  const handleSubmit = useCallback(() => {
478
497
  const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0;
479
498
  if (hasUnsavedContent || canSubmitEmpty) {
@@ -684,6 +703,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
684
703
  sizeClassName="min-h-32 max-h-full"
685
704
  skillReferences={skillReferences}
686
705
  tokens={skillAc.referenceTokens}
706
+ mentionChips={mentionChips}
687
707
  listboxId={composerListboxId}
688
708
  listboxOpen={composerListboxOpen}
689
709
  activeOptionId={activeComposerOptionId}
@@ -849,6 +869,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
849
869
  sizeClassName="max-h-64 min-h-[4.5rem]"
850
870
  skillReferences={skillReferences}
851
871
  tokens={skillAc.referenceTokens}
872
+ mentionChips={mentionChips}
852
873
  listboxId={composerListboxId}
853
874
  listboxOpen={composerListboxOpen}
854
875
  activeOptionId={activeComposerOptionId}
@@ -993,6 +1014,78 @@ function syncOverlayGutter(
993
1014
  state.applied = scrollbar;
994
1015
  }
995
1016
 
1017
+ /**
1018
+ * The default chip look: the theme's primary at a wash, one shade stronger
1019
+ * than a skill reference's so the two token kinds in one overlay read as
1020
+ * siblings rather than the same thing. Deliberately no ring: every class here
1021
+ * is one the package already emitted, so a host build's CSS (and the portable
1022
+ * guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds
1023
+ * `box-shadow: 0 0 0 Npx <background>` through `tokenClassName` — paint, not
1024
+ * layout, per the metric rule above.
1025
+ */
1026
+ const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]';
1027
+
1028
+ /**
1029
+ * One token's span in the highlight overlay.
1030
+ *
1031
+ * METRIC RULE, load-bearing for every token kind: a span may change COLOR,
1032
+ * BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
1033
+ * that moves a glyph — padding, margin, border width, font-weight,
1034
+ * letter-spacing, font-size — would shift the overlay's text off the
1035
+ * textarea's own layout and drift the caret away from the painted glyphs. A
1036
+ * pill's breathing room is faked with a paint-only `box-shadow` ring in the
1037
+ * chip's own background color.
1038
+ */
1039
+ function renderTokenSpan(
1040
+ range: ComposerTokenRange,
1041
+ text: string,
1042
+ mentionTokenClassName?: string,
1043
+ ): React.ReactNode {
1044
+ if (range.kind === 'mention') {
1045
+ // `data-mention-token` / `data-mention-kind` are the host's styling hook;
1046
+ // `tokenClassName` is appended verbatim and is the host's to keep
1047
+ // metric-safe (see the rule above).
1048
+ return (
1049
+ <span
1050
+ key={`mention-${range.start}`}
1051
+ data-mention-token={range.person.id}
1052
+ data-mention-kind={range.person.kind}
1053
+ className={`${MENTION_CHIP_CLASSES}${
1054
+ mentionTokenClassName ? ` ${mentionTokenClassName}` : ''
1055
+ }`}
1056
+ >
1057
+ {text}
1058
+ </span>
1059
+ );
1060
+ }
1061
+ // Human-only tokens carry a quiet dotted underline as their inline marker
1062
+ // (text-decoration never affects glyph layout, so overlay alignment is
1063
+ // safe). The accessible explanation lives in HumanOnlySkillNotice below
1064
+ // the textarea — this overlay is aria-hidden.
1065
+ return (
1066
+ <span
1067
+ key={`skill-${range.start}`}
1068
+ data-skill-ref-token={range.skill.entry.name}
1069
+ data-skill-ref-human-only={range.skill.entry.humanOnly ? 'true' : undefined}
1070
+ className={`text-primary bg-primary/10 rounded-[3px] ${
1071
+ range.skill.entry.humanOnly
1072
+ ? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
1073
+ : ''
1074
+ }`}
1075
+ >
1076
+ {text}
1077
+ </span>
1078
+ );
1079
+ }
1080
+
1081
+ /** The mention half of the highlight layer. Null → no mention source at all. */
1082
+ export interface ComposerMentionChips {
1083
+ /** The people whose `@Label` token still survives in the text. */
1084
+ readonly people: readonly MentionPerson[];
1085
+ /** Host class appended to each chip (see `MentionSource.tokenClassName`). */
1086
+ readonly tokenClassName?: string;
1087
+ }
1088
+
996
1089
  interface ComposerTextareaProps {
997
1090
  value: string;
998
1091
  onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
@@ -1005,8 +1098,14 @@ interface ComposerTextareaProps {
1005
1098
  textareaRef: (el: HTMLTextAreaElement | null) => void;
1006
1099
  /** Positioned skill-reference occurrences to highlight. */
1007
1100
  tokens: SkillReferenceToken[];
1008
- /** Off → render the plain pre-feature textarea, byte-for-byte. */
1101
+ /** Off → no skill-reference contribution to the highlight layer. */
1009
1102
  skillReferences: boolean;
1103
+ /**
1104
+ * The mention contribution, or null with no `mentionSource`. Present (even
1105
+ * with nobody tagged yet) it turns the overlay on, so the first pick paints
1106
+ * a chip without swapping the textarea for a different element.
1107
+ */
1108
+ mentionChips: ComposerMentionChips | null;
1010
1109
  /** ARIA relationship to the skill-reference listbox. */
1011
1110
  listboxId: string;
1012
1111
  listboxOpen: boolean;
@@ -1014,13 +1113,15 @@ interface ComposerTextareaProps {
1014
1113
  }
1015
1114
 
1016
1115
  /**
1017
- * The composer's textarea. With `skillReferences` off this is exactly the
1018
- * pre-feature `<textarea>`; with it on, inserted skill-reference tokens are
1019
- * highlighted via a mirrored, aria-hidden overlay rendered BEHIND a
1020
- * transparent-text textarea (a textarea cannot style substrings). The overlay
1021
- * shares the exact font/padding/wrapping metrics and mirrors scroll position,
1022
- * and token spans change ONLY color/background (never font or weight), so the
1023
- * glyphs the browser lays out in the textarea and the glyphs the overlay
1116
+ * The composer's textarea. With NEITHER token source active this is exactly
1117
+ * the pre-feature `<textarea>`; with either one on, its tokens are painted by
1118
+ * a mirrored, aria-hidden overlay rendered BEHIND a transparent-text textarea
1119
+ * (a textarea cannot style substrings). ONE overlay serves both sources: two
1120
+ * mirrored layers could never stay pixel-aligned with each other, and only
1121
+ * one of them could own the scroll sync. The overlay shares the exact
1122
+ * font/padding/wrapping metrics and mirrors scroll position, and token spans
1123
+ * obey the metric rule on `renderTokenSpan` (paint only, never layout), so
1124
+ * the glyphs the browser lays out in the textarea and the glyphs the overlay
1024
1125
  * paints coincide. During IME composition the overlay hides and the textarea
1025
1126
  * text becomes visible again (`.pn-ref-composing`), keeping native
1026
1127
  * composition rendering (underlines, candidate highlights) intact.
@@ -1035,6 +1136,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1035
1136
  textareaRef,
1036
1137
  tokens,
1037
1138
  skillReferences,
1139
+ mentionChips,
1038
1140
  listboxId,
1039
1141
  listboxOpen,
1040
1142
  activeOptionId,
@@ -1063,6 +1165,23 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1063
1165
  [textareaRef],
1064
1166
  );
1065
1167
 
1168
+ // The overlay exists while EITHER source is active. `mentionChips` turns it
1169
+ // on for the whole life of a mention composer, not only once somebody is
1170
+ // tagged, so a pick never swaps the textarea element under the caret.
1171
+ const mentionPeople = mentionChips?.people;
1172
+ const highlight = skillReferences || mentionChips !== null;
1173
+
1174
+ // The one list of spans the overlay paints, from every active source.
1175
+ // Skill references are the earlier group, so they win a byte both claim.
1176
+ const ranges = useMemo(
1177
+ () =>
1178
+ mergeTokenRanges(value, [
1179
+ skillReferences ? skillTokenRanges(tokens) : [],
1180
+ mentionPeople ? mentionTokenRanges(value, mentionPeople) : [],
1181
+ ]),
1182
+ [value, tokens, skillReferences, mentionPeople],
1183
+ );
1184
+
1066
1185
  // Keep the mirror aligned when the value changes without a scroll event
1067
1186
  // (e.g. programmatic insertion moving the caret into a scrolled region).
1068
1187
  useEffect(() => {
@@ -1080,9 +1199,9 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1080
1199
  });
1081
1200
  observer.observe(el);
1082
1201
  return () => observer.disconnect();
1083
- }, [skillReferences, syncScroll]);
1202
+ }, [highlight, syncScroll]);
1084
1203
 
1085
- if (!skillReferences) {
1204
+ if (!highlight) {
1086
1205
  return (
1087
1206
  <textarea
1088
1207
  data-pn-mobile-editable="true"
@@ -1105,29 +1224,13 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1105
1224
 
1106
1225
  const segments: React.ReactNode[] = [];
1107
1226
  let pos = 0;
1108
- tokens.forEach((token, i) => {
1109
- if (token.start < pos || token.end > value.length) return; // stale tokens for a different value
1110
- if (token.start > pos) segments.push(value.slice(pos, token.start));
1111
- // Human-only tokens carry a quiet dotted underline as their inline marker
1112
- // (text-decoration never affects glyph layout, so overlay alignment is
1113
- // safe). The accessible explanation lives in HumanOnlySkillNotice below
1114
- // the textarea — this overlay is aria-hidden.
1227
+ for (const range of ranges) {
1228
+ if (range.start > pos) segments.push(value.slice(pos, range.start));
1115
1229
  segments.push(
1116
- <span
1117
- key={`${token.start}-${i}`}
1118
- data-skill-ref-token={token.entry.name}
1119
- data-skill-ref-human-only={token.entry.humanOnly ? 'true' : undefined}
1120
- className={`text-primary bg-primary/10 rounded-[3px] ${
1121
- token.entry.humanOnly
1122
- ? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
1123
- : ''
1124
- }`}
1125
- >
1126
- {value.slice(token.start, token.end)}
1127
- </span>,
1230
+ renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
1128
1231
  );
1129
- pos = token.end;
1130
- });
1232
+ pos = range.end;
1233
+ }
1131
1234
  segments.push(value.slice(pos));
1132
1235
 
1133
1236
  return (
@@ -1135,7 +1238,10 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1135
1238
  <div
1136
1239
  ref={overlayRef}
1137
1240
  aria-hidden="true"
1138
- data-skill-ref-overlay="true"
1241
+ // Names the SKILL-reference layer, so a skill composer's overlay is
1242
+ // byte-identical to the one it rendered before mentions existed; a
1243
+ // mentions-only overlay is found by its mirror attribute below.
1244
+ data-skill-ref-overlay={skillReferences ? 'true' : undefined}
1139
1245
  data-pn-mobile-editable-mirror="true"
1140
1246
  className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
1141
1247
  style={composing ? { visibility: 'hidden' } : undefined}
@@ -34,6 +34,12 @@ export interface UseMentionAutocompleteResult {
34
34
  select: (index: number) => void;
35
35
  /** The ids whose readable token still survives in the text. */
36
36
  mentionIds: readonly string[];
37
+ /**
38
+ * The same survivors as people, for a caller that needs their labels — the
39
+ * composer paints each surviving token as a chip. Frozen-empty with no
40
+ * source, the same treatment `mentionIds` gets.
41
+ */
42
+ mentions: readonly MentionPerson[];
37
43
  }
38
44
 
39
45
  /**
@@ -93,6 +99,7 @@ export function useMentionAutocomplete(options: {
93
99
  () => (enabled ? survivors.map((p) => p.id) : NO_IDS),
94
100
  [enabled, survivors],
95
101
  );
102
+ const mentions = enabled ? survivors : NO_PEOPLE;
96
103
 
97
104
  const onMentionsChange = source?.onMentionsChange;
98
105
  const mentionsKey = mentionIds.join('\u0000');
@@ -244,5 +251,6 @@ export function useMentionAutocomplete(options: {
244
251
  onSelect: readCaret,
245
252
  select,
246
253
  mentionIds,
254
+ mentions,
247
255
  };
248
256
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.43.2",
3
+ "version": "0.44.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The comment composer's TOKEN HIGHLIGHT LAYER, as pure ranges.
3
+ *
4
+ * A textarea cannot style substrings, so `CommentPopover` paints its text
5
+ * through one mirrored, aria-hidden overlay rendered behind a
6
+ * transparent-text textarea. That overlay used to know about exactly one kind
7
+ * of token (skill references). It now paints a MERGED list of ranges produced
8
+ * by one or more sources, so a second source (host `@` mentions) needs no
9
+ * second overlay — two mirrored layers could never stay pixel-aligned with
10
+ * each other, and only one of them could own the scroll sync.
11
+ *
12
+ * Everything here is pure: no DOM, no styling, no React. The component maps a
13
+ * range to its span (that is where Tailwind classes and `data-*` attributes
14
+ * live, so the class scanner still sees them); this module only decides WHICH
15
+ * bytes are a token and which token wins when two sources claim the same
16
+ * ones.
17
+ */
18
+ import { mentionToken, type MentionPerson } from './mentions';
19
+ import type { SkillReferenceToken } from './skillReferences';
20
+
21
+ /** One highlighted span of the composer's text, half-open `[start, end)`. */
22
+ export type ComposerTokenRange =
23
+ | {
24
+ readonly kind: 'skill';
25
+ readonly start: number;
26
+ readonly end: number;
27
+ readonly skill: SkillReferenceToken;
28
+ }
29
+ | {
30
+ readonly kind: 'mention';
31
+ readonly start: number;
32
+ readonly end: number;
33
+ readonly person: MentionPerson;
34
+ };
35
+
36
+ /**
37
+ * The skill-reference source: the positioned occurrences the autocomplete
38
+ * already found, unchanged. Order, spans and duplicates are preserved — a
39
+ * name referenced twice highlights twice.
40
+ */
41
+ export function skillTokenRanges(
42
+ tokens: readonly SkillReferenceToken[],
43
+ ): ComposerTokenRange[] {
44
+ return tokens.map((skill) => ({
45
+ kind: 'skill',
46
+ start: skill.start,
47
+ end: skill.end,
48
+ skill,
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * The mention source: every occurrence of each tagged person's readable
54
+ * `@Label` token in the text.
55
+ *
56
+ * Driven by the mention ID MODEL, never by a regex over arbitrary `@words`:
57
+ * the people passed in are the ones the author actually picked and whose
58
+ * token still survives (`survivingMentions`), so editing a byte of a token
59
+ * un-chips it in the same breath as it untags the person: a chip follows the
60
+ * body, never a stale pick. (The reported IDS can lag in one inherited case —
61
+ * a label that is a prefix of another label — see HANDOFF § "Mention token
62
+ * chips in the composer".)
63
+ *
64
+ * KNOWN, INHERITED LIMITATION: two people whose labels sanitize to the same
65
+ * token are indistinguishable in a plain-text body, so the FIRST of them
66
+ * listed owns every occurrence of it. That is the same first-match rule
67
+ * `survivingMentions` applies to the ids; it renders a chip either way and
68
+ * never throws.
69
+ */
70
+ export function mentionTokenRanges(
71
+ text: string,
72
+ people: readonly MentionPerson[],
73
+ ): ComposerTokenRange[] {
74
+ const ranges: ComposerTokenRange[] = [];
75
+ const claimed = new Set<string>();
76
+ for (const person of people) {
77
+ const token = mentionToken(person);
78
+ // A person whose label sanitizes to nothing would make every bare `@` a
79
+ // chip; and a token already claimed belongs to the person listed first.
80
+ if (token.length <= 1 || claimed.has(token)) continue;
81
+ claimed.add(token);
82
+ let from = text.indexOf(token);
83
+ while (from !== -1) {
84
+ ranges.push({ kind: 'mention', start: from, end: from + token.length, person });
85
+ from = text.indexOf(token, from + token.length);
86
+ }
87
+ }
88
+ return ranges;
89
+ }
90
+
91
+ /**
92
+ * The one list the overlay paints: every source's ranges, in document order,
93
+ * with overlaps resolved DETERMINISTICALLY and stale ranges dropped.
94
+ *
95
+ * `groups` is in priority order (earlier wins a tie). The rules, applied in
96
+ * this order at each position:
97
+ *
98
+ * 1. a range outside `[0, text.length)`, or empty/inverted, is dropped —
99
+ * these are ranges computed for a text the composer has since changed;
100
+ * 2. earlier `start` wins;
101
+ * 3. at the same start, the LONGER range wins (so `@Marcus Chen` beats a
102
+ * `@Marcus` that is also tagged, rather than chipping half of it);
103
+ * 4. at the same start and length, the earlier group wins;
104
+ * 5. a range that begins inside one already kept is dropped outright — the
105
+ * overlay is a sequence of non-overlapping spans and nothing may nest.
106
+ *
107
+ * With a single skill source this reproduces the pre-refactor loop exactly
108
+ * (which dropped a token whose `start` fell behind the cursor or whose `end`
109
+ * ran past the text); its ranges arrive sorted and non-overlapping, so rules
110
+ * 2-5 never fire.
111
+ */
112
+ export function mergeTokenRanges(
113
+ text: string,
114
+ groups: readonly (readonly ComposerTokenRange[])[],
115
+ ): ComposerTokenRange[] {
116
+ const candidates: { range: ComposerTokenRange; priority: number }[] = [];
117
+ groups.forEach((group, priority) => {
118
+ for (const range of group) {
119
+ if (!Number.isInteger(range.start) || !Number.isInteger(range.end)) continue;
120
+ if (range.start < 0 || range.end > text.length || range.end <= range.start) continue;
121
+ candidates.push({ range, priority });
122
+ }
123
+ });
124
+ candidates.sort(
125
+ (a, b) =>
126
+ a.range.start - b.range.start ||
127
+ b.range.end - a.range.end ||
128
+ a.priority - b.priority,
129
+ );
130
+ const merged: ComposerTokenRange[] = [];
131
+ let pos = 0;
132
+ for (const { range } of candidates) {
133
+ if (range.start < pos) continue;
134
+ merged.push(range);
135
+ pos = range.end;
136
+ }
137
+ return merged;
138
+ }
package/utils/mentions.ts CHANGED
@@ -45,6 +45,20 @@ export interface MentionSource {
45
45
  readonly emptyNotice?: string | null;
46
46
  /** Optional heading drawn above the list ("People in this workspace"). Absent → no heading row. */
47
47
  readonly heading?: string | null;
48
+ /**
49
+ * Optional class appended to each `@Label` chip the composer paints in its
50
+ * text (0.44.0), for a host that wants its own chip look.
51
+ *
52
+ * THE METRIC RULE IS THE HOST'S TO KEEP: the chip is painted by an overlay
53
+ * mirrored behind a transparent-text textarea, so it may change COLOR,
54
+ * BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
55
+ * that moves a glyph — padding, margin, border width, font-weight,
56
+ * letter-spacing, font-size — drifts the painted text off the textarea's
57
+ * own layout and takes the caret with it. Fake a pill's breathing room
58
+ * with `box-shadow: 0 0 0 Npx <background>`, which paints without
59
+ * occupying space.
60
+ */
61
+ readonly tokenClassName?: string;
48
62
  /** Fires on every text change with the ids whose token still survives in the body. */
49
63
  readonly onMentionsChange?: (ids: readonly string[]) => void;
50
64
  /**