Repository navigation
[Tracking] Inline link icons through a hyperlink mark view #512
Description
Summary
Every link in a docs.plus pad will show a small icon before its text:
- docs.plus links get the docs.plus logo;
mailto:andtel:links get a glyph;- other
http(s)links get the site favicon.
The favicon comes from our own REST route, never from Google or the linked site. A link with no icon looks like a plain link.
The mechanism ships in @docs.plus/extension-hyperlink 2.2.0 as a public option, so any Tiptap host can use it:
linkIcon, a sync resolver that returns an element ornull;- a mark view that draws that element at the start of the
<a>, outsidecontentDOM; - two helpers:
linkIconMap, an offline domain table, andlinkIconFetcher, which builds one<img>per link from an icon URL per origin.
The webapp wires the chain, and the REST server adds GET /api/metadata/icon. Four side issues clean up related code.
The maintainer rejected the Decoration.widget design for its per-keystroke cost and chose a mark view. Keystroke dispatch cost, median, from one local bench (laptop, jsdom view, no layout):
| Link blocks | No icon | Widget design | Mark view |
|---|---|---|---|
| 2000 | 0.108 ms | 1.150 ms | 0.121 ms |
| 5000 | 0.239 ms | 6.433 ms | 0.250 ms |
- 5000 shows the worst case, where the widget cost is largest. 2000 is the gate size in Show a link icon before every pad link in the webapp #516, an engineering default.
- The Chrome gates in Show a link icon before every pad link in the webapp #516 Step 9 are the numbers that bind.
Real-browser check (2026-10-18). A throwaway build of this design ran in Chromium 151 and WebKit 26.5 through Playwright, with layout on. It used prosemirror-view 1.42.3, the 150-origin icon stub and 500 / 2000 / 5000 link blocks. Firefox was not run. Every gate in #516 Step 9 passed:
| At 2000 link blocks, icons on minus off | Chromium | WebKit |
|---|---|---|
| Keystroke dispatch, median (plain, in a link, at a block-start link) | −0.1 ms | 0 ms (1 ms clock) |
| Cold mount, sync / to the next frame | +10.4 / +22.8 ms | +22 / +23 ms |
| Mark views, resolver calls, images and requests per local or remote keystroke | 0 | 0 |
| Icon requests on a cold open | at most 1 per URL (149 URLs) | at most 1 per URL |
- Cascade: on y-tiptap 3.0.9, a remote insert at the top rebuilds every link and icon: +22 ms at 2000 and +38 ms at 5000 (Chromium, sync), with 0 new requests. On y-tiptap 3.0.10 it rebuilds 1. So Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 first is the recommended order.
- Memory and harness proof: see §Memory and §Proof that the harness can fail, under §Tests and benchmarks.
- Labels: laptop, Chromium headless or WebKit headless, layout on. Re-run on prosemirror-view 1.42.6 after Bump TipTap packages to 3.32.0 for the remote-cursor fix #473.
Maintainer rulings
Quoted words are the maintainer's own. Where no quote was kept, the ruling is stated as recorded. These rulings are also posted as one Maintainer ruling, 2026-10-17 comment on this tracker.
2026-10-16
- Favicons, from our own route. "for webapp we have to work on own fetcher also and put it into the hocuspocus". Here "fetcher" means the server side: the icon route in hocuspocus. The client helper is
linkIconFetcher.
2026-10-17, on the plan
- Parity exception. Every link in the pad shows its icon automatically, with no opt-in. This goes against Google Docs, Word and Notion (3 of 3) on purpose, like Revise the heading size scale to follow a documented typographic convention #369. Do not "fix" it back. Show a link icon before every pad link in the webapp #516 records it in
AGENTS.mdbeside Revise the heading size scale to follow a documented typographic convention #369.- Sources: Google Docs links https://support.google.com/docs/answer/45893 and smart chips https://support.google.com/docs/answer/13543219; Word for the web https://insider.microsoft365.com/en-us/blog/preview-links-in-word-for-the-web; Notion https://www.notion.com/help/create-links-and-backlinks.
- Notion shows an icon only on an opt-in "Paste as mention". Word shows a site logo only in the click card.
- No primary source says that Google Docs or Word draw no icon on a plain link. That part is inferred from silence (UNVERIFIED). The report the maintainer ruled on lists this gap, as AGENTS.md#L151 asks.
- Webapp map content and glyph freedom. "for the webapp just hold our docsplus brand icon that customize for the hyperlink ... let user to use diffrent glyphs not only from lucide ... the rest of logos come from the fetcher"
- Offline map has priority. A link that matches the map takes the map's icon, and the fetcher helper never runs for it. A host may use the map alone. (Recorded; no quote kept.)
- Light and dark icons, and the tile, are developer choices. A map entry may give one icon or a
{ light, dark }pair. The fetcher helper takes atileoption. (Recorded; no quote kept.) - Webapp tile off. "no we do not need it"
- Sync, one function. "the A is the best". The resolver returns an element at once, and the element does its own fetching.
- Where the code lives. The mechanism goes into
@docs.plus/extension-hyperlink. The webapp uses it and is where it is tested. The icon route lives in hocuspocus. (Recorded; no quote kept.)
2026-10-17, on the architecture review
- Mark view, not a decoration. "the alternative the node view, decoration for this cost sooo much."
- Byte cap is configurable, default 16 KiB. "let developer chose,but set 16 kib as default." The developer is the server operator.
- Privacy page. "yeas, mention this feature in privacy page."
- Print hides link icons. "agrre"
- Icon box 1em, gap 0.25em, align by measurement, one design-system row. "agrre"
- docs.plus logo:
apps/webapp/public/icons/logo.svgas is. "true" - Tests. "true. also we have to test the offline map and other side that related to the extension in the extension sides." The ruled set:
- webapp: one Cypress spec for the Edit-form prefill;
- extension: clean-room Cypress, plus unit tests for the mark view,
linkIconMap,linkIconFetcher, pairs and the tile; - server: one unit test for the byte check.
- The byte check has two parts: the cap in
readCappedBody, and the sniff. Test files mirror source names, so Add an icon route to link-metadata with an operator-set byte cap #515 tests these two parts in two files.
- Legacy
linkmarks: no production count, README caveat only. "true" - File four side issues, adjusted to the new plan. "yep, base on the new need and plan."
Child issues, in order
- Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 (external, a draft PR today) — TipTap 3.32.0,
@tiptap/y-tiptap3.0.10 and the rootprosemirror-viewoverride 1.42.6. It lands before Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 and Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520, which is the recommended order. One ruling, Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 Decision 1, also answers Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2. See §prosemirror-view floor. - Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 — The
linkIconoption, the mark view, the parse guard, the icon-click rule, the.hyperlink-iconCSS, unit tests and clean-room Cypress. Owns Decisions 1 to 6 and the IME merge gate. After Bump TipTap packages to 3.32.0 for the remote-cursor fix #473: Step 0 accepts the post-Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 versions and re-cites them, and Step 8 skips the override change. - Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 —
linkIconMap,linkIconFetcher,{ light, dark }pairs, the tile, the failed-URL set, unit tests and clean-room Cypress. After Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513. - Add an icon route to link-metadata with an operator-set byte cap #515 — The icon route, the
LINK_ICON_MAX_BYTESvariable, its own rate limiter, OpenAPI, docs and unit tests for the byte check. Can start at once. Owns the Traefik log Decision. - Show a link icon before every pad link in the webapp #516 — The chain
padLinkIconincomponents/TipTap/padLinkIcon.ts, wired intoTipTap.tsx, the CSS mirror, print, the TOC preview strip and the design-system row. Also the privacy paragraph, theAGENTS.mdparity line, the ruled Cypress spec and the Chrome gates. After the three above. If its Decision 1 is ruled yes, it also waits for Load profile and preview favicons from our route, not from Google #517. That edge exists only under that ruling, so theDepends onfield does not list it. - Load profile and preview favicons from our route, not from Google #517 — Move the Google s2 favicon calls onto the icon route. After Add an icon route to link-metadata with an operator-set byte cap #515. It ships with Show a link icon before every pad link in the webapp #516 if that child's Decision 1 is ruled yes.
- Fill the link Edit forms from the document, not from innerText #518 — Fill all six Edit-form prefill paths through one
getHyperlinkDisplayTextread; this child is independent. See Fill the link Edit forms from the document, not from innerText #518 §Current behaviour. - Stop link preview metadata writes from reaching every peer #519 — Stop preview metadata writes from reaching every peer. Independent.
- Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 — Take y-tiptap 3.0.10 (Bump TipTap packages to 3.32.0 for the remote-cursor fix #473) and re-measure the rebuild cascade. After Bump TipTap packages to 3.32.0 for the remote-cursor fix #473.
Close this tracker when every child is closed and 2.2.0 is on npm.
Shared rules
Children point here instead of repeating these.
API contract
// @docs.plus/extension-hyperlink 2.2.0
interface LinkIconContext { href: string } // exported type; new fields are additive
interface HyperlinkOptions {
/** Sync. A fresh element with no text, or null for a plain link. A throw counts as null and logs once. */
linkIcon?: (ctx: LinkIconContext) => HTMLElement | null
}
function linkIconMap(entries: Record<string, string | { light: string; dark: string } | (() => HTMLElement)>):
(ctx: LinkIconContext) => HTMLElement | null
function linkIconFetcher(options: { src: (origin: string) => string; tile?: boolean }):
(ctx: LinkIconContext) => HTMLElement | nullResolver input. The resolver sees only hrefs that pass isSafeHref (validateURL.ts#L18).
DOM shape. An iconed link is <a …><ICON class="hyperlink-icon" contenteditable="false" aria-hidden="true"><span>text</span></a>. ICON is whatever element the resolver returns: an <img>, a <span> pair, or a host element.
- The resolver returns a bare element with no text. It must not be in the document yet.
- The mark view adds the
hyperlink-iconclass,contenteditable="false"andaria-hidden="true". On an<img>, it also setsdraggable=false. - The mark view wraps the link text in a new
<span>, which iscontentDOM. - A plain link keeps today's
<a>text</a>. getHTML(), Markdown, copy that ProseMirror handles, and drag never contain an icon. They use the schema serializer, not mark views.- One case is open: a native copy whose selection starts outside the editor may carry the icon
<img>(UNVERIFIED). The §Verification of Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 owns this check.
- One case is open: a native copy whose selection starts outside the editor may carry the icon
When a link has no icon. There are two cases, and both look like a plain link:
- The resolver returns
null, or throws. The DOM is today's<a>text</a>. - The resolver returns an
<img>that later fails to load. The img stayshidden(display: none), so it has no box and no gap. The DOM keeps the iconed shape until the view is rebuilt. - A pair has no hidden-until-load step. A bad pair URL shows a blank 1em box. This goes in the README Caveats.
Requests and logging.
- "One
<img>per link" is literal: each call returns a fresh element. "One request per origin" is the HTTP cache's job, because every link on one origin uses the same image URL.- Same-URL images that are still loading share one request in Chromium 151 and WebKit 26.5 (laptop, headless, real-browser check 2026-10-18). Firefox is not checked. The cold-open gate in Show a link icon before every pad link in the webapp #516 tests it again.
- A throwing or invalid resolver logs once per resolver function per page. The pad, history and
/editorshare one flag, because they sharepadLinkIcon.
Map matching (linkIconMap):
- Keys are bare hostnames. A key with
/,:,?,#,@,*,%or a space throwsTypeError. So a key holds no scheme, port or path, and scheme keys do not exist. - IDN keys become punycode, like hrefs.
www.and a trailing dot are ignored on both sides.- Subdomains match their parent key, and the longest key wins.
- Only
http:andhttps:hrefs match. - When two keys name one host, the later key wins; no throw.
Pairs. A pair is a <span> with an inline background-image: light-dark(url(light), url(dark)), set through CSSOM. light-dark() follows the app's color-scheme, which the theme sets per data-theme (globals.scss#L15 light, #L70 dark). So pairs follow the app theme with no listener and no theme attribute. Engines without the image form should keep the earlier url(light) value (UNVERIFIED in a browser).
Exports.
- New barrel lines in
src/index.ts:linkIconMap,linkIconFetcherandtype LinkIconContext. Nothing else. src/index.tshasexport * from './hyperlink'(index.ts#L28). Sohyperlink.tsmust not export any link-icon symbol. It imports the mark view as a value and the types withimport type.- The helpers live in
src/link-icons/, notsrc/utils/. So theutils/index.tsnamed-export rule does not apply. The barrel lines are explicit instead. - This is a minor bump under lockstep: all five packages go to 2.2.0.
prosemirror-view floor.
- For external hosts, the floor is what
@tiptap/pmasks. At the pinned commit,@tiptap/pm3.31.3 asks^1.42.3. The mark view'supdatemethod needs 1.42.0 or later. - Our clean-room tests run on the root override version. At the pinned commit that is 1.42.3 (package.json#L52). Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2 asks to move it to 1.42.6.
- Open PR Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 makes that same change. Its diff moves the override from
"1.42.3"to"1.42.6", and@tiptap/pm3.32.0 asks^1.42.6. One ruling covers both. If Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 is onmain, Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Step 8 skips the override change and still runs the rest. - The
docs/api.mdline follows the floor (Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 puts it there, not in the README):- If
@tiptap/pmonmainasks^1.42.6, the floor is already above 1.42.4.docs/api.mdthen says that the tests run on 1.42.6, and it drops the 1.42.4 advice. - Otherwise,
docs/api.mdrecommends 1.42.4 or later for arrow and delete keys.
- If
Webapp surfaces and the chain
- Editors with icons. Every editor built from
editorConfiginTipTap.tsx(TipTap.tsx#L203-L212): the pad,/editor, and history (useHistoryEditor.ts#L16-L19), including compare mode. - No icons. The chat composer passes no
linkIcon(useTiptapEditor.ts#L180-L188). Chat messages are sanitized HTML with no editor (HTMLBody.tsx#L29), so no mark view runs there. - Which links count as docs.plus links. The webapp map has two keys, both mapped to
/icons/logo.svg(logo.svg#L1):docs.plus. Through map matching, it also coverswww.docs.plusandnew.docs.plus.new URL(HOME_SITE_URL).hostname.HOME_SITE_URLisNEXT_PUBLIC_APP_URL, orhttps://docs.pluswhen unset (homeMetadata.ts#L1, #L10). So a self-hosted origin gets the logo. In development this key islocalhost, so everylocalhostlink gets the logo, on any port.
- Mail and phone glyphs.
mailto:andtel:links cannot get a favicon. Ruling 2 says every pad link shows an icon, so the chain adds a glyph step. It usesIcons.mailandIcons.phonefrom the webapp icon registry, throughlucideSvgString. Ruling 3 lets a host use any glyph; the webapp uses its own registry. - Links that stay plain. Other schemes, such as
ftp:, and favicon misses.
Names
Use one name per thing in code, issues and docs:
- link icon — the element before the link text. Its class is
hyperlink-icon. - docs.plus logo —
apps/webapp/public/icons/logo.svg. Never call it a mark, because a mark is the ProseMirrorhyperlinkmark. - favicon — a website's own icon image.
- glyph — the Lucide mail and phone SVGs only.
- resolver — the function passed as
linkIcon. - chain — the webapp resolver
padLinkIcon:docsplusMap(ctx) ?? appScheme(ctx) ?? fetchIcon(ctx). The first non-null result wins. - appScheme — the chain step that returns the mail or phone glyph for a
mailto:ortel:href. - mark view — the
hyperlinkmark's view inlinkIconMarkView.ts. Not "widget" and not "decoration". - hole — the space inside the
<a>but outsidecontentDOM. - sweep — moving stray nodes from the hole into
contentDOM. - pair — a
{ light, dark }map entry. - tile — the light square behind fetched icons on a dark scheme.
- icon route —
GET /api/metadata/icon?origin=<origin>. It takes an origin only, never a full href. - fetcher helper —
linkIconFetcher, the client function. Never say "fetcher" alone; it is ambiguous with the icon route. - failed-URL set — the per-helper set of image URLs that errored once.
Links and numbers
- Every code permalink is pinned to
69ca1ef1649540f7c8989dfd74ad901c741a30b1. Markdown files use?plain=1#L…. - Installed-package lines name the version and the
node_modules/.bun/path, because they have no permalink. They cite the versions at the pinned commit:@tiptap/core3.31.3 andprosemirror-view1.42.3. After Bump TipTap packages to 3.32.0 for the remote-cursor fix #473, re-check each cited line against 3.32.0 and 1.42.6, and re-cite it. Do not stop on the version change alone. - Cite a maintainer ruling as Ruling N (1 to 17). Only this tracker quotes the maintainer's words.
- Every number carries one of these labels:
- "laptop, jsdom view, no layout" for a full
view.dispatchin jsdom 26.1.0 with bun 1.4.3; - "laptop, plugin only, no view" for a bun 1.4.3 script with no editor view;
- "laptop, Chromium headless or WebKit headless, layout on" for the 2026-10-18 real-browser check;
- "laptop, Chrome , dev build" for the Show a link icon before every pad link in the webapp #516 gates;
- "read from code, not measured" for anything else.
- "laptop, jsdom view, no layout" for a full
- The design note and the probe scripts are local files. Children cite them only as the design review, with no file name or section number.
Test policy
- Write only the ruled tests (ruling 15). No DOM snapshots, no "renders without crashing" tests.
- Prove each new test by sabotage: break the named code, see the test fail, then restore it.
- Extension unit tests that need a DOM wait for Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 6 (jsdom). That Decision names the tests that a "no" drops. Today the unit layer is headless on purpose (commands.test.ts#L1-L3).
jsdom@26.1.0is inbun.lockonly through the rootjest-environment-jsdom. It does not resolve from the extension. If the Decision is yes, use a per-file setup, not a global preload. - The clean-room playground keeps its default behaviour. Icons turn on only behind new query flags, such as
?icons=element|map|fetcher|both|throwand?tile=on. So existing specs and the README gallery assets stay as they are.
Performance gates
#516 Step 9 holds the gates, their method and their limits. They are engineering defaults from the review.
Checks every child runs where it applies
Each line names the check and its owner. The pass detail lives only in the owner child.
- Full-document paste, the hand paste plus one run of
full-doc-paste.cy.js: Show a link icon before every pad link in the webapp #516 Step 8. getHTML()and Markdown hold no icon: Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Step 9.- The hypermultimedia clean-room suite: Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Step 11.4.
- History and compare mode: Show a link icon before every pad link in the webapp #516 Step 8 (History).
- Modal over embeds: Show a link icon before every pad link in the webapp #516 Step 8.
- Workbox: Show a link icon before every pad link in the webapp #516 Step 8.
- A cold open with many origins: Show a link icon before every pad link in the webapp #516 Step 9.
logo.svgat 1em indocsplus,docsplus-darkanddocsplus-dark-hc: Show a link icon before every pad link in the webapp #516 Step 8 (Placement).- The cold install: Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Verification and Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 Alternative A.
- Docs that follow the API: each extension child.
Rules every child keeps
- No icon data in the document, Yjs, mark attributes, Tiptap storage, plugin state, localStorage, IndexedDB or Workbox. The browser HTTP cache holds icon bytes.
- No new design token. The icon row in
design-system.mdis the one approved design edit. apps/webapp/src/utils/apiBaseUrl.tsand the leafapps/webapp/src/utils/iconRouteUrl.tsbelong to whichever of Load profile and preview favicons from our route, not from Google #517 or Show a link icon before every pad link in the webapp #516 lands first. That child creates both, and the other reuses them.iconRouteUrl.tsexportsiconRouteUrl(origin), which returns${apiBaseUrl()}/metadata/icon?origin=${encodeURIComponent(origin)}.- Both files are imported by leaf path, never from the
@utilsbarrel. getSiteIconUrlandpadLinkIconboth calliconRouteUrl. The local variable infetchDocument.ts:55-62stays.
- Shared files. Stop link preview metadata writes from reaching every peer #519 and Load profile and preview favicons from our route, not from Google #517 both edit
previewShared.ts,LinkPreviewSheet.tsxanduseLinkMetadata.ts. Show a link icon before every pad link in the webapp #516 edits only thepreviewShared.ts:20-25comment. Stop link preview metadata writes from reaching every peer #519 and Fill the link Edit forms from the document, not from innerText #518 both editlinkMarkUtils.ts.- Line numbers hold only at the pinned commit. The child that lands second finds each edit by symbol name and builds on the first.
- The "Link icons" section of
pages/privacy.tsxbelongs to Show a link icon before every pad link in the webapp #516. Load profile and preview favicons from our route, not from Google #517 adds or replaces one sentence in it. Only if Load profile and preview favicons from our route, not from Google #517 ships alone first does it create the section, and Show a link icon before every pad link in the webapp #516 then extends it. There is never a second "Link icons" section. LEGAL_UPDATEDfollows the Show a link icon before every pad link in the webapp #516 Decision 2 ruling, in whichever child edits the privacy page. No child sets it before that ruling comment exists.- No commits from a child. Each ends at a Review checkpoint. "Done" for a child means its work is committed on local
mainby the maintainer. The family release needs that work pushed toorigin/main. - A step that says stop and comment here resumes only after a maintainer reply on that issue, unless the step names another condition.
Local stack
- Run
make dev-local. The webapp serves port 3000, or the next free port. Read the port from the WEBAPP log (scripts/dev-local-preflight.ts:318). REST serves port 4000. - Run webapp specs as
(cd apps/webapp && bunx cypress run --browser electron --config baseUrl=http://localhost:<port> --spec <spec>). bun run --filter @docs.plus/hocuspocus testneedsmake dev-localup, because some suites need Postgres and Redis.
Tests and benchmarks
The design was measured before these issues were filed. These numbers are evidence, not gates. The gates live in #516 Step 9 and in each child's Verification. Every child re-measures on the code it ships.
How it was measured
- jsdom bench. ProseMirror 1.42.3 in jsdom with no layout. It compared no icon, a
Decoration.widgeticon and a mark-view icon. This bench made the maintainer choose the mark view (Ruling 9). - Real-browser check, 2026-10-18. A throwaway build of the planned code: the mark view,
linkIcon,linkIconMap,linkIconFetcherwith its failed-URL set, and the webapp chain. It used the real@tiptap/core3.31.3 Editor and the repo's Hyperlink extension from source.- Browsers: Chromium 151 headless and WebKit 26.5 headless, driven by Playwright, with layout and paint on. Firefox was not run.
- Documents: 500, 2000 and 5000 top-level paragraphs, one link each, cycling about 150 origins. A local stub served the icons with the planned route headers, and 10 origins answered
404. - Method: the same document with
linkIconunset and then set, 5 runs each, median and p95. Times are sync dispatch, plus a number to the next frame (doublerequestAnimationFrame). Remote edits used two editors joined by y-tiptap in one page. - The harness lived outside the repo and is not committed. Show a link icon before every pad link in the webapp #516 Step 9 repeats the key measurements on a real pad.
Results
Icons off / on, median, ms (laptop, Chromium headless, layout on):
| Link blocks | Mount, sync | Mount, to next frame | Keystroke in plain text | Keystroke in a link | Paste of 500 links |
|---|---|---|---|---|---|
| 500 | 15.6 / 19.3 | 21.3 / 29.6 | 0.3 / 0.3 | 0.4 / 0.4 | 24.8 / 32.3 |
| 2000 | 35.0 / 45.4 | 53.8 / 76.6 | 0.5 / 0.4 | 0.6 / 0.5 | 26.8 / 34.9 |
| 5000 | 71.5 / 101.4 | 124.3 / 187.3 | 0.6 / 0.4 | 0.6 / 0.5 | 27.2 / 32.9 |
- WebKit (1 ms clock): keystrokes 1 to 3 ms in both modes; mount 22 / 44 ms sync at 2000.
- 4x CPU throttle, Chromium at 2000: mount 148.1 / 195.7 ms sync; keystrokes 0.1 to 0.5 ms in both modes.
- Remote edits, Chromium at 2000, icons off / on:
| y-tiptap | Remote keystroke | Remote insert at the top | Remote Enter | Remote 10-block paste |
|---|---|---|---|---|
| 3.0.9 (installed today) | 4.9 / 4.9 | 30.9 / 53.4 | 32.1 / 49.8 | 33.5 / 50.9 |
| 3.0.10 (#473) | 1.6 / 1.5 | 1.8 / 2.4 | 2.3 / 3.0 | 2.2 / 3.0 |
Counts
| Scenario | Mark views built | Icon requests |
|---|---|---|
| Cold open | one per link | at most 1 per URL (149 URLs) |
| Typing, 600 local keystrokes | 0 | 0 |
| 200 remote keystrokes | 0 | 0 |
| Scroll through the whole document | 0 | 0 |
| Paste of 500 links | 500 | 0 (cached origins; known misses get no src) |
| Remote insert at the top, y-tiptap 3.0.9 | every link | 0 |
| Remote insert at the top, y-tiptap 3.0.10 | 1 | 0 |
Memory (Chromium, through CDP)
- Pad switches: 30 editor create-and-destroy cycles at 2000 links, with garbage collection after each. They left 0 Editors, 0 EditorViews and 0 icon images. DOM nodes and listeners stayed flat. The heap slope was the same with icons on and off, about 9 KiB per cycle.
- Long session: 2000 remote edits at 2000 links. The heap grew the same amount with icons on and off. The growth is Yjs history.
- Cost per link: about +350 to +480 bytes of heap and +2.4 DOM nodes (information only).
Proof that the harness can fail
- A mark view that rebuilt its image on every update failed the request gates: 492 requests on a cold open, and about 494 on a remote insert.
- A
windowlistener that never detaches failed the leak loop: +5,706 listeners and +2.2 MiB per cycle. - The clean runs above come from the restored code.
Limits
- All numbers ran on prosemirror-view 1.42.3 with
@tiptap/pm3.31.3, not the Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 set. Re-run after Bump TipTap packages to 3.32.0 for the remote-cursor fix #473. - Firefox was not run. The local server spoke HTTP/1.1, so 149 icons over 6 connections take about 7.5 s to appear at 300 ms latency. No gate covers that.
- Numbers are from one laptop. Labels: laptop, Chromium headless or WebKit headless, layout on.
What only the maintainer can do
-
Answer the rulings card. Answer each question yes or no. The recommended answer comes first. Each child stops at the step that needs its Decision until a
Maintainer rulingcomment exists.- Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 Decision 1, which also answers Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2: mark draft PR Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 ready, then merge it before Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 and Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520? Yes. If Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 closes unmerged, rule Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2 alone, and pick Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 Alternative A (Alternative B needs Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 merged).
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2, only if Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 closes without a merge: move the root
prosemirror-viewoverride to 1.42.6? Yes. - Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 1: put the icon at the start of every iconed link, image-led and image-only links included? Yes.
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 3: with
linkIconunset, return the plain mark view and load no composition plugin? Yes. - Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 4: accept the block-start caret jump, and record it as a deliberate parity exception? Yes. No primary source states the peer behaviour (UNVERIFIED). An upstream post is optional and needs your yes.
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 5: you run the real iPhone, Android with Gboard and Windows Chrome Chinese legs of Step 10, and the merge waits for them? Yes.
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 6: add
jsdom26.1.0 and@types/jsdom21.1.7 to the rootcatalog, and reference them ascatalog:in the extensiondevDependencies? Yes. A no drops mark-view tests 1-10 and the 11 helper unit tests. - Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1: drop query parameters from the Traefik access log, then restart production Traefik on 3.6.18 or later (the field landed in v3.6.18)? Yes.
- Show a link icon before every pad link in the webapp #516 Decision 1: ship Load profile and preview favicons from our route, not from Google #517 in the same release as the privacy paragraph? Yes.
- Show a link icon before every pad link in the webapp #516 Decision 2: bump the shared
LEGAL_UPDATEDto the ship date, so the Terms page shows the same date? Yes. - Load profile and preview favicons from our route, not from Google #517 Decision 1: let the server set the fallback
faviconto our icon route URL, with a module rule 5 exception? Yes. The client uses the field only as a marker. - Stop link preview metadata writes from reaching every peer #519 Decision 1 (Alternatives A, B and C on the issue): make link previews read-only, with no write and no read of stored title and image? Yes. This accepts that new links lose the fetched native tooltip.
-
Post the ruling comments. After the maintainer answers the card and approves the text, the maintainer, or the maintainer's agent session, posts each answer on its child as a
Maintainer ruling, <date>comment withgh. The tracker ruling comment (Rulings 1 to 17) and the Richer internal-link chips, and a kind icon beside the pad link #174 pointer are already posted. AGENTS.md#L159 asks for the ruling comments. -
Device legs.
- The maintainer runs the Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Step 10 legs on a real iPhone, Android with Gboard and Windows Chrome with Chinese input. Step 10 blocks the merge. Card item 6 (Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 5) asks you to confirm this, because the repo lists no executor devices. The executor runs desktop Chrome, Safari and Firefox, macOS Pinyin and the iOS Simulator.
- The maintainer also runs the Show a link icon before every pad link in the webapp #516 Step 8 legs that its executor could not run: iOS Safari, Android Chrome with Gboard, and VoiceOver. Do this before the family release.
-
Production config.
- Apply the Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1 Traefik change. First check that the host Traefik is 3.6.18 or later (the field landed in v3.6.18). An image older than 3.6.18 refuses the new key and does not start.
- No production change is needed for the byte cap; the 16384 default lives in code. To change the cap, set
LINK_ICON_MAX_BYTESin the host env file that the deploy copies to.env.production(prod.docs.plus.yml#L74), then redeploy. A hand edit of.env.productionis lost on the next deploy. See Add an icon route to link-metadata with an operator-set byte cap #515ENV.md. docker-compose.prod.ymlstays unchanged. Therest-apiservice loadsenv_file: .env.production(#L172), so every key in that file already reaches the container.
-
Rollback.
- Traefik: per Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1.
- npm 2.2.0 and its tags cannot be undone. Fix forward with 2.2.1.
-
Family release 2.2.0. Start only when all three hold:
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 and Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 are committed on local
main. - The Show a link icon before every pad link in the webapp #516 Step 4 edits to the extension
styles.css,docs/styling.mdandCHANGELOG.mdare committed. - Every Show a link icon before every pad link in the webapp #516 gate has passed.
Fill the link Edit forms from the document, not from innerText #518 joins the release if it has landed. The steps follow
scripts/release-family.ts.- The executor runs steps 1-4, 6 and 7, then stops at a Review checkpoint.
- The maintainer commits and pushes (step 5), then publishes and polls per
release-productskill §Ship step 4 (steps 8 and 9). The maintainer then runsrelease:family --allow-noop(step 10), names the versions (step 11) and refreshes Context7. - The steps below keep the 2.2.0 deltas from that skill: the
bun.locklockstep check, the CHANGELOG[Unreleased]handling, the export diff and--allow-noop. The poll also differs: usebun pm view, not the skill'snpm view.
- Set
"version": "2.2.0"in all fiveextensions/extension-*/package.json. Runbun installat the repo root, so the five workspaceversionlines inbun.lockfollow (bun.lock#L257-L259). Check that thebun.lockdiff touches only those five lines. - Resolve the CHANGELOG sections. Today only hyperlink and hypermultimedia have
## [Unreleased], and both are empty (hyperlink CHANGELOG#L11-L13, hypermultimedia CHANGELOG#L9-L11).- hyperlink: rename
## [Unreleased]to## [2.2.0] — <date>. The children fill it. - hypermultimedia: if
git diff @docs.plus/extension-hypermultimedia@2.1.0..HEAD -- extensions/extension-hypermultimedia/src/is empty, delete the empty## [Unreleased]heading. Otherwise resolve it like hyperlink. - Do this before step 3, because the generator inserts its entry before the first
## [heading (release-family.ts#L415-L419).
- hyperlink: rename
- Run
bun run release:family --generate-noop-changelogs. Review the no-op entries it writes for hypermultimedia, indent, inline-code and placeholder. - Put an empty
## [Unreleased]back above## [2.2.0]in hyperlink and hypermultimedia, to keep today's shape. - Maintainer: commit and push to
origin/main, becauserelease:familyrefuses to run unless HEAD equalsorigin/mainand the tree is clean (release-family.ts#L282-L301). - Build fresh
dist/in all five:bun run --filter '@docs.plus/extension-*' build, because the preflight fails whensrc/is newer thandist/(release-family.ts#L240-L260). - Check the hyperlink export list.
bun pm view @docs.plus/extension-hyperlink@2.1.0 dist.tarballprints the 2.1.0 tarball URL. Fetch it withcurl, unpack it withtar, and diff the export names inpackage/dist/index.d.tsagainst the newextensions/extension-hyperlink/dist/index.d.ts. OnlylinkIconMap,linkIconFetcherandLinkIconContextmay be new. - Maintainer: publish each package with
(cd extensions/<pkg> && bun publish), and approve the npm web login with the Chrome passkey. - Maintainer: from the repo root, run
bun pm view @docs.plus/<pkg> dist-tags.latestfor all five, until each shows2.2.0. Never usenpm view. A cached answer may be stale (UNVERIFIED). Repeat the poll after one minute. - Maintainer: run
bun run release:family --allow-noop, because the four no-op packages need the flag (release-family.ts#L363-L386). The script skips the publishes, then creates and pushes the five@docs.plus/extension-*@2.2.0tags. - Maintainer: name the five versions in the product GitHub Release.
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 and Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 are committed on local
-
After the release. Compare
context7.jsonwith the README Caveats, then refresh Context7. -
Close. Close each child with its commit link, only after its commit is on
origin/main.
Relation to #174
After #331, #174 internal kinds join the front of the chain: internalKind(ctx) ?? docsplusMap(ctx) ?? appScheme(ctx) ?? fetchIcon(ctx). The plan-update comment on #174 lists the #174 build notes that this plan replaces. #174 executors read them there.
Rejected options
Decoration.widgetwith host keys. ItsforChildcost grows with the doc size (Summary table). Ruling 9.- Tiptap
addDecorations. It warns on shared widget keys (node_modules/.bun/@tiptap+core@3.31.3+ef29727eaaf819d3/node_modules/@tiptap/core/src/decorations/DecorationManager.ts:378-382). It gains no speed under the y-tiptap whole-doc replace. - The
{ key, render }resolver result. A mark view reuses its DOM throughmark.eqand calls the resolver once. A key and a thunk add nothing. - Icons or favicon URLs in mark attrs, doc attrs or
Y.Map. Every peer gets them, and they land in version rows. Any editor could plant a tracker, and they never expire. - Caches in Tiptap storage or plugin state. They die on reload and pad switch.
- localStorage, IndexedDB or Workbox icon caches. They repeat the HTTP cache and break the Workbox cross-origin rule.
- Google s2, a direct
origin/favicon.icoload, DuckDuckGo, icon.horse, Favicone and logo.dev. Each sends the reader's IP and the link's site to a third party. - Redis icon bytes. Callers could mint keys on an unauthenticated route and evict
store-doc-statekeys. - A batch icon endpoint. It breaks the per-URL HTTP cache.
- A fixed 64 KiB cap. Ruled configurable instead, with a 16 KiB default (ruling 10).
- A CSS
::beforeicon. It cannot hold an arbitrary element, so it overturns ruling 3. It stays the only known fallback if the IME gate fails. <picture>,prefers-color-schemeor two images toggled by CSS for pairs. They follow the OS or need a theme selector.- A JS
color-schemelistener. Nothing could unsubscribe it, and a redraw would cost a whole-doc Yjs pass. - Tiptap's
MarkViewclass. Itsdomgetter returns the editor root (node_modules/.bun/@tiptap+core@3.31.3+ef29727eaaf819d3/node_modules/@tiptap/core/src/MarkView.ts:77-79). - A deferred microtask insert to skip image-only links. It adds timing state. Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 1 handles that case.
- A module-level pending set for sweeps. It would mix the pad, history and
/editorqueues. - A
compositionendlistener on each<a>. Composition events target the editing host. - Ignoring hole records or hole selection during composition. Both failed in jsdom.
- Typed URL-pattern glyphs (GitHub pull requests and issues). Not ruled.
- A U+2060 joiner in the icon. It would leak into Yjs through
innerTextprefills.
Activity
HMarzban commentedon Oct 18, 2026
Maintainer ruling, 2026-10-17
This comment records the maintainer's rulings for this tracker, so they do not live only in the body. Quoted words are the maintainer's own. Ruling 1 was given on 2026-10-16; the rest on 2026-10-17.
2026-10-16
linkIconFetcher.2026-10-17, on the plan
AGENTS.mdbeside Revise the heading size scale to follow a documented typographic convention #369.{ light, dark }pair. The fetcher helper takes atileoption. (Recorded; no quote kept.)@docs.plus/extension-hyperlink. The webapp uses it and is where it is tested. The icon route lives in hocuspocus. (Recorded; no quote kept.)2026-10-17, on the architecture review
apps/webapp/public/icons/logo.svgas is. "true"linkIconMap,linkIconFetcher, pairs and the tile;readCappedBody, and the sniff. Test files mirror source names, so Add an icon route to link-metadata with an operator-set byte cap #515 tests these two parts in two files.linkmarks: no production count, README caveat only. "true"Still open
The 12 Decisions on the card under "What only the maintainer can do" are not ruled yet. Each child issue holds its own Decisions, with the recommended answer first and a stop gate in the step that needs it. A later comment headed
Maintainer ruling, <date>records each answer.HMarzban commentedon Oct 18, 2026
Maintainer ruling, 2026-10-18
The maintainer answered these Decisions through a multiple-choice prompt in the agent session. Each answer below is the option the maintainer picked; the quoted label is the option text.
All 12 Decisions on the card under "What only the maintainer can do" are ruled. Every answer is the recommended one. Each owning issue has its own ruling comment, which is what its stop gates check.
linkIconmeans the plain mark view and no composition plugin.jsdomand@types/jsdomto the root catalog.LEGAL_UPDATED, set to the ship date.faviconpoints at our icon route.Next: merge #473, then start #513. #515 can start at once.