Skip to content

Show a link icon before every pad link in the webapp #516

Open

Description

@HMarzban

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 /editor shows a link icon before its text. The resolver tries three sources in order:
    1. the docs.plus logo (apps/webapp/public/icons/logo.svg) for docs.plus links;
    2. a Lucide glyph (mail or phone) for mailto: and tel: links;
    3. the favicon from our own icon route, GET /api/metadata/icon.
  • 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.md parity 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, default 16384. The webapp passes no cap, and padLinkIcon.ts has 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.svg as 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-align comes 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 link marks. 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

Current behaviour

Links are pinned to 69ca1ef1649540f7c8989dfd74ad901c741a30b1.

  1. The pad configures Hyperlink with no icon option (TipTap.tsx:203-212). The pad, history and /editor share this config. It registers ftp and mailto for autolink. Images are inline (TipTap.tsx:214), so a link can start with an image.
  2. The chat composer builds its own Hyperlink (useTiptapEditor.ts:180-189).
  3. apiBaseUrl exists twice, and both copies are private.
  4. HOME_SITE_URL comes from NEXT_PUBLIC_APP_URL, default https://docs.plus (homeMetadata.ts:1-10). LegalPage.tsx and _app.tsx already import it from outside the home page (LegalPage.tsx:2-6, _app.tsx:10-13). .env.example:359 sets it to http://localhost:3000 for local work.
  5. The pad link style is at document-styles.scss:27-36.
    • a:hover dims 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, svg display: block. .ProseMirror img { height: auto } has (0,1,1) specificity.
  6. The print hide rule is at _print.scss:33-47. The selectors are lines 33-45.
  7. 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 no ProseMirror-widget class. Without a change, the preview would show a favicon as its cover.
  8. Three Edit-form prefills read innerText (desktopPopoverEntries.ts:61-62, mobilePopoverEntries.ts:47, editHyperlinkPopover.ts:23). A link icon holds no text, so innerText stays right. The ruled spec pins the desktop read only. mobilePopoverEntries.ts:47 has no user path (Fill the link Edit forms from the document, not from innerText #518).
  9. 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, where dom is view.dom (installed @tiptap/core 3.31.3, src/Editor.ts:624-626). So a spec can read getHTML() on a pad route, where window._editor does not exist.
  10. The privacy page has no section on link icons (privacy.tsx:26-60).
  11. The parity rule and its Revise the heading size scale to follow a documented typographic convention #369 exception are at AGENTS.md:151.
  12. 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.
  13. CSP img-src allows https:, localUrls and devUrls, but not envUrls (next.config.js:303; envUrls at :211-215). An https REST host and a local one work. A plain http REST host that is not local gets no favicons. See Out of scope.
  14. isSafeHref blocks only javascript:, data:, vbscript:, file: and blob: (validateURL.ts:11, :18-25). So mailto: and tel: hrefs reach the resolver.

Target behaviour

  • Pad, history and /editor: each http(s), mailto: and tel: 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 location read. Under make dev-local, every http://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 not http(s), and appScheme tests only mailto: and tel:.
  • 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

  1. 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.
  2. 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.
  3. Read extensions/extension-hyperlink/src/link-icons/linkIconMarkView.ts on main. Confirm three facts:
    • the mark view adds hyperlink-icon to 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.
  4. In extensions/extension-hyperlink, run bunx tsup. Clear apps/webapp/.next/cache, restart the dev server and hard-refresh (extensions/CLAUDE.md §Standalone Extension Development).
  5. Run make dev-local. Open http://localhost:4000/api/metadata/icon?origin=https://github.com and expect a 200 image. See [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack.

Step 1. The pad resolver

  1. 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 create utils/apiBaseUrl.ts with the body from fetchMetadata.ts:61-65, and utils/iconRouteUrl.ts per [Tracking] Inline link icons through a hyperlink mark view #512.
    • Import it by its leaf path, @utils/apiBaseUrl, in fetchMetadata.ts and in api/users/fetchLinkMetadata.ts. Delete both private copies.
    • Do not add it to the @utils barrel (utils/index.ts). The barrel pulls in the Supabase graph (extensions/CLAUDE.md §Composer Link Dialog And Internal Links). A leaf path keeps padLinkIcon.ts and link-helpers.ts free of that graph. @utils/lucideSvgString is imported the same way.
    • Import iconRouteUrl from @utils/iconRouteUrl. Use src: iconRouteUrl in linkIconFetcher.
  2. Create apps/webapp/src/components/TipTap/padLinkIcon.ts. It sits beside TipTap.tsx, because it is editor config, not a popover. Keep the import order below; simple-import-sort warns on any other order, and bun run lint runs 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, linkIconFetcher and the siteHost guard. 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). document and iconRouteUrl run 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 are img.hyperlink-icon, with no inner child. Step 2 styles both shapes.
  1. In TipTap.tsx, add linkIcon: padLinkIcon to Hyperlink.configure (TipTap.tsx:203-212). Do not touch useTiptapEditor.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.125em with the measured value.
  • The compiled selector .ProseMirror .hyperlink-icon is (0,2,0). It beats .ProseMirror img { height: auto } at (0,1,1) and preflight img { display: block } at (0,0,1). So img.hyperlink-icon stays inline.
  • .hyperlink-icon > svg matches 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

  1. In styles/_print.scss, add .hyperlink-icon to the hide selectors (_print.scss:33-45).
  2. In toc/TocTickRail.tsx, add '.hyperlink-icon' to PREVIEW_STRIP_SEL (TocTickRail.tsx:62-78). The mark view forces this edit.
  3. Check the composer by hand. Open a pad at 1280×800, open a heading's chat, type https://github.com in the composer, and inspect [data-chat-composer-surface] .ProseMirror a. Pass rule: each <a> has no .hyperlink-icon child.

Step 4. Measure vertical-align, then write the design-system entry

  1. 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 a mailto: link.
  2. For each of -0.125em, text-top and text-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.
  3. 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.
  4. Set that value in document-styles.scss. If it differs from -0.125em, also do these extension edits:
  5. 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 (resolver components/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 frame row in a State | Recipe table. It records the 1em box, the 0.25em gap, the measured vertical-align and pointer-events: none. It also records currentColor glyphs 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.

Step 5. Privacy paragraph (stop until a Maintainer ruling comment exists for Decision 1 and Decision 2)

  1. If pages/privacy.tsx already 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.
  2. Otherwise, add a LegalSection titled "Link icons" to pages/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."
  3. 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."
  4. 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."
  5. Apply the Decision 2 ruling to LEGAL_UPDATED in legalMetadata.ts.
  6. 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.
  7. 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.path only (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 needs make dev-local, and no CI lane runs e2e/document/**. No export {} line; the sibling specs have none.
  • Shared setup.
    • const BASE = Cypress.config('baseUrl') ?? 'http://localhost:3000', a fresh slug per test, and afterEach(() => cy.task('deleteDocBySlug', slug)). cypress.config.ts sets no baseUrl.
    • Add the fixture apps/webapp/cypress/fixtures/link-icon-16.png, a copy of apps/webapp/public/icons/favicon-16x16.png.
    • In beforeEach, run cy.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. The data-testid rule at extensions/CLAUDE.md:37 covers the popover hosts. For the desktop Edit button, use the sibling spec's selector.
  • Desktop leg, on ${BASE}/${slug} at cy.viewport(1280, 800):
    1. Click the pad, press Enter, then realType('[GitHub](https://github.com) and mailto:user@example.com '). The Markdown rule is proven at hyperlink-markdown.cy.js:25, and the mailto: autolink at hyperlink-autolink.cy.js:59.
    2. Wait until each of the two <a> holds one .hyperlink-icon.
    3. Click the GitHub link, then .hyperlink-preview-popover button.edit. Assert that [data-testid="hyperlink-edit-popover"] [data-testid="hyperlink-editor-text"] has the value GitHub.
    4. Close the form with Escape. Repeat for the mailto: link, and expect mailto:user@example.com.
  • Prove it by sabotage.
    1. First copy padLinkIcon.ts outside the repo. Remove !el.textContent from the check in linkIconMarkView.ts. In padLinkIcon.ts, append a text node ('x') to the glyph span. If Fill the link Edit forms from the document, not from innerText #518 is on main, also change the row 1 read in desktopPopoverEntries.ts back to opts.link.innerText.
    2. Rebuild as in Step 0.4.
    3. Run the spec. The desktop mailto: assertion must fail.
    4. Restore the files, then repeat item 2. Run cmp on the copy and padLinkIcon.ts, and run git 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.

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-block and 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 ⌘V in a fresh pad (AGENTS.md:112). Then run apps/webapp/cypress/e2e/editor/copy-paste/full-doc-paste.cy.js once 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/icon entry 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.

  1. 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, plus https://docs.plus, a mailto: link and one miss origin https://no-icon.invalid.
  2. Create a public pad and import the file through Document settings → Import (TipTap/toolbar/desktop/ImportExportSection.tsx). Check that document.querySelectorAll('.ProseMirror p').length is about 2000.
  3. Open the pad in two Chrome profiles. Client A measures, and client B makes the remote edits.
  4. For the "without" side, comment out linkIcon: padLinkIcon in TipTap.tsx and 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) }
  • t holds 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-requestAnimationFrame number per kind, as information.
  • Cold mount: 5 cold loads with linkIcon and 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 the docsplusMap(ctx) ?? … line in padLinkIcon.ts. Reset it with console.countReset('resolver').
  • Count icon requests in the REST server log, or in Network panel rows filtered by metadata/icon that 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_MAX 100 × 10). The Step 9 pad has about 50 icon origins, so it needs 500 and passes. Under make dev-local, .env.example sets RATE_LIMIT_MAX 1000, 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-tiptap on main is 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-tiptap on main is 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.
  • updateState calls caused by icon loads on a cold open: 0.
  • updateState calls per character typed into the hole: 1. Record n.flush too.
  • Icon requests on a cold open: at most 1 per distinct origin, and no 429 on 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 requestAnimationFrame after 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 HTMLImageElement and HTMLAnchorElement counts 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.

  1. 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.
  2. Bump the shared LEGAL_UPDATED to 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.ts holds the only apiBaseUrl() function. The local variable in fetchDocument.ts:55-62 stays. fetchMetadata.ts and fetchLinkMetadata.ts import it, and the @utils barrel does not export it.
  • padLinkIcon.ts builds the resolver at module scope, and TipTap.tsx passes it as linkIcon. The composer passes none.
  • A docs.plus link and a new.docs.plus link show the docs.plus logo. mailto: and tel: links show the glyphs. A GitHub link shows its favicon. An ftp: 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-icon on desktop.
  • The measured vertical-align is 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_UPDATED follows Decision 2.
  • hyperlink-icon-prefill.cy.ts passes, and failed once under its sabotage.
  • The Step 8 and Step 9 comments exist, and every gate passed.
  • AGENTS.md records the parity exception beside Revise the heading size scale to follow a documented typographic convention #369, and CONTEXT.md has 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.json excludes cypress/. bun run lint and the run cover the spec.
  • The Cypress runs need make dev-local up. For the port, see [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack.
  • The editor/hyperlink specs run with real link icons now, because /editor shares 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, class hyperlink-icon, from padLinkIcon: 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's TYPE_TO_ICON. Add that pad link icons follow the padLinkIcon resolver. 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:
  • apps/webapp/CLAUDE.md §TOC tick rail (:288): the preview strips .hyperlink-icon by 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.tsx and legalMetadata.ts (Step 5).
  • apps/webapp/cypress/e2e/editor/hyperlink/README.md: one line pointing to the full-stack spec in e2e/document/.
  • apps/webapp/CHANGELOG.md [Unreleased] ### Added: link icons on pad links, with the parity note. The root CHANGELOG.md gets its product note at release time, through the release-product skill.
  • Only when Step 4 changes the value: extensions/extension-hyperlink/src/styles.css, docs/styling.md and CHANGELOG.md [Unreleased].

Out of scope

Read first

Activity

  1. HMarzban commented on Oct 18, 2026

    @HMarzban
    CollaboratorAuthor

    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.

    Recorded on the tracker #512.

  2. added this to the v2.3.0 milestone on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions