Repository navigation
Show a link icon before every pad link in the webapp #516
Description
Part of #512. Read the tracker first. Work on main.
Summary
- Today. Links in a pad are plain
<a>text. A favicon shows only in the hover preview. - After. Every link in the pad, history and
/editorshows a link icon before its text. The resolver tries three sources in order:- the docs.plus logo (
apps/webapp/public/icons/logo.svg) for docs.plus links; - a Lucide glyph (mail or phone) for
mailto:andtel:links; - the favicon from our own icon route,
GET /api/metadata/icon.
- the docs.plus logo (
- A miss stays a plain link. The chat composer gets no link icons.
- Also in this issue: print, the TOC preview, the CSS mirror, the design-system entry, privacy, the
AGENTS.mdparity line, the gates and the ruled spec.
Names follow #512 §Names.
Maintainer rulings that bind this issue
The full list, with dates and quotes, is on #512.
- Ruling 9, mark view, not a decoration. The ruling's node view is the hyperlink mark view, because a link is a mark. Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 builds it, and this issue only uses it.
- Ruling 10, byte cap. Add an icon route to link-metadata with an operator-set byte cap #515 adds the server variable
LINK_ICON_MAX_BYTES, default16384. The webapp passes no cap, andpadLinkIcon.tshas no byte setting. - Ruling 2, parity exception. Every pad link shows its link icon, with no opt-in, against Google Docs, Word and Notion on purpose, like Revise the heading size scale to follow a documented typographic convention #369. Do not fix it back.
- Ruling 3, webapp map content. The webapp map holds only the docs.plus logo. A host may use any glyph. Every other site logo comes from the icon route.
- Ruling 14, docs.plus logo. Ship
apps/webapp/public/icons/logo.svgas it is. Do not redraw it. - Ruling 6, webapp tile off. Dark favicons such as GitHub's may fade on dark paper. That is accepted.
- Ruling 1, favicons from our own route. The webapp loads favicons only from the icon route in hocuspocus.
- Ruling 11, privacy page. The privacy page mentions this feature (Step 5).
- Ruling 12, print hides link icons. Step 3.1 adds the print rule.
- Ruling 13, icon box. The box is 1em, the gap is 0.25em,
vertical-aligncomes from measurement, and the design system gets one entry. - Ruling 15, tests. The ruled webapp test is one Cypress spec for the Edit-form prefill.
- Ruling 16, legacy
linkmarks. No production count. They get no link icon, and the extension README states that. - Ruling 8, where the code lives. The mechanism lives in the extension. The webapp uses it and is where it is tested.
Depends on / Blocks
- Depends on Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 (the
linkIconoption and the mark view), Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 (linkIconMap,linkIconFetcher) and Add an icon route to link-metadata with an operator-set byte cap #515 (the icon route). - Depends on Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1 (Traefik query) for the privacy text.
- If Decision 1 below is ruled yes, it also depends on Load profile and preview favicons from our route, not from Google #517. Both then ship in the same release.
- Blocks the 2.2.0 family release. The maintainer releases only after this issue's gates pass (see [Tracking] Inline link icons through a hyperlink mark view #512).
Current behaviour
Links are pinned to 69ca1ef1649540f7c8989dfd74ad901c741a30b1.
- The pad configures
Hyperlinkwith no icon option (TipTap.tsx:203-212). The pad, history and/editorshare this config. It registersftpandmailtofor autolink. Images are inline (TipTap.tsx:214), so a link can start with an image. - The chat composer builds its own
Hyperlink(useTiptapEditor.ts:180-189). apiBaseUrlexists twice, and both copies are private.fetchMetadata.ts:61-65andapi/users/fetchLinkMetadata.ts:4-8hold the same code.- Each throws when
NEXT_PUBLIC_RESTAPI_URLis unset.next.config.jsalready refuses that at build and dev start. - The base already ends in
/api(fetchMetadata.ts:84;.env.example:96).
HOME_SITE_URLcomes fromNEXT_PUBLIC_APP_URL, defaulthttps://docs.plus(homeMetadata.ts:1-10).LegalPage.tsxand_app.tsxalready import it from outside the home page (LegalPage.tsx:2-6,_app.tsx:10-13)..env.example:359sets it tohttp://localhost:3000for local work.- The pad link style is at
document-styles.scss:27-36.a:hoverdims the whole<a>to 0.85, so a link icon dims too.- Pad links use
text-decoration: inherit, so they have no underline of their own. - Tailwind preflight makes
img, svgdisplay: block..ProseMirror img { height: auto }has (0,1,1) specificity.
- The print hide rule is at
_print.scss:33-47. The selectors are lines 33-45. - The TOC rail preview strips widgets by class, then hoists the first image (
TocTickRail.tsx:62-78,:352-359,:394-395). A mark-view link icon has noProseMirror-widgetclass. Without a change, the preview would show a favicon as its cover. - Three Edit-form prefills read
innerText(desktopPopoverEntries.ts:61-62,mobilePopoverEntries.ts:47,editHyperlinkPopover.ts:23). A link icon holds no text, soinnerTextstays right. The ruled spec pins the desktop read only.mobilePopoverEntries.ts:47has no user path (Fill the link Edit forms from the document, not from innerText #518). - Test hooks for the spec.
- The desktop preview's Edit button has no test id. The sibling spec selects it as
.hyperlink-preview-popover button.edit(hyperlink-preview-edit.cy.js:121). - Both Edit forms use
data-testid="hyperlink-editor-text"(HyperlinkEditorFields.tsx:29). - Tiptap stores the editor on its root element:
dom.editor = this, wheredomisview.dom(installed@tiptap/core3.31.3,src/Editor.ts:624-626). So a spec can readgetHTML()on a pad route, wherewindow._editordoes not exist.
- The desktop preview's Edit button has no test id. The sibling spec selects it as
- The privacy page has no section on link icons (
privacy.tsx:26-60).- Both legal pages share
LEGAL_UPDATED = '12 October 2026'(legalMetadata.ts:1). - Every page sets
<meta name="referrer" content="no-referrer">(_document.tsx:106). The favicon<img>from Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 also setsreferrerPolicy = 'no-referrer'. So no icon request carries the pad URL.
- Both legal pages share
- The parity rule and its Revise the heading size scale to follow a documented typographic convention #369 exception are at
AGENTS.md:151. - The design system is locked (
design-system.md:9-26). It has no entry for a link icon. The §Hyperlink popovers heading is at:609, and its table runs:613-619. The icon box is ruled, so adding its entry is an approved edit. - CSP
img-srcallowshttps:,localUrlsanddevUrls, but notenvUrls(next.config.js:303;envUrlsat:211-215). AnhttpsREST host and a local one work. A plainhttpREST host that is not local gets no favicons. See Out of scope. isSafeHrefblocks onlyjavascript:,data:,vbscript:,file:andblob:(validateURL.ts:11,:18-25). Somailto:andtel:hrefs reach the resolver.
Target behaviour
- Pad, history and
/editor: eachhttp(s),mailto:andtel:link shows one link icon at the start of its<a>. - docs.plus links and the deployment's own host show the docs.plus logo, per [Tracking] Inline link icons through a hyperlink mark view #512 §Webapp surfaces and the chain. Links to the open pad get the logo too, with no
locationread. Undermake dev-local, everyhttp://localhost:*link shows the logo. That is known and accepted, because it touches only local links. - Other web links show the favicon after it loads. A miss stays a plain link, with no gap.
ftp:,sms:, other app schemes and relative hrefs stay plain links. Both helpers return null for any href that is nothttp(s), andappSchemetests onlymailto:andtel:.- Chat composer: no link icon, and the same
<a>as today. - Print: no link icons.
- TOC rail preview: no favicon cover.
- Screen readers read the link text only.
- Edit form: the Text field equals the link text exactly, on desktop.
- Performance: the gates in Step 9 pass in Chrome.
Implementation steps
Read every file in Read first before Step 0. "Comment here" always means a comment on this GitHub issue, never a code comment.
Step 0. Read and check the base
- Confirm that Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513, Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 and Add an icon route to link-metadata with an operator-set byte cap #515 are merged on
main. - Confirm that ruling comments exist for Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decisions 1, 3 and 4 and Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1. Also confirm the Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 ruling (Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 Decision 1), which answers Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2, or that Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 2 has its own ruling. If Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513, Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 or Add an icon route to link-metadata with an operator-set byte cap #515 is missing from
main, or any listed ruling is missing, stop and comment here. - Read
extensions/extension-hyperlink/src/link-icons/linkIconMarkView.tsonmain. Confirm three facts:- the mark view adds
hyperlink-iconto the element the resolver returns, with no wrapper; - the resolver result is refused when it has text or is connected (
!el.textContent,!el.isConnected); - the renderer is cached by resolver function identity.
If any differs, stop and comment here, because Steps 1, 2 and 6 rely on them.
- the mark view adds
- In
extensions/extension-hyperlink, runbunx tsup. Clearapps/webapp/.next/cache, restart the dev server and hard-refresh (extensions/CLAUDE.md§Standalone Extension Development). - Run
make dev-local. Openhttp://localhost:4000/api/metadata/icon?origin=https://github.comand expect a200image. See [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack.
Step 1. The pad resolver
- One
apiBaseUrl. Per [Tracking] Inline link icons through a hyperlink mark view #512 §Rules every child keeps. If Load profile and preview favicons from our route, not from Google #517 has not landed, first createutils/apiBaseUrl.tswith the body fromfetchMetadata.ts:61-65, andutils/iconRouteUrl.tsper [Tracking] Inline link icons through a hyperlink mark view #512.- Import it by its leaf path,
@utils/apiBaseUrl, infetchMetadata.tsand inapi/users/fetchLinkMetadata.ts. Delete both private copies. - Do not add it to the
@utilsbarrel (utils/index.ts). The barrel pulls in the Supabase graph (extensions/CLAUDE.md§Composer Link Dialog And Internal Links). A leaf path keepspadLinkIcon.tsandlink-helpers.tsfree of that graph.@utils/lucideSvgStringis imported the same way. - Import
iconRouteUrlfrom@utils/iconRouteUrl. Usesrc: iconRouteUrlinlinkIconFetcher.
- Import it by its leaf path,
- Create
apps/webapp/src/components/TipTap/padLinkIcon.ts. It sits besideTipTap.tsx, because it is editor config, not a popover. Keep the import order below;simple-import-sortwarns on any other order, andbun run lintruns with--max-warnings=0.
import { Icons } from '@components/icons/registry'
import { HOME_SITE_URL } from '@components/pages/home/homeMetadata'
import { type LinkIconContext, linkIconFetcher, linkIconMap } from '@docs.plus/extension-hyperlink'
import { iconRouteUrl } from '@utils/iconRouteUrl'
import { lucideSvgString } from '@utils/lucideSvgString'
const LOGO = '/icons/logo.svg' // same origin: CSP img-src 'self', Workbox same-origin rule
// linkIconMap throws on a key with ':' (an IPv6 host); a bad URL here would break every pad.
const siteHost = (() => {
try {
const host = new URL(HOME_SITE_URL).hostname
return host.includes(':') ? null : host
} catch {
return null
}
})()
const docsplusMap = linkIconMap(siteHost ? { 'docs.plus': LOGO, [siteHost]: LOGO } : { 'docs.plus': LOGO })
const glyph = (svg: () => string) => () =>
Object.assign(document.createElement('span'), { innerHTML: svg() })
const MAIL = glyph(() => lucideSvgString(Icons.mail, 16))
const TEL = glyph(() => lucideSvgString(Icons.phone, 16))
const appScheme = ({ href }: LinkIconContext) =>
/^mailto:/i.test(href) ? MAIL() : /^tel:/i.test(href) ? TEL() : null
const fetchIcon = linkIconFetcher({ src: iconRouteUrl })
/** Pad resolver: docs.plus logo, then mail and phone glyphs, then the favicon. */
export const padLinkIcon = (ctx: LinkIconContext): HTMLElement | null =>
docsplusMap(ctx) ?? appScheme(ctx) ?? fetchIcon(ctx)- Safe on the server. Importing the module runs only
linkIconMap,linkIconFetcherand thesiteHostguard. Both helpers create no DOM and start no fetch at build time (Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514).documentandiconRouteUrlrun only when the mark view calls the resolver, which happens in the browser. - Build the resolver once, at module scope. The mark view caches its renderer by function identity.
- The glyph shape is
span.hyperlink-icon > svg. The favicon and the docs.plus logo areimg.hyperlink-icon, with no inner child. Step 2 styles both shapes.
- In
TipTap.tsx, addlinkIcon: padLinkIcontoHyperlink.configure(TipTap.tsx:203-212). Do not touchuseTiptapEditor.ts.
Step 2. The CSS mirror
The webapp does not import the extension's styles.css. In styles/document-styles.scss, inside the .ProseMirror block, add these rules after the a:hover rule (lines 33-36) and before the block's closing brace on line 37:
.hyperlink-icon {
display: inline-block;
width: 1em;
height: 1em;
margin-inline-end: 0.25em;
vertical-align: -0.125em;
pointer-events: none;
-webkit-touch-callout: none;
}
.hyperlink-icon > svg {
display: block;
width: 100%;
height: 100%;
}- Add no code comment about the value. Step 4 replaces
-0.125emwith the measured value. - The compiled selector
.ProseMirror .hyperlink-iconis (0,2,0). It beats.ProseMirror img { height: auto }at (0,1,1) and preflightimg { display: block }at (0,0,1). Soimg.hyperlink-iconstays inline. .hyperlink-icon > svgmatches the glyph, because the glyph<svg>is a direct child of the link icon<span>.- The webapp needs no
[hidden]rule. Preflight's[hidden]rule carries!important. - The glyphs use
currentColor, so they take the link ink in every theme.
Step 3. Print, TOC preview and the composer
- In
styles/_print.scss, add.hyperlink-iconto the hide selectors (_print.scss:33-45). - In
toc/TocTickRail.tsx, add'.hyperlink-icon'toPREVIEW_STRIP_SEL(TocTickRail.tsx:62-78). The mark view forces this edit. - Check the composer by hand. Open a pad at 1280×800, open a heading's chat, type
https://github.comin the composer, and inspect[data-chat-composer-surface] .ProseMirror a. Pass rule: each<a>has no.hyperlink-iconchild.
Step 4. Measure vertical-align, then write the design-system entry
- Use the
docsplus(light) theme at 100% zoom. In Chrome, Safari and Firefox, measure a one-line paragraph at body 16px, a 24pt heading and the 28pt Title. Each holds a docs.plus link and amailto:link. - For each of
-0.125em,text-topandtext-bottom:- read the block height with
el.getBoundingClientRect().height, with the link icons shown and with.hyperlink-icon { display: none }set in DevTools. The difference is the line-box growth; - take a 200% screenshot of the icon beside the text.
- read the block height with
- Pick the value with zero growth at line-height 1.15 that looks centred on the text. Post the table and the screenshots as a comment here.
- Set that value in
document-styles.scss. If it differs from-0.125em, also do these extension edits:- set the same value in
extensions/extension-hyperlink/src/styles.css, in the.ProseMirror .hyperlink-iconrule from Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Step 4; - change the value wherever
extensions/extension-hyperlink/docs/styling.mdquotes it; - add one line to the
[Unreleased]section ofextensions/extension-hyperlink/CHANGELOG.md; - rebuild as in Step 0.4.
- set the same value in
- In
.cursor/docs/design-system.md, add one new###entry after the §Hyperlink popovers table (after line 619, before### Media insert panel internals).- Heading:
### Link icon (.hyperlink-icon) — both. - Second line: the file paths only,
styles/document-styles.scss(resolvercomponents/TipTap/padLinkIcon.ts). Do not name an elevation species. A link icon is not a surface, and a new species word would be a locked-doc change. - One
framerow in aState | Recipetable. It records the 1em box, the 0.25em gap, the measuredvertical-alignandpointer-events: none. It also recordscurrentColorglyphs at Lucide stroke 2, no radius, no tile, the print hide and "maintainer ruling, 2026-10-17". - Add no token. If the entry seems to need any other rule, stop and comment here.
- Heading:
Step 5. Privacy paragraph (stop until a Maintainer ruling comment exists for Decision 1 and Decision 2)
- If
pages/privacy.tsxalready has a "Link icons" section (Load profile and preview favicons from our route, not from Google #517 Step 6.2 adds it when it ships first), extend it instead of adding a second one. Put the sentence "When you open a document, your browser asks our server for the icon of each linked website." at its start, and do not repeat the fetch or log sentence the section already has. This issue owns the section. - Otherwise, add a
LegalSectiontitled "Link icons" topages/privacy.tsx, after "Who we share with". Use this text when Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1 drops the Traefik query:- "When you open a document, your browser asks our server for the icon of each linked website. The request carries the site address, not the full link."
- "Our server then fetches that icon from the site, so the site sees our server, not you. Our logs keep your IP address and the request path, not the site address."
- When Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1 keeps the query, replace the last sentence with: "Our logs keep your IP address. For icons that fail to load, our proxy logs also keep the site address."
- Only when Decision 1 below is ruled no and Load profile and preview favicons from our route, not from Google #517 is not on
main, add: "Profile links and link previews load site icons from Google, which receives the site address and your IP address." - Apply the Decision 2 ruling to
LEGAL_UPDATEDinlegalMetadata.ts. - Do not change "How long we keep data". The server stores no icon: Add an icon route to link-metadata with an operator-set byte cap #515 uses no Redis and no controller log. Browsers cache icons under the route's own
Cache-Control. - Before the text ships, check every log that sees an icon request:
- the Traefik access log follows Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1;
- the pino request log keeps
c.req.pathonly (apps/hocuspocus.server/src/middleware/index.ts:154-172); - run
grep -rn "accessLog\|access_log\|morgan\|requestLogger" apps/hocuspocus.server/src scripts/traefik docker-compose.prod.yml. If the grep finds any other log that keeps the query, stop and comment here.
Step 6. The ruled Cypress spec
Create apps/webapp/cypress/e2e/document/hyperlink-icon-prefill.cy.ts. It needs the full local stack, so it lives in e2e/document/ with the other full-stack specs, not in the /editor-only e2e/editor/hyperlink/.
- Header. Copy the shape of
draft-first-edit-anchor.cy.ts:1-6: it needsmake dev-local, and no CI lane runse2e/document/**. Noexport {}line; the sibling specs have none. - Shared setup.
const BASE = Cypress.config('baseUrl') ?? 'http://localhost:3000', a fresh slug per test, andafterEach(() => cy.task('deleteDocBySlug', slug)).cypress.config.tssets nobaseUrl.- Add the fixture
apps/webapp/cypress/fixtures/link-icon-16.png, a copy ofapps/webapp/public/icons/favicon-16x16.png. - In
beforeEach, runcy.intercept({ method: 'GET', pathname: '/api/metadata/icon' }, { fixture: 'link-icon-16.png', headers: { 'content-type': 'image/png' } }). The spec then never fetches a real favicon.
- Selectors. Select the link icon by
a .hyperlink-icon, the published DOM contract ([Tracking] Inline link icons through a hyperlink mark view #512 §API contract); the mark view sets no test id. Thedata-testidrule atextensions/CLAUDE.md:37covers the popover hosts. For the desktop Edit button, use the sibling spec's selector. - Desktop leg, on
${BASE}/${slug}atcy.viewport(1280, 800):- Click the pad, press Enter, then
realType('[GitHub](https://github.com) and mailto:user@example.com '). The Markdown rule is proven athyperlink-markdown.cy.js:25, and themailto:autolink athyperlink-autolink.cy.js:59. - Wait until each of the two
<a>holds one.hyperlink-icon. - Click the
GitHublink, then.hyperlink-preview-popover button.edit. Assert that[data-testid="hyperlink-edit-popover"] [data-testid="hyperlink-editor-text"]has the valueGitHub. - Close the form with Escape. Repeat for the
mailto:link, and expectmailto:user@example.com.
- Click the pad, press Enter, then
- Prove it by sabotage.
- First copy
padLinkIcon.tsoutside the repo. Remove!el.textContentfrom the check inlinkIconMarkView.ts. InpadLinkIcon.ts, append a text node ('x') to the glyph span. If Fill the link Edit forms from the document, not from innerText #518 is onmain, also change the row 1 read indesktopPopoverEntries.tsback toopts.link.innerText. - Rebuild as in Step 0.4.
- Run the spec. The desktop
mailto:assertion must fail. - Restore the files, then repeat item 2. Run
cmpon the copy andpadLinkIcon.ts, and rungit diff --exit-code extensions/extension-hyperlink/src/link-icons/ apps/webapp/src/components/TipTap/hyperlinkPopovers/desktopPopoverEntries.ts. Expect no difference from either. Run the spec and expect a pass.
- First copy
Step 7. Rule files and docs
Edit every file in Docs to update.
Step 8. Browser walk
Run on desktop Chrome, Safari and Firefox, iOS Safari and Android Chrome with Gboard. Run the theme matrix (docsplus, docsplus-dark, docsplus-dark-hc) only on Placement, Image links, Mixed marks, Underline and Selection. Run the other legs once. Post the results as a comment here. List the legs you cannot run in that comment. They go to the maintainer; #512 Device legs names them.
- Placement. A link mid-paragraph and at the start of a paragraph, a heading and a list item. Pass: the link icon is inline, with computed
display: inline-blockand a height of 1em, and the docs.plus logo reads at 1em in all three themes. - Image links. Pass: an image-led link shows the link icon before the image, and an image-only link shows a link icon (Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Decision 1).
- Mixed marks. An inline-code pill and a bold first segment inside a link. Pass: no overlap and no extra gap beyond 0.25em.
- Underline. Put a link inside underlined text. Pass: no underline under the link icon or the gap; a screenshot otherwise.
- Selection. Select across an iconed link. Pass: the highlight is continuous, and the link icon is not painted twice.
- TOC rail hover. Pass: no favicon cover.
- Print preview. Pass: no link icons.
- Screen readers. VoiceOver reads the link text only.
- History and compare mode. Pass: link icons show, and a diff range that holds a link highlights the link text with one link icon and no layout jump.
- Paste. Press
⌘A,⌘C, then⌘Vin a fresh pad (AGENTS.md:112). Then runapps/webapp/cypress/e2e/editor/copy-paste/full-doc-paste.cy.jsonce with this change. Pass: the links come back once each, with no Image node in the content and no doubled link icon, and the spec is green. - Modal over embeds. Open a modal dialog (Share or Settings) over a pad with iconed links and an iframe embed. Pass: no embed reloads (
apps/webapp/src/components/TipTap/CLAUDE.md§Editor Performance). - Workbox. With REST on the same origin as the webapp (local dev), open a pad, then check Application → Cache Storage in DevTools. Pass: no
/api/metadata/iconentry in any cache.
Step 9. Performance and memory gates
A real-browser check passed these gates before filing (#512 §Tests and benchmarks). This step measures the shipped code on a real pad, so its numbers bind, not the earlier ones.
Measure in Chrome on a laptop, in the dev build, with two clients on one pad. The dev build is intended. Every gate is a delta with linkIcon minus without it, so dev-mode overhead appears on both sides (read from code, not measured). The house also bans a production build beside a live dev server (AGENTS.md §Test Policy). Use the prosemirror-view version that ships.
Set up the pad.
- Outside the repo, write a Markdown file with
bun -e: 2000 paragraphs, one link each, with a blank line between paragraphs. Cycle about 50 real origins, plushttps://docs.plus, amailto:link and one miss originhttps://no-icon.invalid. - Create a public pad and import the file through Document settings → Import (
TipTap/toolbar/desktop/ImportExportSection.tsx). Check thatdocument.querySelectorAll('.ProseMirror p').lengthis about 2000. - Open the pad in two Chrome profiles. Client A measures, and client B makes the remote edits.
- For the "without" side, comment out
linkIcon: padLinkIconinTipTap.tsxand hard-reload both clients. Restore it after, and hard-reload again.
Counters, pasted in client A's DevTools console:
const ed = document.querySelector('.ProseMirror').editor, v = ed.view
const n = { state: 0, flush: 0 }, t = []
const us = v.updateState.bind(v); v.updateState = (s) => { n.state++; return us(s) }
const fl = v.domObserver.flush.bind(v.domObserver); v.domObserver.flush = () => { n.flush++; return fl() }
const dp = v.dispatch.bind(v); v.dispatch = (tr) => { const a = performance.now(); dp(tr); t.push(performance.now() - a) }tholds sync dispatch times in ms. Measure three keystroke kinds: in plain text, inside a link, and at a block-start link. For each, type 20 warm-up characters, then measure 200. Report the median and p95. The gate scores sync dispatch. Also record one double-requestAnimationFramenumber per kind, as information.- Cold mount: 5 cold loads with
linkIconand 5 without. The pad mounts empty, and the first y-sync fill does the link work. So time from the first y-sync fill to the next frame, using the Performance trace. Report the median of 5. Measure the history view separately, because it mounts prefilled. - Count resolver calls with a DevTools logpoint,
console.count('resolver'), on thedocsplusMap(ctx) ?? …line inpadLinkIcon.ts. Reset it withconsole.countReset('resolver'). - Count icon requests in the REST server log, or in Network panel rows filtered by
metadata/iconthat show no(memory cache)or(disk cache)marker. - The icon rate budget must be at least 10 times the distinct origins of one open. The code default gives 1000 per 15 minutes per IP (
RATE_LIMIT_MAX100 × 10). The Step 9 pad has about 50 icon origins, so it needs 500 and passes. Undermake dev-local,.env.examplesetsRATE_LIMIT_MAX1000, so the budget is 10000 (Add an icon route to link-metadata with an operator-set byte cap #515 Step 10). - To type into the hole, set the DOM selection to
(a, 1)by script, on the<a>of a block-start link (Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 test 5). On prosemirror-view 1.42.3, ArrowLeft parks the caret at the start of the text span instead, so it never reaches the hole (laptop, Chromium headless and WebKit headless, layout on).
Time gates (engineering defaults):
- Keystroke dispatch delta at 2000 link blocks: median ≤ 0.5 ms.
- Cold-mount delta at 2000 link blocks: ≤ 40 ms. jsdom showed +20.2 ms (laptop, jsdom view, no layout).
- With the DevTools 4x CPU throttle, each value is at most 4 times the number above.
Count gates:
- Resolver calls per local keystroke, per remote keystroke and per metadata write: 0.
- Resolver calls on a remote Enter and on a remote 10-block paste. If
@tiptap/y-tiptaponmainis older than 3.0.10, record the count; it is not 0 (Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520). From 3.0.10, pass: at most 1 per inserted link block in a real browser (1 for Enter, 10 for a 10-block paste). The Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 jsdom floor of 2 and 20 counts one extra build per block that real browsers do not make. - Remote insert time. If
@tiptap/y-tiptaponmainis older than 3.0.10, record the remote top-insert delta at 2000 link blocks, and comment here. The real-browser check measured +22 ms (Chromium, sync) on 3.0.9. There is no gate, because Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 is the recommended fix. updateStatecalls caused by icon loads on a cold open: 0.updateStatecalls per character typed into the hole: 1. Recordn.flushtoo.- Icon requests on a cold open: at most 1 per distinct origin, and no
429on the icon route. - New requests on an Enter cascade or a remote 10-block paste: 0. Include the miss origin. No flash: in the next
requestAnimationFrameafter the remote change, count.ProseMirror img.hyperlink-icon[hidden]. The count equals the number of miss-origin links. Run this gate in Chrome, Safari and Firefox.
Memory:
- Open a pad with 50+ links, including
mailto:, a docs.plus link and a miss. Collect garbage and take a heap snapshot. - Switch pads five times, end on Home, and snapshot again. Pass: Editor and EditorView counts are 0 on Home. Detached
HTMLImageElementandHTMLAnchorElementcounts do not grow. The heap slope per switch with icons is at most the slope without icons plus 1 KiB. - Expected retainers: the TOC preview clones until the next
replaceChildren, and in-flight images until their request settles.
Post every number as a comment here, with the label "laptop, Chrome , dev build". Then add one bullet to TipTap/CLAUDE.md §Editor Performance: at most two sentences, with the keystroke and cold-mount deltas at 2000 link blocks and that label.
Step 10. Cohesion pass
Run the DRY/KISS pass from AGENTS.md:152 over the touched files. That means a dead-import sweep and a check that no second apiBaseUrl() function or second resolver exists. The local apiBaseUrl variable in fetchDocument.ts:55-62 stays.
Review checkpoint
Show the touched files, the spec output and the gate comment, then stop. Do not run git add, git commit, git push or git stash. Do not create a branch.
Decisions
Owner: the maintainer. Each question ends with the recommended answer.
- Ship Load profile and preview favicons from our route, not from Google #517 in the same release as the privacy paragraph? Yes.
- Reason: today the profile links and the preview fallback send the reader's IP and the site address to Google. A privacy paragraph about favicons is incomplete while that is true.
- Ruling 11 covers the paragraph, not this order.
- Alternative: ship this issue first, and add the Step 5.3 sentence about Google.
- Needed by Step 5. Stop until a Maintainer ruling comment exists.
- Bump the shared
LEGAL_UPDATEDto the ship date, so the Terms page shows the same date? Yes.- Alternative: add a separate privacy date constant, so Terms keeps 12 October 2026.
Acceptance criteria
-
utils/apiBaseUrl.tsholds the onlyapiBaseUrl()function. The local variable infetchDocument.ts:55-62stays.fetchMetadata.tsandfetchLinkMetadata.tsimport it, and the@utilsbarrel does not export it. -
padLinkIcon.tsbuilds the resolver at module scope, andTipTap.tsxpasses it aslinkIcon. The composer passes none. - A docs.plus link and a
new.docs.pluslink show the docs.plus logo.mailto:andtel:links show the glyphs. A GitHub link shows its favicon. Anftp:link stays plain. - A miss origin shows a plain link, and typing in it makes no new request.
- Print preview shows no link icons, and the TOC preview shows no favicon cover.
- The composer shows no
.hyperlink-iconon desktop. - The measured
vertical-alignis in the CSS and in the new design-system entry. The Step 4 table is posted here. - The privacy page has one "Link icons" section, worded per the rulings.
LEGAL_UPDATEDfollows Decision 2. -
hyperlink-icon-prefill.cy.tspasses, and failed once under its sabotage. - The Step 8 and Step 9 comments exist, and every gate passed.
-
AGENTS.mdrecords the parity exception beside Revise the heading size scale to follow a documented typographic convention #369, andCONTEXT.mdhas a Link icon entry. - Decisions 1 and 2 have Maintainer ruling comments.
Verification
bun run --filter @docs.plus/webapp typecheck
bun run lint
bun run lint:styles
bun run format
bun run check:agent-docs
(cd apps/webapp && bunx cypress run --config baseUrl=http://localhost:3000 --spec cypress/e2e/document/hyperlink-icon-prefill.cy.ts)
(cd apps/webapp && bunx cypress run --config baseUrl=http://localhost:3000 --spec 'cypress/e2e/editor/hyperlink/*.cy.*')apps/webapp/tsconfig.jsonexcludescypress/.bun run lintand the run cover the spec.- The Cypress runs need
make dev-localup. For the port, see [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack. - The
editor/hyperlinkspecs run with real link icons now, because/editorshares the pad config. Their favicon requests may fail without the REST server. A hidden favicon must not fail them.
Docs to update
AGENTS.md:151: after the Revise the heading size scale to follow a documented typographic convention #369 sentence, add one sentence. "Inline link icons on every pad link are a second deliberate exception (maintainer ruling, 2026-10-17)."CONTEXT.md§Pad tools: add a Link icon entry. "The 1em element before a pad link's text, classhyperlink-icon, frompadLinkIcon: the docs.plus logo, a mail or phone glyph, or the site's favicon. Not stored in the document." Add_Avoid_: favicon (for the element), inline icon, decoration, widget.extensions/CLAUDE.md::57-62: scope "favicon wins" and "Use Lucide React components only" to the preview popover'sTYPE_TO_ICON. Add that pad link icons follow thepadLinkIconresolver. Its glyphs are Lucide, and the docs.plus logo is the one image file.:74: scope "Internal links skip the metadata fetch/favicon" to the preview popover.
apps/webapp/src/components/TipTap/CLAUDE.md::23: scope "The server never fetches pictures" to Markdown import;- §Editor Performance: one line. "Link icons use a mark view, not a
DecorationSet." Add "; a remote block insert rebuilds the link icons below it (Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520)" only if@tiptap/y-tiptaponmainis older than 3.0.10. Then the Step 9 bullet.
apps/webapp/CLAUDE.md§TOC tick rail (:288): the preview strips.hyperlink-iconby design.apps/webapp/src/components/TipTap/hyperlinkPopovers/previewShared.ts:20-25: scope the comment to the preview popover..cursor/docs/design-system.md: the new entry (Step 4).apps/webapp/src/pages/privacy.tsxandlegalMetadata.ts(Step 5).apps/webapp/cypress/e2e/editor/hyperlink/README.md: one line pointing to the full-stack spec ine2e/document/.apps/webapp/CHANGELOG.md[Unreleased]### Added: link icons on pad links, with the parity note. The rootCHANGELOG.mdgets its product note at release time, through therelease-productskill.- Only when Step 4 changes the value:
extensions/extension-hyperlink/src/styles.css,docs/styling.mdandCHANGELOG.md[Unreleased].
Out of scope
- Internal-link kind glyphs. Richer internal-link chips, and a kind icon beside the pad link #174 adds them to the front of the resolver after Build stored heading and message links from the pad path, not the address bar #331.
- Typed URL patterns, such as GitHub pull request glyphs. Not ruled.
- Link icons in the chat composer or in sent messages.
- A webapp
[hidden]rule, a glyph cache, a per-origin promise map, and a CSS::beforelink icon. Each was cut in the design review. - Moving the prefills off
innerText. Fill the link Edit forms from the document, not from innerText #518 owns it. - Legacy
linkmarks: no link icon, README caveat only. - CSP for a plain
httpREST host that is not local (Current behaviour item 13). File it separately if a self-hoster reports it. - A retention period for server request logs. The privacy page states none today, and this issue adds no new store.
Read first
AGENTS.md, mainly §Test Policy, §UI And Theme, §Workflow And Review Expectations and §Simplified English Mandate.apps/webapp/CLAUDE.md, mainly §Webapp Module Layers.apps/webapp/src/components/TipTap/CLAUDE.md.extensions/CLAUDE.md§Standalone Extension Development, §Webapp-Owned Hyperlink Popovers and §Composer Link Dialog And Internal Links.extensions/extension-hyperlink/AGENTS.md..cursor/docs/design-system.md§Change protocol and thedesign-systemskill.- 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, in full.
Activity
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.
LEGAL_UPDATEDto the ship date. Load profile and preview favicons from our route, not from Google #517 follows the same ruling.Recorded on the tracker #512.