Feature: Inserting, editing and removing a link in the article body As a verified RenewCMS owner writing an article I want to turn text into a link from the toolbar or with Ctrl/Cmd+K, and to edit or remove it later So that readers can follow references, and no link I add can run script Background: Given this slice is SPA only; no Go code, route or API contract changes And the screen is /article/:id/edit (EditorPage in DashboardShell), per docs/design/screens/editor.md rows 11 to 27, docs/design/components.md "Editor: link" and the Editor group of the board "RenewCMS Screens Back-office.dc.html"; docs/design/README.md says "11 states" but all 17 rows are in scope And a link change is a body edit only: it moves useArticleDraft to "dirty" ("Unsaved changes", Save enabled, PublishControl "Save and publish" on a draft) and sends no request; the existing PATCH /v1/article/{id} with {"body": ""} saves it, unchanged And the body stays Tiptap HTML, a link being ; ArticleEditor keeps its props, events and exposed focus, insertImage, imageSrc and removeImage And the toolbar has a link button (icon i-lucide-link) after the list buttons, before "Clear formatting" and "Image"; 28 px on desktop, 40 px on mobile; text colour --ui-text-toned And Ctrl+K (Cmd+K on macOS) with the editor focused opens the same dialog as the button and prevents the browser's own shortcut And LinkModal is a dialog at desktop width and a bottom sheet at mobile width (viewport); fields "Link text" and "URL", toggle "Open in new tab" (off by default) with the helper text "Links open in the same tab unless you turn on a new tab."; title "Insert link" or "Edit link"; Cancel left, "Insert link" or "Save link" right; in edit mode a text button "Remove link" And a valid URL, after trimming, is http(s)://host..., mailto:address, a path with exactly one leading "/", or #anchor; everything else fails with "Enter a valid URL. Use http(s), mailto, a path or #anchor." (so javascript:, data:, vbscript:, file:, ftp:, tel:, //host, page.html and ../x are all invalid, in any letter case, with leading whitespace, control characters or entity escapes) And "Link text" must not be blank, else "Enter the link text." And validation runs on submit, and on blur for the URL; an invalid submit inserts nothing, keeps the dialog open and focuses the first invalid field And "Open in new tab" writes target="_blank" rel="noopener noreferrer" on the anchor; off writes neither And the link popover and its sheet follow the input type, not the width: a popover for a fine pointer, a bottom sheet for pointer: coarse And the popover is 288 px wide under the link: the URL in mono, then "Edit", "Open", "Remove" (error colour); the sheet shows the link text, the URL and 48 px rows "Edit link", "Open link", "Remove link" And Remove takes no confirmation and is undone by the editor's undo; Open opens a new tab with rel="noopener noreferrer" And selection edge cases follow the editor's default mark behaviour; no custom rules And automatic URL linking is untouched by this slice And target and rel exist only in ?format=html; the json and markdown reads carry only the href (accepted) And every new English string has a French entry in messages.ts Scenario Outline: The link button across its states Given the user is on /article/12/edit in the Saved state, Then the link button shows Examples: | situation | expected | | the cursor is in plain text | aria-pressed "false", --ui-text-toned | | the pointer rests on it for 400 ms (Windows or Linux) | the tooltip "Insert link (Ctrl+K)" | | the pointer rests on it for 400 ms (macOS) | the tooltip "Insert link (⌘K)" | | the input is touch | no tooltip | | it takes keyboard focus | a 2 px --ui-primary outline, 1 px offset; Enter or Space opens the dialog | | the cursor is inside a link | aria-pressed "true", --rc-sand fill, --rc-on-sand icon; pressing it opens the edit dialog | | part or all of a link's text is selected | the same active look | | an image is selected | 40% opacity, aria-disabled "true", no tooltip; pressing it and Ctrl/Cmd+K open nothing | | Preview is on, or ArticleEditor has disabled true | Ctrl/Cmd+K opens nothing | Scenario Outline: Creating a link Given the user is on /article/12/edit with the body "

We also check the timber windows before any tiling starts.

" And When the user presses Ctrl+K, or activates the link button Then LinkModal opens titled "Insert link" with "Link text" , the toggle off, and "Cancel" and "Insert link" And has focus When the user sets "Link text" to "", "URL" to "/articles/winter-checklist" and activates "Insert link" Then the dialog closes and the body holds
at that place And focus is back in the editor, SaveStatus reads "Unsaved changes", and no request has been sent Examples: | start | text-field | focus-target | text | | "timber windows" is selected | prefilled "timber windows" | "URL" | timber windows | | "timber windows" is selected | prefilled, then edited | "URL" | timber sash windows | | nothing is selected | empty, placeholder "What the reader sees" | "Link text" | winter checklist | Scenario Outline: Editing and removing an existing link Given the body has the link "timber windows" to "/articles/winter-checklist" opening in a new tab When the user Then LinkModal opens titled "Edit link" with the text, the URL (focused) and the toggle on prefilled, and "Remove link", "Cancel", "Save link" When the user Then Examples: | opens-edit | action | result | | presses Edit in the link popover | sets the URL to "https://example.org/windows" and activates "Save link" | the same anchor has that href and still opens in a new tab; SaveStatus "Unsaved changes" | | presses Ctrl+K with the cursor in the link | activates "Save link" with nothing changed | the dialog closes, the body is identical and a Saved draft stays Saved | | presses Ctrl+K with the cursor in the link | activates "Remove link" | the text stays without the link, no confirmation, "Unsaved changes"; undo brings the link back with its target | Scenario Outline: Validation in the dialog Given the dialog is open When the user sets "Link text" to "", "URL" to "" and activates "Insert link" Then And nothing is inserted, the dialog stays open and the draft's dirty state is unchanged Examples: | text | url | outcome | | timber windows | www.example | "URL" shows "Enter a valid URL. Use http(s), mailto, a path or #anchor." | | timber windows | example.com | the same URL error | | timber windows | javascript:alert(1) | the same URL error | | timber windows | JaVaScRiPt:alert(1) | the same URL error | | timber windows | javascript:alert(1) | the same URL error | | timber windows | javascript:alert(1) | the same URL error | | timber windows | data:text/html;base64,PHA+ | the same URL error | | timber windows | vbscript:msgbox(1) | the same URL error | | timber windows | file:///etc/passwd | the same URL error | | timber windows | ftp://example.org/a | the same URL error | | timber windows | tel:+33123456789 | the same URL error | | timber windows | //evil.example | the same URL error | | timber windows | page.html | the same URL error | | timber windows | ../x | the same URL error | | timber windows | http:// | the same URL error | | timber windows | https://exa mple.org | the same URL error | | | #measuring | "Link text" shows "Enter the link text." and has focus | | (spaces) | #measuring | the same text error | | | www.example | both errors show and focus goes to "Link text" | Scenario Outline: Accepted URL forms Given the dialog is open with "Link text" "x" When the user enters "" and activates "Insert link" Then the link is inserted with href "" Examples: | url | href | | https://example.org/a?b=1#c | https://example.org/a?b=1#c | | HTTP://Example.org | HTTP://Example.org | | mailto:hello@example.org | mailto:hello@example.org | | /articles/winter-checklist | /articles/winter-checklist | | #measuring | #measuring | | (padded) " /page " | /page | Scenario: The URL validates on blur Given the dialog is open and the user typed "www.example" in "URL" When focus leaves "URL" Then the URL error shows and nothing is inserted Scenario: Link: long URL Given the edit dialog is open on "planning guidance" with the 90-character URL "https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/…/2026-update.pdf" Then "URL" stays on one line, does not widen the dialog, scrolls to reveal the whole value, and its title attribute holds the full URL And "Save link" keeps its place and stays reachable at desktop and mobile width Scenario Outline: Leaving the dialog without saving Given the dialog is open with changed fields When the user Then it closes, the body and the draft's dirty state are unchanged, and focus returns to the editor with the earlier selection Examples: | leaves | | activates "Cancel" | | presses Esc | | clicks or taps outside it | Scenario Outline: Dialog or sheet by viewport, popover or sheet by input Given a link "timber windows" to "/articles/winter-checklist" in the body When Then Examples: | action | result | | the user opens the link dialog at 1440 px wide | a dialog | | the user opens the link dialog at 390 px wide | a bottom sheet with the same fields, errors and footer | | a fine pointer clicks the link, or Enter on the focused link | a 288 px popover under the link with the URL, "Edit", "Open", "Remove"; the link button is active; Esc closes it and keeps the cursor in the link | | a coarse pointer taps the link | a bottom sheet with "timber windows", the URL and 48 px rows "Edit link", "Open link", "Remove link"; Esc or a tap outside closes it | | the user activates "Open" or "Open link" | the URL opens in a new tab with rel "noopener noreferrer" | | the user activates "Remove" or "Remove link" | the link is removed with no confirmation, "Unsaved changes", and undo restores it | Scenario Outline: Rendered link states in both themes Given the body has the link "timber windows" and the theme is Then the link Examples: | state | theme | | is --ui-text-highlighted with a 1 px underline at a 3 px offset | light | | is --ui-text-highlighted with a 1 px underline at a 3 px offset | dark | | shows a 2 px underline on pointer hover | light | | shows a 2 px underline on pointer hover | dark | | shows a 2 px --ui-primary outline at 2 px offset when reached by Tab or arrows | light | | shows a 2 px --ui-primary outline at 2 px offset when reached by Tab or arrows | dark | | has an --rc-sand fill and --rc-on-sand text when the selection crosses it | light | | has an --rc-sand fill and --rc-on-sand text when the selection crosses it | dark | Scenario: Toolbar overflow at 390 px Given the viewport is 390 px wide Then the full toolbar scrolls sideways in one row without wrapping And a 24 or 32 px fade marks each side that has hidden buttons, and none on a side with nothing hidden When the toolbar is scrolled to the end Then the link, "Clear formatting" and "Image" buttons are visible, each 40 px And at 1440 px the toolbar is unchanged and shows no fade Scenario Outline: Selections that straddle a link or span blocks Given the body is "

We also check the timber windows before

any tiling starts.

" And the selection is When the user opens the dialog with the text unchanged and submits "/b" Then the new link covers the whole selected range and replaces any partial link inside it And no error occurs, and no text of the body is lost Examples: | selection | | "check the timber" (plain text and part of a link)| | "windows before" (part of a link and plain text) | | "before" to "any tiling" (across two paragraphs) | Scenario: Links survive the HTML body format and reach the other formats Given a link "timber windows" to "/articles/winter-checklist" opening in a new tab is in the body When the user saves and reloads /article/12/edit Then PATCH /v1/article/12 carried the anchor with href, target="_blank" and rel="noopener noreferrer" And the reloaded editor shows the link, and its edit dialog has the toggle on And a link saved without the toggle carries neither target nor rel When a client sends GET /v1/article/12?format=json, or ?format=markdown Then json holds a link mark with href "/articles/winter-checklist" on "timber windows", and markdown holds [timber windows](/articles/winter-checklist), with no target or rel Scenario Outline: An unsafe href already in a body is neutralised, and the next save rewrites the stored body Given GET /v1/article/12 resolves a body containing x, or the user pastes that HTML into the body When the editor loads the body Then the text "x" is kept without a link, the link button is not active on it, and clicking it navigates nowhere And the load alone does not mark the draft dirty and sends no request, so the stored body is still unchanged When the user makes any edit and saves Then PATCH /v1/article/12 carries the neutralised body, without the unsafe href, so the stored body is silently rewritten without the link And the body the editor serialises never contains "javascript:", "data:" or "vbscript:" in an href And a paste is itself an edit, so in the paste case the draft is dirty straight away Examples: | href | | javascript:alert(1) | | data:text/html, | | vbscript:msgbox(1) |