@plannotator/ui 0.53.0 → 0.53.1

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
@@ -96,6 +96,12 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted
96
96
  | `identityGenerator` | `() => string` | The synchronous generator behind the default "tater" display name when no `identityProvider` is installed | A built-in 16 x 16 word pool of the same `adjective-noun-tater` shape; Plannotator registers the full dictionary via `utils/identity-tater` |
97
97
  | `alertIconRenderer` | `(name: string) => ReactNode | null` | The icon rendered for a GitHub alert whose title line carries `<!-- icon: name -->` (0.38.0; grammar in `utils/alertTitle`). Called only for a title line with an icon comment and no leading emoji; a null return falls back to the type icon | `null` for every name: the type's own icon, the package bundles no icon set |
98
98
  | `fenceTheme` | `(colorTheme: string, mode: 'light' \| 'dark') => string \| undefined` | The Shiki theme every fence-style surface renders in (Viewer fences, `CodeBlock`, the code-file hover preview, the plan diff view, code review suggestion snippets: everything that reads `useFenceTheme` / `resolveFenceTheme`). Return a theme name to use it, `undefined` to keep the default for that (palette, mode); a throw or an empty string also falls through, and a name Shiki does not know renders the block as plain text. Install it before the first render; it is read on every render, so a later change applies the next time a fence re-renders. The code-review diff pane's dark/light pair (`resolveSyntaxTheme`) is not affected. Also `setFenceThemeResolver` / `resetFenceThemeResolver` on `utils/syntaxTheme` (0.50.0) | The palette's `SHIKI_THEME_MAP` entry, else `pierre-dark` / `pierre-light`. Replaces mutating the exported `SHIKI_THEME_MAP`, which keeps working but is not a supported seam |
99
+ | `skillCatalogTransport` | `() => Promise<SkillCatalogEntry[]>` | The skill catalog the comment composers' `/` and `$` picker lists | `GET /api/skills` on the page origin |
100
+ | `skillContentTransport` | `(name: string) => Promise<unknown>` | A human-only skill's SKILL.md, pasted into feedback | `GET /api/skills/content?name=` on the page origin |
101
+ | `skillReferenceExport` | `boolean` | Whether the fetched catalog feeds each comment's "Skills referenced" list in the export; false for a host that composes one block per message (ui 0.53.0) | true |
102
+ | `skillInstructionsDestination` | `string` | What a composer's "Includes skill instructions" line says the instructions go with (ui 0.53.0) | `'your feedback'` |
103
+ | `webmcp` | `WebMcpPolicy` | The WebMCP provider policy, `{ enabled, namePrefix }` | Enabled whenever the browser exposes `document.modelContext`, prefix `plannotator.` |
104
+ | `componentTooltips` | `boolean` | Draw ui's own hover text through `components/HostTitle` as ui's `Tooltip` instead of a native `title` (ui 0.53.0) | false: native titles |
99
105
 
100
106
  ### Interface details worth knowing
101
107
 
@@ -964,6 +970,8 @@ that state out of `Viewer`, exactly as with `AnnotationToolstrip`'s
964
970
 
965
971
  ### 3. `CommentPopover` `mentionSource` — an `@` mention source for the composer
966
972
 
973
+ > **Agent tags (ui 0.53.1; opt-in, byte-identical when absent):** `MentionSource.kinds?: readonly ('user' | 'agent')[]` (default `['user']`) is the kinds `mentionMatches` offers; `MentionSource.matchDetail?: boolean` (default `true`) whether a query also matches `detail`; `MentionPerson.group?: string` makes `MentionPicker` draw each run of one group as `role="group"` (`data-mention-group`) labelled by its heading (`data-mention-group-heading`), the option ids unchanged (`<id>-option-<flat index>`); `MentionSource.renderNotice?: (tagged: readonly MentionPerson[]) => ReactNode` is drawn under the composer field in both `CommentPopover` layouts. The Plannotator Inbox uses all four to tag agent sessions in a file comment. `mentionGroups(people)` (`components/MentionPicker`) is the run splitter, exported for a host that draws its own grouped list.
974
+
967
975
  > **0.43.2 additions (opt-in, byte-identical when absent):** `MentionSource.heading?: string | null` draws one non-selectable heading row (`data-mention-heading`, e.g. "People in this workspace") above the list; `MentionPerson.avatar?: { url?, initials?, tint? }` draws an avatar (`data-mention-avatar="image" | "initials"`) before the label — an `<img>` when `url` is set, else `initials` (defaulting to the label's first letter) on a disc tinted with `tint` (any CSS color; absent means the muted surface). Neither the grammar, the keyboard state machine, `onMentionsChange`, `onPickBlocked` nor `Annotation.mentions` changes.
968
976
 
969
977
  ```ts
@@ -1718,6 +1726,31 @@ Everything host-facing that changed between ui 0.50.0 / core 0.25.10 and this pa
1718
1726
  - **"Ask this session" announcement (#1690):** new `components/AskSessionAnnouncementDialog`, `utils/askSessionAnnouncement` (cookie `plannotator-announce-ask-session-seen`) and `hooks/useFirstRunAnnouncementWindow`. Plannotator's apps mount it; nothing in the library mounts it for a host.
1719
1727
  - **gruvbox palette (#1721):** sets `--diffs-addition-color-override` (dark `#b8bb26`, light `#79740e`), so added diff lines draw in gruvbox green instead of the text colour. This changed the guides.show viewer stylesheet (see "Publishing & versioning").
1720
1728
 
1729
+ ## Two fewer forced layouts on document open (ui 0.53.1)
1730
+
1731
+ No API change. Two reads that forced a synchronous layout every time a
1732
+ `Viewer` mounted are gone (found by the Workspaces perf program: a warm
1733
+ document open spent most of its mount task in forced layouts):
1734
+
1735
+ - **`useVimSelection`** no longer resets on a mount, or on a `contentVersion`
1736
+ change, while vim was never on. The reset (state, focus, HUD, and the page
1737
+ selection inside the viewer through `window.getSelection()`) now runs only
1738
+ when vim goes from on to off. Behaviour change a host may notice: with vim
1739
+ off, a selection already inside the viewer's container is no longer
1740
+ cleared when the document mounts or its content changes; it is left to the
1741
+ browser, as on any page. Turning vim off still clears it, and a selection
1742
+ outside the container is never touched (0.46.1).
1743
+ - **`AnnotationToolstrip`**'s buttons no longer read `offsetWidth` in a layout
1744
+ effect. The label's width comes from layout: a hidden copy of the label sits
1745
+ in a one-column grid whose column animates `0fr` -> `1fr`, and the visible
1746
+ label is placed beside the icon with `inset-inline-start` (so it follows the
1747
+ icon under `dir="rtl"`) and clipped by the button. Collapsed
1748
+ size, padding, gap, the 180 ms curve, compact / iconOnly / touch rules and
1749
+ colours are unchanged; an expanded button is now its exact subpixel width
1750
+ (up to 0.5 px wider than the integer `offsetWidth` it used before). The
1751
+ button still carries the label twice in its text (the old hidden measure
1752
+ span did too); the copy is `aria-hidden`.
1753
+
1721
1754
  ## Viewer `answerOnly` (ui 0.53.0)
1722
1755
 
1723
1756
  Additive, one optional `Viewer` prop; Plannotator passes none, so its render is unchanged. Added for the Plannotator Inbox window (`packages/inbox`), which shows an agent's message with answerable `:::question` cards and takes no comments on the message text.
@@ -1726,6 +1759,12 @@ Additive, one optional `Viewer` prop; Plannotator passes none, so its render is
1726
1759
  - Inside, `Viewer` computes `authoringOff = readOnly || answerOnly` and uses it wherever it used `readOnly` for authoring; the question and checkbox handlers still read `readOnly`. Absent or false, every path is the one it was.
1727
1760
  - The article keeps its card padding; a host that wants the bare text (as the Inbox does) overrides it from its own CSS on `[data-print-region="article"]`.
1728
1761
 
1762
+ ## Viewer `textComments` (ui 0.53.1)
1763
+
1764
+ Additive, one optional `Viewer` prop; Plannotator's plan review and annotate pass none, so their render is unchanged. Added for the Plannotator Inbox (approval "inbox-message-comments", 2026-10-19), where the person comments on the words of an agent's message.
1765
+
1766
+ - **`textComments?: boolean`** on `Viewer`, with `answerOnly`: a text selection opens the selection toolbar again, comment-only (the toolbar's `commentOnly`: Comment and the one-click 👍, no Delete, no label picker), with its comment composer, and `annotations` are drawn as highlights (the highlighter is enabled). Question cards (and a host's `renderQuestionCard`) are wrapped `annotation-exclude`, so their words are never commented on. Nothing else of authoring comes back: no pinpoint, code-block toolbar, Global comment, Copy or Vim. Ignored without `answerOnly` and under `readOnly`. Inside, `selectionCommentsOn = !authoringOff || (answerOnly && textComments && !readOnly)` replaces `!authoringOff` for the highlighter and the selection toolbar, its composer and its label picker only.
1767
+
1729
1768
  ## Viewer `resolveLocalLink` (ui 0.53.0)
1730
1769
 
1731
1770
  Additive, one optional `Viewer` prop; Plannotator's plan review and annotate pass none, so their render is unchanged. Added for the Plannotator Inbox, where a link in a message to a file the message attached opens that file beside the thread.
@@ -1734,7 +1773,7 @@ Additive, one optional `Viewer` prop; Plannotator's plan review and annotate pas
1734
1773
 
1735
1774
  ## Viewer `questionDecisionLocked` (ui 0.53.0)
1736
1775
 
1737
- Additive, one optional `Viewer` prop; Plannotator passes none, so its render is unchanged. Added for the Plannotator Inbox (step 3, decisions), where one message can hold a sent question and an open one.
1776
+ Additive, one optional `Viewer` prop; Plannotator passes none, so its render is unchanged. Added for the Plannotator Inbox (step 3, decisions), where one message can hold a sent question and an open one. The Inbox no longer passes it: it records no decisions since #1985, parses its cards with `questionDecisionLines={false}` and locks sent questions with `questionReadOnly` instead.
1738
1777
 
1739
1778
  - **`questionDecisionLocked?: (question: IndexedQuestion) => boolean`**: true for a question passes it neither `onToggleQuestionDecisionRecording` nor `onOpenQuestionDecision`, so its decision tag draws as a plain tag (on or dimmed off, as `questionDecisionRecording` says), exactly as under `readOnly`. Other questions in the same `Viewer` keep their switch and opener. Absent or false, every path is the one it was.
1740
1779
 
@@ -1768,13 +1807,18 @@ Additive, opt-in; Plannotator passes none of it, so its render is unchanged. Add
1768
1807
 
1769
1808
  ## Publishing & versioning
1770
1809
 
1771
- - **core 0.25.13 / ui 0.53.0: both change. Order: `core` 0.25.13, then `ui` 0.53.0. guides.show is already deployed.** ui pins core `0.25.13` exactly. It imports no core subpath or export that 0.25.12 lacks (checked name by name), but the exact pin cannot resolve until 0.25.13 is on npm, so core still goes first. core 0.25.13 moves the guides.show viewer pin from `viewer.BYGvfCcj.js` + `viewer.CBpqTZpM.css` (0.25.12) to `viewer.Cs7U8_7B.js` + `viewer.BbPFXtTg.css`. Both files, `chunks/index.DU_F3bOf.js` and every `langs` chunk answer 200 on `https://guides.show/v1/`, and the served files match the manifest's SRI hashes, so no manual deploy is needed. The previous pairs were ui 0.52.0 and ui 0.52.1 on core 0.25.12. **No export is removed from either package**, so a consumer upgrades without code changes, but these behave differently and Workspaces should check them:
1810
+ - **core 0.25.13 / ui 0.53.0: both change. Order: `core` 0.25.13, then `ui` 0.53.0. guides.show is already deployed.** Both were published on 2026-10-18, cut from `main` at `7aba0e9a5` (#1985): the npm tarballs match that tree file for file, apart from the generated `styles.css` and bridge assets. That is later than the publish prep (#1898), so the pair also carries what merged between the two; the notable changes are listed after item 5 below. ui pins core `0.25.13` exactly and imports one core subpath that 0.25.12 lacks, `@plannotator/core/skill-reference-block` (#1914, from `utils/skillReferences`), so core goes first. core 0.25.13 moves the guides.show viewer pin from `viewer.BYGvfCcj.js` + `viewer.CBpqTZpM.css` (0.25.12) to `viewer.DERExLls.js` + `viewer.BjyNeslq.css`. Both files, `chunks/index.DfozTB5d.js` and all 59 `langs` chunks answer 200 on `https://guides.show/v1/`, and the two viewer files match the manifest's SRI hashes, so no manual deploy is needed. The previous pairs were ui 0.52.0 and ui 0.52.1 on core 0.25.12. **No export name is removed from either package** (checked name by name against core 0.25.12 and ui 0.52.1), but core removes two Inbox fields from three types (item 6), and these behave differently, so Workspaces should check them:
1772
1811
  1. **Block quotes and headings (ui `utils/parser`).** `parseMarkdownToBlocks` returns ONE `blockquote` block per run of `>` lines, its `content` the quote's inner markdown, rendered by the new `BlockquoteBlock` (fences, lists, nested quotes, tables inside one `<blockquote>`). Block ids after a quote that used to split shift down (restore re-anchors by text), and a host that reads a quote's `block.content` now sees fence and list markers. A heading now needs a space (or the line's end) after its `#`s, at top level too: `#123 fixed`, `#hashtag` and `#!/bin/sh` are paragraph text, no longer `<h1>`. See "Block quotes render their content (ui 0.53.0; …)".
1773
1812
  2. **Long choice labels (core `question-block`).** `parseQuestionAnswer` caps `selected` labels at the new `MAX_QUESTION_SELECTED_CHARS` (20,000) instead of 200, `canonicalQuestionAnswer` maps an answer an older build cut at 200 back to its full label, and `questionExportItems` passes `choices` for labels over 200 (`questionChoicesNeedCanonicalLabels`). A host that checks picks against the choices itself must not cut picked labels shorter than the labels it compares them with. See "Long choice labels are picked whole (core 0.25.13; …)".
1774
1813
  3. **Wider unions** (a compile error only in an exhaustive `switch`): `AgentToolHost` gains `'oh-my-pi'`; core `external-annotation`'s `session-closed` event has `by: "agent" | "session"` (ui's `useExternalAnnotations` passes it on, `utils/agentClosed` adds `closedReviewCopy`); core `inbox-types` gains the `"superseded"` question state and more `InboxRecordKind` / `InboxIdPrefix` members, and `inbox-questions` reads a bare negative word (`Stopped: no`) as no stop.
1775
1814
  4. **Smaller visible changes.** Core `CODE_FILE_REGEX` also matches `.mjs`, `.cjs`, `.mts` and `.cts`, so those paths link as code files; the Pi provider icon is the three-colour pi.dev mark (no longer `currentColor`); the question card's eyebrow pills share one 18 px box and an off decision tag is fainter; `hooks/useUpdateCheck` (Plannotator-only, see "not for hosts") no longer asks GitHub from the browser and returns null unless the server's advert is passed.
1776
1815
  5. **Questions (core `question-block`).** (a) `QUESTION_AUTHORING_GUIDE` was rewritten (2,213 to 2,654 characters) and now ends with an Inbox-only bullet naming `read_thread`, `supersedes` and `Replaces:`. A host that pastes the guide into its own agent prompts now passes on Plannotator Inbox wording; filter that bullet out or write your own. (b) `parseQuestionBlock` reads a `Replaces:` line whose every token is id-shaped (`msg_…/q-…`, or a bare `q-…`) into the new optional `ParsedQuestion.replaces` instead of context, wherever it sits in the block. Any other `Replaces:` line is prose as before, and `questionKey` is unchanged. (c) `QuestionAnswer` gains optional `by_reply` (kept by `parseQuestionAnswer` only beside `skipped: true`). `formatQuestionAnswersSection` prints such a skip as "Skipped (not answered in this reply)" and adds a count line telling the agent to treat those as skipped.
1777
1816
  Everything else is additive and inert until a host passes it: on `Viewer` `answerOnly`, `resolveLocalLink`, `questionDecisionLocked`, `renderQuestionCard` / `renderQuestionBelowPrompt`, `refRepo` and `onHostDraft` (also on `HtmlViewer` and `DiagramViewer`, with `DiagramHostDraft`); `ViewerHandle.stepPin` / `revealPin` / `takeSelection`; `HtmlViewer` `hostNavigates`; `AnnotationToolstrip` `hideRedline`; `RenderedMarkdown` `resolveLocalLink` / `resolveImage` / `headingAnchors`; the `PlanCleanDiffView` seams and `foldUnchangedPlanDiff`; `CodeAnnotation.markdownPreview`; `AnnotationPanel` `emptyHint`; `CodeFileView` (the body of `CodeFilePopout`, exported); the Oh My Pi setup hint; Plannotator's own update modules (`Settings` `updateCheckSetting`, `hooks/useUpdateToast`, `hooks/useUpdateCheckSetting`, `utils/updateNotice`). Core adds `crypto`'s `encryptWithKey` / `decryptWithKey` / `deriveRelayKeys`, `diff-files`' `isLineRangeInPatch`, `source-save`'s decided and cross-origin responses, an optional error on `serverSessionMismatchBody`, Inbox wire types (`inbox-attachments.ts`, `inbox-surface-bridge.ts`, both re-exported by `inbox-types`), and `annotatable`'s `isDotenvFileName(name)` (#1568): the dotenv-family rule on a file NAME (`.env`, any name ending in `.env`, `.envrc`, and `.env.*` outside the annotatable docs, so `.env.example` stays). The Inbox attachment rule and Plannotator's HTML asset route both use it; the attachment rule is unchanged.
1817
+ Merged after the publish prep (#1898) and shipped in this pair:
1818
+ 6. **Inbox decisions removed (core `inbox-types`, `inbox-questions`; #1985).** The wire types `InboxQuestionRecord` and `InboxQuestion` lose `decision_on_answer` and `decision_id`, and the parser's output `ParsedInboxQuestion` loses `decision_on_answer`: code that reads them no longer compiles. `parseInboxQuestionBlocks` parses with `{ decisionLines: false }`, so a `Decision:` line in an Inbox question is context. The Inbox decision types and helpers that were on `main` between the two publishes (`InboxDecision`, `inboxDecisionWords`, the search `decision` group and others) were never published.
1819
+ 7. **Question behaviour in every host** (plan review and annotate too): a choice question whose `Recommended:` names no choice shows its Suggested line, and a read-only card draws no empty Other row (#1906); "Accept recommended" stays on a pick-any card until the picks match, and a `Recommended:` list that names a whole choice whose description holds the separator now matches it (#1913); an answer's prompt is cut between grapheme clusters (`capQuestionPrompt`, #1906). For Inbox helpers: `composeInboxReplyBody` adds the still-open line to a reply with no answer (#1903), and `deriveInboxSubject` no longer cuts inside an emoji (#1899). See "Grapheme-safe cuts on `inbox-questions`", "Question prompts are cut whole", "Question cards: the Suggested line, the read-only Other row, a host status label", "A reply with no answers leaves the questions open" and "Accept recommended on pick-any questions".
1820
+ 8. **Smaller changes.** `Tooltip`'s popup draws at `z-[110]` (was `z-50`) and carries `data-pn-tooltip` (#1982). Four default wires send `serverSession` once a tab has adopted one (`adoptServerSession`), and do nothing new otherwise: `ExportModal`'s notes wire, `PlanDiffViewer`'s VS Code diff wire and `useAgentJobs` (#1921), and `OpenInAppButton` (#1915), whose server now refuses an `/api/open-in` without a catalog `appId`. All four are on the unsupported list.
1821
+ Additive and inert until a host passes it: `Viewer` `questionDecisionLines` with core's `ParseQuestionBlockOptions` (`decisionLines`) on `parseQuestionBlock` / `indexQuestionBlocks` (#1985; see "Questions without decision lines"), `questionReadOnly`, `questionStatusLabel` (`QuestionStatusLabel`), `imageLightboxStepping` and `onImageClick`; `AnnotationPanel` `compareAnnotations`, `title`, `titleNote` and `cardParts`, and the export-only `exportLocation` field the exporters print under an entry's heading (#1931; described in `README.md`); `components/ComposerTokenTextarea`, `HumanOnlySkillNotice` `destination`, `renderSkillReferencesBlock`, `normalizeSkillContent`, `setSkillCatalogExportRegistration` and `configurePlannotatorUI`'s `skillReferenceExport` / `skillInstructionsDestination` (#1914); `components/HostTitle`, `utils/textCut` and `configurePlannotatorUI`'s `componentTooltips` (#1982); `components/ImagePeek` and `ImageLightbox` `images` (#1944); `Tooltip` `shouldOpen`, `container` and `escapePassesThrough`; `CodeFileView` `overflow`; `hooks/useOpenInApps` and `utils/storage`'s `getStoredOpenInApp` (#1920); `utils/pasteInsert` (`installPlainTextPaste`, the Chrome 155-156 macOS paste workaround Plannotator installs, #1970); `SessionBridgeInfo.interruptible`, `hasNonInterruptibleSession` and `SessionAskNotice` `canInterrupt` (#1807: a bridge that advertises `interruptible: false` gets no "Interrupt and ask now", and its Stop reads "Stop listening"). Core adds the subpaths `file-kind` (#1916), `inbox-search` (#1902) and `skill-reference-block` (#1914), `open-in-apps`' `checkOpenInAppId` (#1915), `markdown-structure`'s `codeFenceFor` (#1914), `question-block`'s `capQuestionPrompt`, `inbox-questions`' `cutText` / `sliceText` / `inboxStillOpenLine` and the person-thread names (`derivePersonThreadSubject`, `suggestPersonThreadName`, #1956), and `inbox-types` now re-exports `inbox-open-in` (#1920). Also new in this pair, for hosts that read the Inbox's wire: `inbox-attachments`' video support (the `"video"` kind, `INBOX_VIDEO_BLOCK_ID`, `inboxVideoMeta`, `readInboxVideoTime`, `formatInboxVideoTime`; #1931) and `InboxInlineImage` (#1944); and in `inbox-types`, `InboxAck` / `INBOX_ACK_LABEL` (#1947, #1980), `InboxDraft*` / `InboxCompose*` / `InboxUpload` and the `upl` / `drf` id prefixes (#1917), `InboxNotifyReach` / `InboxWindowPermission` (#1967), `InboxThreadNaming` / `InboxThreadNamedBy` (#1956), `INBOX_TOOL_COST` / `inboxToolCostText` (#1962), `inboxRowOffersMarkRead` / `inboxRowWaitsOnPerson` (#1926) and `THREAD_NAME_BIDI` / `THREAD_NAME_CONTROL`. ui also adds `collectDocumentImages`, `LightboxImage` and `lightboxImageName` beside `ImageLightbox` (#1944), `COMPOSER_TEXT_CLASSES` and `ComposerMentionChips` on `components/ComposerTokenTextarea` (#1914), and `useRememberedOpenInApp` / `loadOpenInApps` in `hooks/useOpenInApps` (#1920).
1778
1822
  - **core 0.25.11 / ui 0.51.0: both change. Order: guides.show deploy, then `core` 0.25.11, then `ui` 0.51.0.** ui imports `canonicalQuestionAnswer` and `PI_THINKING_LEVELS`, which no published core before 0.25.11 has, and pins core `0.25.11` exactly. core 0.25.11 pins a new guides.show viewer stylesheet (`viewer.DuqkfIUz.css`, from the gruvbox fix #1721; the viewer JS `viewer.BYGvfCcj.js` is unchanged), which guides.show serves only after a deploy, so run the deploy by hand first (next bullet). **ui 0.51.0 is BREAKING for 0.50.0 consumers of the "Ask this session" exports**: it removes `findUsableSessionBridge`, `resolveSessionBridgeFallback`, the `'fallback'` `SessionAskAction` member, `SessionAskActions`' `fallbackLabel` and `DocumentAIChatPanel`'s `sessionAskFallbackLabel`, and makes a listed bridge the only Ask AI selection. Release notes must say so. Everything else is additive or a fix (including #1725's agent tool switch and offer); see "Ask this session (shipped in 0.50.0; …)", "Comment composer trim (0.51.0; …)", "Ask AI from a diagram comment (0.51.0; …)" and "Questions, restore, drafts and the rest (ui 0.51.0, core 0.25.11)".
1779
1823
  - **guides.show before a core publish that moves the viewer pin.** guides.show deploys on Plannotator release tags only. When a core publish carries a new `guide-viewer-manifest` hash (any change to the shared CSS or the guide chain) and no release tag has shipped it yet, run the deploy by hand first (`gh workflow run guides-show-deploy.yml --ref main`), then check that `curl -I https://guides.show/v1/<css>` and `<js>` from the manifest answer 200. Otherwise every host's guide pages load without that asset (core 0.25.9 hit this; fixed by a manual deploy). The deploy is add-only.
1780
1824
  - **core 0.25.9 / ui 0.49.0 (question host seams): both change. Publish `core` first**: ui imports the new `@plannotator/core/markdown-structure` subpath and the new `findQuestionBlocks` / decision fields, so a ui on a published core 0.25.8 fails to compile in a consumer. See "Question host seams (ui 0.49.0, core 0.25.9)".
@@ -1786,13 +1830,13 @@ Additive, opt-in; Plannotator passes none of it, so its render is unchanged. Add
1786
1830
  - **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)".
1787
1831
  - **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)".
1788
1832
  - **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.
1789
- - **The current pair is `@plannotator/ui` `0.41.2` on `@plannotator/core` `0.25.4`.** Core is UNCHANGED from 0.41.1, so 0.41.2 publishes alone (`ui` only; core 0.25.4 must already be published). 0.41.2 ships the palette-derived Mermaid node shadow at a default of 70% (`DEFAULT_MERMAID_SHADOW_AMOUNT`) and the Settings → Display "Diagram Shadow" control (0 / 40 / 70 / 100); see "Node shadow (the one thing that is not a colour)" under "Theme-aware Mermaid diagrams (0.40.0)" for the mapping — no API removal, only additive exports (`buildMermaidShadow`, `DEFAULT_MERMAID_SHADOW_AMOUNT`, `utils/diagramShadow`).
1833
+ - **The pair 0.41.2 shipped as was `@plannotator/ui` `0.41.2` on `@plannotator/core` `0.25.4`.** Core is UNCHANGED from 0.41.1, so 0.41.2 publishes alone (`ui` only; core 0.25.4 must already be published). 0.41.2 ships the palette-derived Mermaid node shadow at a default of 70% (`DEFAULT_MERMAID_SHADOW_AMOUNT`) and the Settings → Display "Diagram Shadow" control (0 / 40 / 70 / 100); see "Node shadow (the one thing that is not a colour)" under "Theme-aware Mermaid diagrams (0.40.0)" for the mapping — no API removal, only additive exports (`buildMermaidShadow`, `DEFAULT_MERMAID_SHADOW_AMOUNT`, `utils/diagramShadow`).
1790
1834
  - The pair 0.41.1 shipped as was `@plannotator/ui` `0.41.1` on `@plannotator/core` `0.25.4`. Core is UNCHANGED from 0.41.0, so 0.41.1 published alone (`ui` only; core 0.25.4 must already be published). 0.41.1 is two fixes over 0.41.0 with no API change — the diagram engine is loaded lazily by the first diagram fence instead of riding every document read, and a press on the canvas's own controls no longer comments on the part behind them; see "0.41.1 — the engine is lazy, and the controls are not part of the diagram". The 0.41.0 notes below still describe the engine itself.
1791
1835
  - **The pair 0.41.0 shipped as was `@plannotator/ui` `0.41.0` on `@plannotator/core` `0.25.4`. Publish `core` 0.25.4 first, then `ui` 0.41.0** (both by hand from `main` after merge; CI never publishes these packages). 0.41.0 is the diagram engine (see "Diagram engine (0.41.0)"): one renderer slot and one canvas behind `MermaidBlock` / `GraphvizBlock`, the `components/diagram` surface, the Graphviz runtime slot with `@viz-js/viz` pinned `3.30.0`, and `Annotation.diagramAnchor`; core 0.25.4 adds the `diagram-anchor` subpath ui imports, so a ui 0.41.0 on a published core 0.25.3 would fail to compile in a consumer.
1792
1836
  - The previous pair was `@plannotator/ui` `0.40.0` on `@plannotator/core` `0.25.3` (publish order the same). Three things shipped in 0.40.0: (1) **Mermaid 12.0.0**, pinned exactly (was `^11.17.2`): ELK layout by default for flowchart/state/class/ER/requirement, Safari 17.4+ / ES2024 floor, SVG ids byte-identical to 11 but `g.edgePaths` children now in declaration order, and the plan editor no longer imports `utils/mermaid-eager` (the lazy path is the default for everyone; hosts that want startup registration import the eager entry themselves) — see "Mermaid 12 (0.40.0)"; (2) **theme-aware Mermaid diagrams**: New additive exports `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`, `isDarkBackground`, `MERMAID_THEME_TOKEN_NAMES`) and `utils/cssColor` (parser + OKLab/contrast toolkit). `MermaidBlock` now calls `useTheme()` and `applyMermaidTheme` before each render; `MERMAID_CONFIG`, `loadMermaidRuntime`, `mermaid-eager` and `securityLevel: 'strict'` are unchanged. A host whose document carries no theme tokens renders diagrams byte-identically to 0.39.0; a host that mounts `ThemeProvider` with `theme.css` gets diagrams in its palette and mode with no configuration. No new peer dependencies; core unchanged. See "Theme-aware Mermaid diagrams (0.40.0)".; (3) **element context through the host seam (#1521, #1549), which is what moves `core` to 0.25.3:** `@plannotator/core/html-anchor` gains `parseHtmlElementContext`, `MAX_ELEMENT_CONTEXT_BYTES` and `MAX_PAGE_URL_LENGTH`; `PersistedHtmlAnchor.elementContext?` and `HtmlAnnotationTarget.context?` now round-trip through `buildPersistedHtmlAnchor` and `projectHostThreads`. **Core changes here, so bump and publish `core` first** and update UI's exact core dependency before packing ui — a ui build that imports these from an older published core fails to compile in a consumer, the 0.38.0 failure mode. `@plannotator/ui/components/html-viewer` re-exports the validator, so 0.39.0's import site is unchanged, and rows without context stay byte-identical on the wire. `utils/parser` gains `includeOutline` on `elementContextExportBlock` / `exportAnnotationEntry`, and `exportAnnotationEntry`'s `includeRoute` now defaults to true per field. See "Element context through the host seam".
1793
1837
  - The previous pair was `@plannotator/ui` `0.39.0` on `@plannotator/core` `0.25.2` (core unchanged; nothing under `packages/core` moved). UI 0.39.0 adds **element context** to raw-HTML and live-app pinpoint annotations (#1517, #1520): a new optional `Annotation.elementContext` (`HtmlElementContext` in `@plannotator/ui/types`) and `HtmlAnnotationTarget.context`, captured by the bridge at click time (tag, id, author classes, ancestor `path`, `role`, accessible `name`, an allowlisted `attrs` set with href/src scrubbed of query and fragment, rendered `text`, an adaptive collapsed HTML `outline`, child count, viewport `rect`, nearest `landmark` and `heading`, a `component` hint, and in live-app sessions `page`), hard-capped at 2 KiB serialized per primary and 1 KiB per extra target, and re-validated at the parent trust boundary by the new `parseHtmlElementContext` export of `@plannotator/ui/components/html-viewer`. New helpers on `@plannotator/ui/utils/parser`: `elementContextExportBlock(ann, { includeRoute })` (the fenced skeleton plus selector/path/role/name/attrs/text/box/near lines the full export now prints under a context-bearing comment) and `exportAnnotationEntry(ann, { includeRoute })` (one annotation as a standalone feedback entry, a pure helper for hosts; `AnnotationPanel`'s card chrome is unchanged from 0.38.2). The field is purely descriptive: `HtmlElementAnchor` and restore are untouched, no `BRIDGE_PROTOCOL_VERSION` bump, share links drop it like anchors, annotations without it export byte-identically, and the repaint path posts only anchors to the bridge. **Host persistence gap in 0.39.0 itself, closed in the next publish (#1521, #1549)**: as shipped, 0.39.0's `@plannotator/core/html-anchor` (`buildPersistedHtmlAnchor`, `projectHostThreads`) does not carry `elementContext`, so a host pinned to 0.39.0 that persists through those helpers drops it on save and must persist and project the field itself. The next publish carries it end to end — see "Element context through the host seam". Peer ranges are unchanged from 0.38.2: `react` / `react-dom` `^19.2.3`, `tailwindcss` as before, and `@codemirror/state ^6.7.2` beside `@codemirror/view ^6.43.10`. Decision-control change in the same window (#1516): the header primary reads `Send Feedback` / `Post Comments` with no inline count (`DecisionPrimary.count` removed; internal, not host-supported surface).
1794
1838
  - Before that, `@plannotator/ui` `0.38.2` on `@plannotator/core` `0.25.2`. UI 0.38.2 keeps the type word in a titled alert's accessible name through a visually hidden `sr-only` span before the title instead of an `aria-label` on the title row (naming a generic `div` is prohibited by ARIA and WebKit drops it, so VoiceOver on Safari read only the bold title in 0.38.1), and loosens the React peer back to `^19.2.3` (0.38.1 declared `^19.2.8` only because the dependency batch moved it; nothing in the package needs a newer API). **Do not consume ui 0.38.0**: it imports `@plannotator/core/token-hover` (the hover-card trigger settings, #1462) but pins core 0.25.1, which never exported that subpath, so it fails to compile in any consumer; 0.38.1 is the same UI pinning core 0.25.2, which publishes `./token-hover`, the rotated `guide-viewer-manifest` pin, and the `config-types` hover fields (core 0.25.2 is the first core publish since 0.25.1 even though those changes landed over several releases; the package smoke now diffs the UI's core imports against the registry so an unpublished core subpath fails preflight instead of the consumer). UI 0.38.1 also aligns `@codemirror/state` to `^6.7.2` beside `@codemirror/view ^6.43.10`, so a consumer can no longer resolve two state copies. UI 0.38.0 also renders a GitHub alert's bold-only first body line as its title on the icon row (an emoji on that line becomes the icon; `<!-- icon: name -->` is stripped and resolved through the new `alertIconRenderer` seam, null by default; grammar in `utils/alertTitle`, importable by a host editor so it writes the bytes the reader parses; a fenced code block inside an alert body still renders as text, deferred because nesting a `CodeBlock` inside a block interacts with the positional annotation anchors and needs its own design). UI 0.38.0 carries the whole unified decision-control stack: the internal primitives (`DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — not host-supported surface, see the unsupported list; `useDismissablePopover` also replaced the hand-rolled dismissal inside `ActionMenu`/`ApproveDropdown`, both likewise unsupported) plus one blessed-barrel addition: `decisionControlShortcuts` on `@plannotator/ui/shortcuts` (pure scope data, fetch-free, same contract as the other scopes). The removal of `ToolbarButtons`' platform-mode `muted` prop is internal — `ToolbarButtons` is not host-supported surface. UI 0.37.0 added the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases; core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`, and UI pins that corrected core exactly.
1795
- - Recent pairs, for the consumer's install matrix: ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published), ui 0.38.1, ui 0.38.2, and ui 0.39.0 on core 0.25.2, and ui 0.40.0 on core 0.25.3 (lockstep, `html-anchor` element context). Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export. Do not consume ui 0.38.0 because its exact core 0.25.1 dependency lacks the `token-hover` export.
1839
+ - Recent pairs, for the consumer's install matrix: ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published), ui 0.38.1, ui 0.38.2, and ui 0.39.0 on core 0.25.2, and ui 0.40.0 on core 0.25.3 (lockstep, `html-anchor` element context). Since then, from the registry: ui 0.41.0, 0.41.1 and 0.41.2 on core 0.25.4; ui 0.42.0, 0.43.0 to 0.43.2, 0.44.0, 0.45.0 and 0.45.1 on core 0.25.5; ui 0.46.0 and 0.46.1 on core 0.25.6; ui 0.47.0 on core 0.25.7; ui 0.49.0 on core 0.25.9 (core 0.25.8 and ui 0.48.0 were never published; their changes first shipped in ui 0.49.0 / core 0.25.9); ui 0.50.0 on core 0.25.10; ui 0.51.0 on core 0.25.11; ui 0.52.0 and 0.52.1 on core 0.25.12; ui 0.53.0 on core 0.25.13 (the current `latest` of both). Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export. Do not consume ui 0.38.0 because its exact core 0.25.1 dependency lacks the `token-hover` export.
1796
1840
  - When both packages change, **publish `core` first**: ui 0.32.0 imports the `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. Bump core, update UI's exact core dependency to the same new version, and run `bun install` so `bun.lock` records the new workspace versions before packing either package.
1797
1841
  - The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
1798
1842
  - UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
@@ -2259,15 +2303,15 @@ Plannotator's servers issue one random `serverSession` nonce per process (new `@
2259
2303
 
2260
2304
  `parseQuestionAnswer` used to cut every `selected` label to `MAX_QUESTION_CHOICE_LABEL_CHARS` (200). Task-list choices (`- [ ]`) are never cut by the parser, so a task-list label over 200 characters could never be picked: the card sent the whole label, the read-back answer held the first 200 characters, the card did not show it picked, and the Plannotator Inbox refused it (`422 "<first 200 chars>" is not one of the question's choices`). Now `selected` labels are capped at the new **`MAX_QUESTION_SELECTED_CHARS`** (= `MAX_QUESTION_BODY_CHARS`, 20,000: no label a block can hold is longer), so every label round-trips whole. `MAX_QUESTION_CHOICE_LABEL_CHARS` keeps its value and its one parse use (cutting a long plain-bullet label at a sentence end). **`canonicalQuestionAnswer`** also maps an answer an older build cut at 200 characters back to the one choice whose label (or alias) begins with it; two choices sharing their first 200 characters leave it as stored. That lookup is exact: a long label adds no case or punctuation matching (normalized matching still runs only for choices with `aliases`). `questionExportItems` now also passes `choices` when a label is over 200 characters (new **`questionChoicesNeedCanonicalLabels(choices)`** decides it), so the export prints the full label for such an answer; a host that builds its own `QuestionExportItem`s should use the same rule. The parser, labels and `questionKey` do not change. **A host that validates answers itself** (Workspaces' own check that a pick is one of the choices) must not cut picked labels shorter than the choice labels it compares them with.
2261
2305
 
2262
- ## Grapheme-safe cuts on `inbox-questions` (next core publish; additive, no new subpath)
2306
+ ## Grapheme-safe cuts on `inbox-questions` (core 0.25.13; additive, no new subpath)
2263
2307
 
2264
- `@plannotator/core/inbox-questions` gains two exports, **`cutText(text, max, ellipsis = "…")`** and **`sliceText(text, max)`** (source `packages/core/text-cut.ts`, which is deliberately NOT its own subpath, so core's export map stays the published one). Both count UTF-16 code units like `slice`, so a limit means what it did, but cut only between grapheme clusters (`Intl.Segmenter`, with a surrogate-pair fallback): an emoji, a flag, a family or a letter with its marks is kept whole or dropped whole, never drawn as "�". **`deriveInboxSubject`** now uses `cutText`, so a derived subject whose 120-character cut used to land inside an emoji ends before it instead; any other subject is byte-identical. The UI does not import them; publish them with the next core, no ui change needed. The question-prompt cut changed in the next section.
2308
+ `@plannotator/core/inbox-questions` gains two exports, **`cutText(text, max, ellipsis = "…")`** and **`sliceText(text, max)`** (source `packages/core/text-cut.ts`, which is deliberately NOT its own subpath, so core's export map stays the published one). Both count UTF-16 code units like `slice`, so a limit means what it did, but cut only between grapheme clusters (`Intl.Segmenter`, with a surrogate-pair fallback): an emoji, a flag, a family or a letter with its marks is kept whole or dropped whole, never drawn as "�". **`deriveInboxSubject`** now uses `cutText`, so a derived subject whose 120-character cut used to land inside an emoji ends before it instead; any other subject is byte-identical. The UI does not import them; they shipped in core 0.25.13, with no ui change needed. The question-prompt cut changed in the next section.
2265
2309
 
2266
- ## Question prompts are cut whole (next core publish; additive, no new subpath)
2310
+ ## Question prompts are cut whole (core 0.25.13; additive, no new subpath)
2267
2311
 
2268
2312
  `@plannotator/core/question-block` gains **`capQuestionPrompt(prompt)`**: the prompt an answer keeps, cut at `MAX_QUESTION_PROMPT_CHARS` (400) between grapheme clusters (`sliceText`), never inside an emoji. `parseQuestionAnswer` and `emptyQuestionAnswer` use it, and so does `inbox-questions` wherever it compares or stores a prompt, so the two never disagree (`checkInboxAnswer` also takes the plain 400-unit cut an older client made). A prompt of 400 code units or fewer is byte-identical; a longer one whose 400th unit is half of an emoji now ends before the emoji instead of with a lone surrogate. **A host that cuts prompts itself** (Workspaces' own answer check) should call `capQuestionPrompt` rather than `slice(0, 400)`, or an answer whose prompt was cut across an emoji will read as a different question. The Pi extension vendors `text-cut.ts` beside `question-block.ts` for this.
2269
2313
 
2270
- ## Question cards: the Suggested line, the read-only Other row, a host status label (ui, next publish; plan review and annotate change too)
2314
+ ## Question cards: the Suggested line, the read-only Other row, a host status label (ui 0.53.0; plan review and annotate change too)
2271
2315
 
2272
2316
  Three changes to `components/blocks/QuestionBlock`, from the Plannotator Inbox stress test (rows 36 to 38). The first two are bug fixes that show in plan review and annotate exactly as in the Inbox (the owner chose "fix them for everyone"); the third is an optional seam.
2273
2317
 
@@ -2277,33 +2321,33 @@ Three changes to `components/blocks/QuestionBlock`, from the Plannotator Inbox s
2277
2321
 
2278
2322
  ## Guided review cards: one count, any heading level (guide-viewer; the guides.show viewer is regenerated)
2279
2323
 
2280
- `@plannotator/guide-viewer` is not published, but code review and the guides.show viewer use it, so for the record: **`GuideSectionCard`** gains **`numbered?: boolean`** (default true). `GuideView` passes `numbered={false}` to the "Everything else" card and numbers the sections against `guide.sections.length`, so the header's "14 sections" and the cards' "01 / 14" agree (the cards used to read "01 / 15"). A card with no position and no Reviewed control draws no empty row. **`renderMarkdownProse`** reads `#` to `######` as the card's one sub-heading style (an `h3`), where only `### ` was a heading and `## Heading` showed its hashes; a heading also ends the paragraph above it. Seven hashes, or none followed by a space, are prose. The pinned viewer manifest (`@plannotator/core/guide-viewer-manifest`) is regenerated: 399.7 KB gz of the 400 KB budget (it was 399.6).
2324
+ `@plannotator/guide-viewer` is not published, but code review and the guides.show viewer use it, so for the record: **`GuideSectionCard`** gains **`numbered?: boolean`** (default true). `GuideView` passes `numbered={false}` to the "Everything else" card and numbers the sections against `guide.sections.length`, so the header's "14 sections" and the cards' "01 / 14" agree (the cards used to read "01 / 15"). A card with no position and no Reviewed control draws no empty row. **`renderMarkdownProse`** reads `#` to `######` as the card's one sub-heading style (an `h3`), where only `### ` was a heading and `## Heading` showed its hashes; a heading also ends the paragraph above it. Seven hashes, or none followed by a space, are prose. The pinned viewer manifest (`@plannotator/core/guide-viewer-manifest`) is regenerated: 399.7 KB gz of the 400 KB budget (it was 399.6). That was the figure for that build; the pin core 0.25.13 ships, `viewer.DERExLls.js`, is 335.1 KB gz by the same measure (gzip level 9, `apps/guides-show/build/check-budgets.ts`).
2281
2325
 
2282
- ## `CodeFileView` `overflow` (ui, next publish; additive, no core change)
2326
+ ## `CodeFileView` `overflow` (ui 0.53.0; additive, no core change)
2283
2327
 
2284
2328
  **`CodeFileView`** (`components/CodeFilePopout`) gains **`overflow?: 'scroll' | 'wrap'`**, handed to Pierre's `File`: `'scroll'` (the default, today's view) scrolls long lines sideways, `'wrap'` wraps them. `CodeFilePopout` passes nothing and is unchanged. The Inbox wraps a code file with a line over 1,000 characters (a minified bundle), with no toggle.
2285
2329
 
2286
- ## A reply with no answers leaves the questions open (next core publish; additive exports, one output change)
2330
+ ## A reply with no answers leaves the questions open (core 0.25.13; additive exports, one output change)
2287
2331
 
2288
2332
  `@plannotator/core/inbox-questions` gains **`inboxStillOpenLine(questions)`** and **`INBOX_STILL_OPEN_LINE`** (a RegExp matching one line of it). **`composeInboxReplyBody`** changes output in one case only: when `sent` is empty (the reply carries no answer and no skip), it now puts the still-open line after the words: "No questions answered yet; all N are still open. Don't re-ask them; the person will answer in the Inbox." (N: the questions still open or picked and not sent, counted over the new optional `stillOpen` input, default `questions`; the Inbox passes every question the replied-to message's session asked in the thread; the singular and an "earlier answers" variant are in the source; nothing when none is open). A body with at least one answer or skip is byte-identical. A host that composes Inbox replies with it gets the new line; a host that strips the store's text from a reply (as the window's `replyWords` does) should cut at a line `INBOX_STILL_OPEN_LINE` matches. `question-block.ts` (plan review's `formatQuestionAnswersSection`) is unchanged. No ui change.
2289
2333
 
2290
- ## Accept recommended on pick-any questions; a whole choice in a `Recommended:` list (ui and core, next publish; plan review, annotate and the Inbox change too)
2334
+ ## Accept recommended on pick-any questions; a whole choice in a `Recommended:` list (ui 0.53.0 and core 0.25.13; plan review, annotate and the Inbox change too)
2291
2335
 
2292
2336
  - **`QuestionBlock`**: on a `:::question-multi` card whose `Recommended:` names choices, "Accept recommended" now stays offered while the answer is not exactly those choices (`isRecommendedQuestionAnswer`), where it went away at the first tick. Accept still sets exactly `recommendedQuestionAnswer` (the recommended choices, extra ticks and Other cleared, the note kept), so after it the button goes away, and focus moves to the first checked choice (or the field holding the suggestion) instead of dropping to the page. Pick-any questions recommending only a suggestion, pick-one and text questions, settled questions and read-only cards are unchanged. No prop or DOM change otherwise.
2293
2337
  - **`parseQuestionBlock`** (core): a multi `Recommended:` list that names a choice by its full text, `— description` included, whose description holds the list's separator (`Reply box, Comments on attached files — one line of work, could ship first`), now matches every choice instead of none (it was a Suggested line). After the plain split fails, neighbouring parts are joined back, but only into a span that is exactly a choice's full text, label or name, and only when every part is covered. It runs only where the plain split failed, so a recommendation that split cleanly is unchanged; one main matched only through the final lettered fallback can now match more (plain bullets `a. Alpha`, `b. Beta — slow, steady` with `Recommended: a. Alpha first, b. Beta — slow, steady` named only `a.` and now names both).
2294
2338
  - **`Viewer` `questionReadOnly?: (question: IndexedQuestion) => boolean`** (additive): true draws that one question card read-only, exactly as under `readOnly` (no picks, Other, note, Skip or Accept recommended; its decision tag a plain tag), while the other questions in the document stay answerable. The Inbox passes it for every question whose state is not open or picked (sent, closed by a Mark read or the archive, superseded), so a sent card in a message whose other question is still open no longer offers to re-answer it (the store would take that pick and make the question picked again; the thread-state model says the window does not offer it, `adr/implementation/inbox-thread-state.md`, the Pick row). Absent (plan review, annotate): every question follows `readOnly`, unchanged.
2295
2339
 
2296
- ## Skill references outside the comment popover (ui + core, next publish; additive, Plannotator unchanged)
2340
+ ## Skill references outside the comment popover (ui 0.53.0 + core 0.25.13; additive, Plannotator unchanged)
2297
2341
 
2298
2342
  For the Plannotator Inbox's composers (approval "inbox-skill-references"). Every default reproduces today's markup and export byte for byte.
2299
2343
 
2300
2344
  - **`components/ComposerTokenTextarea`** (new, exported): the comment composer's textarea with its token highlight mirror, moved out of `CommentPopover` (which renders it with every default). New optional props: **`textClassName`** (classes on both the textarea and the mirror; default `COMPOSER_TEXT_CLASSES`, also exported), **`inputClassName`** / **`mirrorClassName`** (extra classes on one side), **`inputProps`** (further textarea attributes; an `aria-label` there replaces the placeholder default). `CommentPopover` re-exports the `ComposerMentionChips` type.
2301
- - **`HumanOnlySkillNotice`** gains **`destination?: string`**; without it the line uses the host's word, **`setSkillInstructionsDestination`** / **`configurePlannotatorUI({ skillInstructionsDestination })`**, default `'your feedback'`. **`SkillReferenceMenu`** rows carry **`data-skill-item-part="name" | "description" | "root"`** on their three spans (a styling hook; no visual change).
2302
- - **`utils/skillReferences`**: **`renderSkillReferencesBlock(refs, { contentOf?, injectedNames?, external?, audience? })`**, the block for references a caller already resolved; `skillReferenceExportBlock` now calls it with the reviewer wording (output unchanged). `audience` is a `SkillBlockAudience` from the new **`@plannotator/core/skill-reference-block`** (`REVIEWER_SKILL_AUDIENCE`, `INBOX_REPLY_SKILL_AUDIENCE`, `INBOX_MESSAGE_SKILL_AUDIENCE`, `skillsReferencedHeading`, `skillInstructionsIncludedNote`, and the Inbox's reader `splitInboxSkillsBlock` / `withoutInboxSkillsBlock`). ui now imports this new core subpath, so the next ui publish needs the next core.
2345
+ - **`HumanOnlySkillNotice`** gains **`destination?: string`**; without it the line uses the host's word, **`setSkillInstructionsDestination`** / **`configurePlannotatorUI({ skillInstructionsDestination })`**, default `'your feedback'`. **`SkillReferenceMenu`** rows carry **`data-skill-item-part="name" | "description" | "root"`** on their three spans (a styling hook; no visual change). **`SkillReferenceMenu`** also takes optional **`boundary?: () => { top, bottom } | null`** (the box to stay inside, default the viewport), **`prefer?: 'above' | 'below'`** (default above) and **`maxListHeight?`** (default 256), and **`computeMenuPlacement`** is exported; **`useSkillReferenceAutocomplete`** takes **`bareEscapeDismisses?`** (default off: Escape on a bare trigger passes through). Without them nothing changes (the Inbox's compose dialog passes them).
2346
+ - **`utils/skillReferences`**: **`renderSkillReferencesBlock(refs, { contentOf?, injectedNames?, external?, audience? })`**, the block for references a caller already resolved; `skillReferenceExportBlock` now calls it with the reviewer wording (output unchanged). `audience` is a `SkillBlockAudience` from the new **`@plannotator/core/skill-reference-block`** (`REVIEWER_SKILL_AUDIENCE`, `INBOX_REPLY_SKILL_AUDIENCE`, `INBOX_MESSAGE_SKILL_AUDIENCE`, `skillsReferencedHeading`, `skillInstructionsIncludedNote`, and the Inbox's reader `splitInboxSkillsBlock` / `withoutInboxSkillsBlock`). ui imports this new core subpath, so ui 0.53.0 needs core 0.25.13.
2303
2347
  - **`@plannotator/core/markdown-structure`** gains **`codeFenceFor(code)`** (three backticks, or one more than the longest backtick run in the code). Code review's feedback export fences Highlighted text, Replaces and Suggested code with it: byte-identical unless the quoted code holds a ``` run.
2304
2348
  - **`utils/skillCatalog`**: **`normalizeSkillContent`** is exported; **`setSkillCatalogExportRegistration(on)`** (host configuration: `resetSkillCatalogCache` leaves it alone), and **`configurePlannotatorUI({ skillReferenceExport })`** (default true): false keeps the fetched catalog out of the per-comment export, for a host that composes one block per message itself.
2305
2349
 
2306
- ## The image peek, a stepping lightbox, and `Viewer` image seams (ui, next publish; additive, no core change)
2350
+ ## The image peek, a stepping lightbox, and `Viewer` image seams (ui 0.53.0; additive, no core change)
2307
2351
 
2308
2352
  For the Plannotator Inbox's one viewer (approval "inbox-image-viewer", 2026-10-15). A host that passes none of the new props sees no change.
2309
2353
 
@@ -2311,7 +2355,7 @@ For the Plannotator Inbox's one viewer (approval "inbox-image-viewer", 2026-10-1
2311
2355
  - **`ImageLightbox`** gains **`images?: LightboxImage[]`** (`{ src, alt, section? }`): with two or more it is a full-screen stepping view ("2 of 4 · in “Options”", ← → and `]` `[`, the end note, Actual size, the pill and the peek); without it, the same single-image overlay byte for byte. Also exported: `collectDocumentImages(container)` and `lightboxImageName(image)`.
2312
2356
  - **`Viewer`** gains **`imageLightboxStepping?: boolean`** (the lightbox steps through every zoomable image of the rendered document; Plannotator's plan review and annotate pass it) and **`onImageClick?: (src, alt) => void`** (the host takes image clicks and no lightbox opens; the Inbox opens a message's captured image in its own viewer). Both absent: unchanged.
2313
2357
 
2314
- ## `Tooltip` `shouldOpen` (ui, next publish; additive, no core change)
2358
+ ## `Tooltip` `shouldOpen` (ui 0.53.0; additive, no core change)
2315
2359
 
2316
2360
  **`Tooltip`** (`components/Tooltip`) gains **`shouldOpen?: (trigger: Element) => boolean`**, asked each time the tooltip would open; `false` keeps it closed that time (Base UI's `onOpenChange` with `cancel()`). Unset, it always opens, as before. The Inbox passes a check that the trigger's text is cut (`isTextCut` in `utils/textCut.ts`: any whole pixel of `scrollWidth` over `clientWidth`, or the text's Range width over the box's content width by a hundredth of a pixel, since Chrome draws an ellipsis for a 0.06 px overflow that the integer widths hide), so a cut label shows its whole text only while it is cut.
2317
2361
 
@@ -2319,6 +2363,13 @@ For the Plannotator Inbox's one viewer (approval "inbox-image-viewer", 2026-10-1
2319
2363
 
2320
2364
  **`Tooltip`** also gains **`container?: HTMLElement | null`**, where the popup is portaled (Base UI's `Portal` `container`). Unset or null: the document body, as before. The Inbox passes the open native `<dialog>` a trigger sits in (its search palette, compose and its modal dialogs), since a popup in the body is drawn under the dialog's top layer. And **`escapePassesThrough?: boolean`**: Escape closes the tooltip without using the key up (the close is canceled, so Base UI neither prevents nor stops it, and the tooltip closes through its own state), so a page's Escape still works while a tooltip is open. Unset: Base UI's default, unchanged.
2321
2365
 
2366
+ ## Questions without decision lines (ui 0.53.0, core 0.25.13; additive)
2367
+
2368
+ For a host that records no decisions; the Plannotator Inbox passes it since its Decisions were removed (#1985). Every decision seam above is unchanged.
2369
+
2370
+ - **Core:** `parseQuestionBlock(kind, body, options?)` and `indexQuestionBlocks(blocks, options?)` take an optional **`ParseQuestionBlockOptions`** with **`decisionLines?: boolean`** (default true, the old parse). False: a `Decision:` line is kept as context, and the result never carries `decisionOnAnswer` or `decision`. A line the default parse reads still ends the choice, bullet or recommendation above it, so the prompt, `questionKey`, numbering and choices are the default parse's, and an answer stored against one parse matches the other.
2371
+ - **ui:** `Viewer` **`questionDecisionLines?: boolean`** (default true) passes that option to its question index, so with `false` no card gets a decision tag or row from a `Decision:` line. Absent: unchanged.
2372
+
2322
2373
  ## The law (guardrails for anyone editing `@plannotator/ui`)
2323
2374
 
2324
2375
  These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.
package/README.md CHANGED
@@ -19,6 +19,7 @@ configurePlannotatorUI({
19
19
  storageBackend, // where settings persist
20
20
  identityProvider, // who the current user is
21
21
  imageSrcResolver, // how image paths resolve to URLs
22
+ uploadTransport, // where pasted/attached images upload to
22
23
  docPreviewFetcher,
23
24
  fileTreeBackend,
24
25
  draftTransport,
@@ -34,6 +35,8 @@ configurePlannotatorUI({
34
35
  identityGenerator, // sync generator behind the default "tater" name (no identityProvider)
35
36
  alertIconRenderer, // (name) => ReactNode | null for a GitHub alert title line's <!-- icon: name --> (default: null, the type's icon)
36
37
  fenceTheme, // (colorTheme, mode) => Shiki theme name | undefined for code fences (default: SHIKI_THEME_MAP, else pierre-dark/light)
38
+ componentTooltips, // true: ui's own hover text (components/HostTitle) draws as ui's Tooltip, not a native title (ui 0.53.0; default false)
39
+ loadSettingsFromBackend, // true: re-hydrate settings from your (synchronous) storageBackend after install, applied last
37
40
  });
38
41
  ```
39
42
 
@@ -294,6 +297,16 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
294
297
  in a plain-text body, so the first person you list owns every occurrence of
295
298
  it; and a restored draft has no chips (and reports no ids) until the author
296
299
  picks again. No source → no overlay element at all.
300
+ - **Agents in the `@` picker** (ui 0.53.1; the Plannotator
301
+ Inbox's agent tags): `MentionSource.kinds?: ('user' | 'agent')[]` (absent →
302
+ users only, as before) lets a host offer agents; `MentionSource.matchDetail?:
303
+ boolean` (absent → true) keeps a query from matching the `detail` line;
304
+ `MentionPerson.group?: string` draws consecutive people of one group under a
305
+ heading, each run a `role="group"` labelled by it (absent on every person →
306
+ the flat list); `MentionSource.renderNotice?: (tagged) => ReactNode` draws a
307
+ host line under the composer field from the people the draft still tags
308
+ (`CommentPopover`). All optional; a host passing none renders exactly as
309
+ before.
297
310
  - **`Viewer` / `HtmlViewer` `mentionSource`** (0.43.1): the same prop on the two
298
311
  viewers, forwarded to every comment composer each of them mounts, so a host
299
312
  wires mentions once per surface instead of per composer. The ids the author
@@ -350,17 +363,17 @@ Neither prop reaches `CodeAnnotation` cards (the review-editor shape), which
350
363
  take no `renderCardFooter` either. See HANDOFF.md § "Annotation card header
351
364
  slot and mentions on the edit box (0.45.0)".
352
365
 
353
- - **`compareAnnotations?: (a, b) => number`** (unreleased) — the order of the
366
+ - **`compareAnnotations?: (a, b) => number`** (ui 0.53.0) — the order of the
354
367
  comment timeline and of each All-files group, for comments with an order of
355
368
  their own (the Inbox sorts a video's comments by the moment they are at).
356
369
  Ties keep creation order; replies still thread under their parent. Omit it
357
370
  and the panel sorts by creation exactly as before.
358
371
  - **`title?`, `titleNote?`, `cardParts?: (annotation) => { quote?, images? }`**
359
- (unreleased) — the header's title (default "Annotations") and a quiet note
372
+ (ui 0.53.0) — the header's title (default "Annotations") and a quiet note
360
373
  at its right, and per card whether the built-in quote and image row are
361
374
  drawn (the Inbox's video comments draw the moment and the frame in
362
375
  `renderCardHeader` instead). Omit them and the panel is as before.
363
- - **Export: `exportLocation`** (unreleased) — a string a host sets on the
376
+ - **Export: `exportLocation`** (ui 0.53.0) — a string a host sets on the
364
377
  COPY of an annotation it hands the exporters (`exportAnnotations`,
365
378
  `exportLinkedDocAnnotations`): one line printed under the entry's heading
366
379
  (the Inbox's `At 1:42 (102.4 s of 7:41)`). Never stored; an annotation
@@ -418,13 +431,15 @@ See HANDOFF.md § "Question decision toggle and status tag (ui 0.52.0; …)".
418
431
 
419
432
  See HANDOFF.md § "Decision toggle on any question, and an opener for the host's decision card (ui 0.52.1, …)".
420
433
 
421
- **Questions without decision lines (after ui 0.53.0, core 0.25.13).** `Viewer` `questionDecisionLines?: boolean` (default `true`, today's behaviour). `false` parses every `:::question` card with core's new `{ decisionLines: false }` option (`parseQuestionBlock(kind, body, options?)`, `indexQuestionBlocks(blocks, options?)`): a `Decision:` line is shown as context, and no card gets a decision tag or row from it. The prompt, key, numbering and choices are the default parse's (the line still ends a choice right above it). For a host that records no decisions; the Plannotator Inbox passes it since Decisions were removed there (2026-10). Every decision seam above is unchanged.
434
+ **Questions without decision lines (ui 0.53.0, core 0.25.13).** `Viewer` `questionDecisionLines?: boolean` (default `true`, today's behaviour). `false` parses every `:::question` card with core's new `{ decisionLines: false }` option (`parseQuestionBlock(kind, body, options?)`, `indexQuestionBlocks(blocks, options?)`): a `Decision:` line is shown as context, and no card gets a decision tag or row from it. The prompt, key, numbering and choices are the default parse's (the line still ends a choice right above it). For a host that records no decisions; the Plannotator Inbox passes it since Decisions were removed there (2026-10). Every decision seam above is unchanged.
422
435
 
423
436
  **Answers without comments (ui 0.53.0).** `Viewer` `answerOnly`: annotation authoring and the document actions (Global comment, Copy) are off as under `readOnly`, while question cards and checkboxes keep their handlers, for a host that shows a message to answer, not a document to annotate. See HANDOFF.md § "Viewer `answerOnly` (ui 0.53.0)".
424
437
 
438
+ **Comments on an answer-only document (ui 0.53.1).** `Viewer` `textComments`, with `answerOnly`: the text-selection toolbar (comment-only) and its composer come back, question cards stay out of selection, nothing else of authoring does. See HANDOFF.md § "Viewer `textComments` (ui 0.53.1)".
439
+
425
440
  **Local links decided by the host (ui 0.53.0).** `Viewer` `resolveLocalLink`: the host decides what a link to a local path does (open it itself, or draw it as inert text), markdown and raw HTML alike. See HANDOFF.md § "Viewer `resolveLocalLink` (ui 0.53.0)".
426
441
 
427
- ### Diff file tree (`components/DiffFileTree`; next ui release)
442
+ ### Diff file tree (`components/DiffFileTree`; ui 0.50.0)
428
443
 
429
444
  A read-only file tree for a list of changed files, to sit beside a host's own per-file diff list. It is Plannotator's code-review tree, not a lookalike: the builder (`utils/diffFileTree`), the folder open/closed hook (`hooks/useDiffFileTreeExpansion`) and the row atoms moved here from `packages/review-editor`, and the review now renders from them.
430
445
 
@@ -766,9 +766,38 @@ function formatTimestamp(ts: number): string {
766
766
  if (hours < 24) return `${hours}h`;
767
767
  if (days < 7) return `${days}d`;
768
768
 
769
- return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
769
+ // An older date names its year: "Oct 20" alone reads as this October.
770
+ const date = new Date(ts);
771
+ const thisYear = date.getFullYear() === new Date(now).getFullYear();
772
+ return date.toLocaleDateString(
773
+ undefined,
774
+ thisYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' },
775
+ );
770
776
  }
771
777
 
778
+ /**
779
+ * A card's "author · time" line. The author truncates (its full name in the
780
+ * title) while the time never shrinks, so a long name cannot push the time
781
+ * out; `<bdi>` keeps a right-to-left name from reordering the time around it.
782
+ * The text reads exactly as before: "Alice (me) · 5m".
783
+ */
784
+ const CardByline: React.FC<{ author?: string; isMe: boolean; timestamp: number }> = ({ author, isMe, timestamp }) => (
785
+ <span data-annotation-byline="true" className="flex min-w-0 items-center text-[10px] text-muted-foreground/50">
786
+ {author ? (
787
+ <>
788
+ <HostTitle title={author} cut>
789
+ <span data-annotation-author="true" className="min-w-0 truncate">
790
+ <bdi>{author}</bdi>
791
+ </span>
792
+ </HostTitle>
793
+ {isMe ? <span data-annotation-me="true" className="shrink-0 whitespace-pre"> (me)</span> : null}
794
+ <span className="shrink-0 whitespace-pre"> · </span>
795
+ </>
796
+ ) : null}
797
+ <span data-annotation-time="true" className="shrink-0">{formatTimestamp(timestamp)}</span>
798
+ </span>
799
+ );
800
+
772
801
  /** Pinned card for committed direct edits: +N/−M summary, expandable unified
773
802
  * diff, and a two-step discard. Not part of the annotation timeline — edits
774
803
  * are document state, not a selection-anchored note. */
@@ -1056,9 +1085,7 @@ const AnnotationCard: React.FC<{
1056
1085
  </span>
1057
1086
  </HostTitle>
1058
1087
  )}
1059
- <span className="text-[10px] text-muted-foreground/50 truncate">
1060
- {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
1061
- </span>
1088
+ <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdA} />
1062
1089
  {/* Host header slot (a status stamp etc.) — interactions inside it
1063
1090
  must not toggle card selection, exactly like the footer slot.
1064
1091
  Rendered under readOnly too: its contents are host-owned. */}
@@ -1103,7 +1130,7 @@ const AnnotationCard: React.FC<{
1103
1130
  isEditing ? (
1104
1131
  editComposer
1105
1132
  ) : (
1106
- <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1133
+ <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1107
1134
  {annotation.text}
1108
1135
  </p>
1109
1136
  )
@@ -1111,7 +1138,7 @@ const AnnotationCard: React.FC<{
1111
1138
  <>
1112
1139
  {/* Quote — the annotated text */}
1113
1140
  {parts?.quote !== false && (
1114
- <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1141
+ <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1115
1142
  "{annotation.originalText}"
1116
1143
  </p>
1117
1144
  )}
@@ -1122,7 +1149,7 @@ const AnnotationCard: React.FC<{
1122
1149
  editComposer
1123
1150
  ) : (
1124
1151
  annotation.text && (
1125
- <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1152
+ <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1126
1153
  {annotation.text}
1127
1154
  </p>
1128
1155
  )
@@ -1224,9 +1251,7 @@ const CodeAnnotationCard: React.FC<{
1224
1251
  {/* Header: type word + author · time + actions */}
1225
1252
  <div className="mb-1.5 flex items-center gap-1.5">
1226
1253
  <span className="text-[11px] font-medium text-primary">Code</span>
1227
- <span className="text-[10px] text-muted-foreground/50 truncate">
1228
- {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)}
1229
- </span>
1254
+ <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdAt} />
1230
1255
  {!readOnly && (
1231
1256
  <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1232
1257
  {onEdit && !isEditing && (
@@ -1261,7 +1286,7 @@ const CodeAnnotationCard: React.FC<{
1261
1286
  </HostTitle>
1262
1287
 
1263
1288
  {annotation.originalCode && (
1264
- <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1289
+ <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1265
1290
  {annotation.originalCode}
1266
1291
  </p>
1267
1292
  )}
@@ -1294,7 +1319,7 @@ const CodeAnnotationCard: React.FC<{
1294
1319
  </div>
1295
1320
  ) : (
1296
1321
  annotation.text && (
1297
- <p className="mt-1.5 whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1322
+ <p className="mt-1.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1298
1323
  {annotation.text}
1299
1324
  </p>
1300
1325
  )
@@ -1,4 +1,4 @@
1
- import React, { useState, useRef, useLayoutEffect, useEffect } from 'react';
1
+ import React, { useState, useEffect } from 'react';
2
2
  import type { EditorMode, InputMethod } from '../types';
3
3
  import { TaterSpritePullup } from './TaterSpritePullup';
4
4
 
@@ -291,10 +291,26 @@ const ICON_SIZE = 28; // collapsed button width (px)
291
291
  const H_PAD = 10; // horizontal padding when expanded (px) — matches px-2.5
292
292
  const GAP = 6; // gap between icon and label (px) — matches gap-1.5
293
293
  const ICON_INNER = 14; // icon element width (px)
294
+ const COLLAPSED_PAD = (ICON_SIZE - ICON_INNER) / 2;
294
295
  const DURATION = 180; // transition ms
296
+ const EASE = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
295
297
 
296
298
  /* ─── Button ─── */
297
299
 
300
+ /**
301
+ * One toolstrip button: the icon, and a label that slides out beside it.
302
+ *
303
+ * The width comes from layout, never from a JS measurement. A hidden copy of
304
+ * the label sits in a one-column grid whose column animates `0fr` -> `1fr`, so
305
+ * the button's shrink-to-fit width runs from the icon-only 28 px to the icon,
306
+ * the gap and the label; the side padding animates 7 -> 10 px with it. The
307
+ * width at every frame matches the explicit-width version this replaced
308
+ * (28 + (12 + label) x progress, same 180 ms curve). The visible label is
309
+ * positioned beside the icon and clipped by the button's own edge, as before:
310
+ * a grid track laid out at a fraction of its width reveals its content more
311
+ * slowly than the button grows, so the label is not drawn inside it.
312
+ * Reading `offsetWidth` per button forced a layout on every document open.
313
+ */
298
314
  const ToolstripButton: React.FC<{
299
315
  active: boolean;
300
316
  onClick: () => void;
@@ -306,18 +322,9 @@ const ToolstripButton: React.FC<{
306
322
  iconOnly?: boolean;
307
323
  }> = ({ active, onClick, icon, label, color, mounted, compact = false, iconOnly = false }) => {
308
324
  const [hovered, setHovered] = useState(false);
309
- const [labelWidth, setLabelWidth] = useState(0);
310
- const measureRef = useRef<HTMLSpanElement>(null);
311
325
  const styles = colorStyles[color];
312
326
  const [isTouchDevice] = useState(() => 'ontouchstart' in window || navigator.maxTouchPoints > 0);
313
327
 
314
- // Measure label text width synchronously before first paint
315
- useLayoutEffect(() => {
316
- if (measureRef.current) {
317
- setLabelWidth(measureRef.current.offsetWidth);
318
- }
319
- }, [label]);
320
-
321
328
  // iconOnly: never expand (mobile sticky lane).
322
329
  // compact: only active expands (sm+ sticky lane — shows current mode).
323
330
  // default: active or hovered expands (top-of-doc full toolstrip).
@@ -326,8 +333,7 @@ const ToolstripButton: React.FC<{
326
333
  : compact
327
334
  ? active
328
335
  : (active || hovered || isTouchDevice);
329
- const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD;
330
- const currentWidth = expanded ? expandedWidth : ICON_SIZE;
336
+ const pad = expanded ? H_PAD : COLLAPSED_PAD;
331
337
 
332
338
  const colorClass = active
333
339
  ? styles.active
@@ -335,12 +341,9 @@ const ToolstripButton: React.FC<{
335
341
  ? styles.hover
336
342
  : styles.inactive;
337
343
 
344
+ const slide = (property: string) => `${property} ${DURATION}ms ${EASE}`;
338
345
  const transition = mounted
339
- ? `width ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease`
340
- : 'none';
341
-
342
- const innerTransition = mounted
343
- ? `padding-left ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`
346
+ ? `${slide('padding-inline-start')}, ${slide('padding-inline-end')}, background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease`
344
347
  : 'none';
345
348
 
346
349
  return (
@@ -349,34 +352,36 @@ const ToolstripButton: React.FC<{
349
352
  onMouseEnter={() => setHovered(true)}
350
353
  onMouseLeave={() => setHovered(false)}
351
354
  aria-pressed={active}
352
- className={`relative flex items-center h-7 rounded-md overflow-hidden ${colorClass}`}
353
- style={{ width: currentWidth, transition }}
355
+ className={`relative flex items-center h-7 rounded-md overflow-hidden whitespace-nowrap ${colorClass}`}
356
+ style={{ paddingInlineStart: pad, paddingInlineEnd: pad, transition }}
354
357
  >
355
- {/* Inner flex container — fixed layout, no layout-shifting properties animated */}
356
- <div
357
- className="flex items-center whitespace-nowrap"
358
- style={{ paddingLeft: expanded ? H_PAD : (ICON_SIZE - ICON_INNER) / 2, gap: GAP, transition: innerTransition }}
358
+ {icon}
359
+ {/* The label people see, beside the icon, clipped at the button's edge. */}
360
+ <span
361
+ className="absolute inset-y-0 flex items-center text-xs font-medium"
362
+ style={{
363
+ insetInlineStart: pad + ICON_INNER + GAP,
364
+ opacity: expanded ? 1 : 0,
365
+ transition: mounted
366
+ ? `${slide('inset-inline-start')}, opacity ${expanded ? DURATION : DURATION * 0.6}ms ease ${expanded ? '60ms' : '0ms'}`
367
+ : 'none',
368
+ }}
359
369
  >
360
- {icon}
361
- <span
362
- className="text-xs font-medium"
363
- style={{
364
- opacity: expanded ? 1 : 0,
365
- transition: mounted ? `opacity ${expanded ? DURATION : DURATION * 0.6}ms ease ${expanded ? '60ms' : '0ms'}` : 'none',
366
- }}
367
- >
368
- {label}
369
- </span>
370
- </div>
371
-
372
- {/* Hidden measurement span — rendered offscreen to get label pixel width */}
370
+ {label}
371
+ </span>
372
+ {/* The width: a hidden copy of the gap and label in a column that
373
+ animates 0fr -> 1fr. min-width 0 and overflow hidden let it reach 0. */}
373
374
  <span
374
- ref={measureRef}
375
- className="text-xs font-medium absolute pointer-events-none"
376
- style={{ visibility: 'hidden', position: 'absolute', left: -9999 }}
377
375
  aria-hidden
376
+ style={{
377
+ display: 'grid',
378
+ gridTemplateColumns: expanded ? '1fr' : '0fr',
379
+ transition: mounted ? slide('grid-template-columns') : 'none',
380
+ }}
378
381
  >
379
- {label}
382
+ <span style={{ minWidth: 0, overflow: 'hidden', visibility: 'hidden' }}>
383
+ <span className="block text-xs font-medium" style={{ paddingInlineStart: GAP }}>{label}</span>
384
+ </span>
380
385
  </span>
381
386
  </button>
382
387
  );