Feature: Editor screen — load, edit and save an article's body As a verified RenewCMS owner I want /article/:id/edit to load an article, let me edit its body, save it, and warn me before I lose unsaved work So that I can safely write and revise an article's content, with clear feedback while saving, near the length limit, offline, or when my session expires mid-edit Background: Given /article/:id/edit is a verified route (meta.auth "verified") rendered as EditorPage inside DashboardShell, per docs/design/routes.md And EditorPage calls GET /v1/article/{id} once per navigation to load the article, per docs/design/screens/editor.md and features/public_article_read.feature And the header shows breadcrumb "Articles › {article.title}" linking "Articles" to /article (mobile top bar title "Edit article" instead), an ArticleStatusBadge size "md" reflecting article.is_online ("Draft" or "Online"), a SaveStatus span, a Save button, and a PublishControl button, per docs/design/components.md And the article's title is never editable after creation, per features/article_title_rules.feature; only the body is edited here And the body is edited in ArticleEditor (modelValue bound to the fetched HTML body); any change moves useArticleDraft's state from "clean" to "dirty", and PublishControl's label switches from "Publish" (or "Unpublish", if online) to "Save and publish" while dirty and the article is a draft; clicking PublishControl itself is out of scope for this slice and is specified in a follow-on slice (editor_publish_and_delete) And Save is enabled only when useArticleDraft's state is "dirty", the request is not already in flight, and the app is not offline, whatever the body's length; it is disabled in every other case And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."}, the same contract features/edit_article_body.feature already gives the API: 200 with the updated article.Article (any body length, including empty), 404 for an id absent from the repository, 401 with no valid session And a successful save (200) sets useArticleDraft's state to "saved" and records the save time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after an hour has passed, per docs/design/components.md And a 500 response or a request that never reaches the server sets useArticleDraft's state to "error"; SaveStatus shows "Save failed" with an inline Retry button (UButton size="xs" variant="outline"); the edited body is kept exactly as typed, both in the editor and for the next Retry And a 404 response to a save (the article was deleted elsewhere) does not use the "error" SaveStatus; it shows a toast "That article no longer exists.", per docs/design/states.md's HTTP-status-to-copy table And a 401 response to a save is handled by useApi's global handling (features/api_client_global_error_handling.feature): before the redirect, useArticleDraft mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives the redirect to /login?redirect=/article/{id}/edit&reason=expired And EditorStatusBar shows a neutral "{words} words · {characters} characters" count with en-US thousands separators (e.g. "1,204 words · 7,318 characters", "0 words · 0 characters" for an empty body); characters is the body's HTML string length, words are the whitespace-separated tokens of its text content; the count has no limit, warning or error styling, per features/article_body_unlimited.feature And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a read-only rendering of the title and body in the content type scale; switching it off restores the editable surface with the same content, unchanged And when navigator.onLine is false, or the browser fires a window "offline" event, useArticleDraft's state becomes "offline"; SaveStatus shows the wifi-off icon and reads "Waiting for connection"; Save and PublishControl are disabled with a tooltip explaining why, and DashboardShell's existing OfflineBanner is shown; when the connection returns (a window "online" event, or navigator.onLine true again), Save and PublishControl re-enable according to the underlying dirty/saved/error state, which is unaffected by having been offline And navigating away (the breadcrumb "Articles" link, a sidebar nav link, the browser back button, or closing the tab) while useArticleDraft's state is "dirty" opens ConfirmDialog (neutral tone) titled "Leave without saving?", text 'Your changes to "{article.title}" haven't been saved.', cancel "Discard changes", confirm "Keep editing"; "Discard changes" proceeds with the navigation, discarding the edit; "Keep editing" closes the dialog and keeps the user on the editor with the edit intact; navigating away while "clean" or "saved" never opens this dialog And an id absent from the repository (GET resolves 404) replaces the entire editor with an error state: title "This article doesn't exist.", and a single action "All articles" navigating to /article; no breadcrumb-title, SaveStatus, Save, PublishControl or ArticleEditor renders in this state And skeleton placeholders (for the title, content and details) replace the editor's content only after 150 ms of GET /v1/article/{id} being unresolved, so a fast response never flashes a skeleton, per the Skeleton component spec Scenario: Skeletons appear only after 150 ms, never for a fast response Given the user navigates to /article/12/edit for the first time in this session When GET /v1/article/12 has not resolved 150 ms after the navigation Then skeleton placeholders replace the title, content and details areas When GET /v1/article/12 then resolves 200 with a draft article titled "How we price a bathroom renovation" Then the skeletons are replaced by the Saved state's header, editor and details Given a second, separate navigation to /article/12/edit When GET /v1/article/12 resolves in under 150 ms Then no skeleton is ever shown, and the Saved state's content renders directly Scenario Outline: Saved — a freshly loaded article, draft or online Given GET /v1/article/12 resolves 200 with an article titled "How we price a bathroom renovation", is_online , body length 4318, updated_at "Today, 11:02" When the user navigates to /article/12/edit Then the breadcrumb reads "Articles › How we price a bathroom renovation" And the ArticleStatusBadge reads "" And SaveStatus reads "Saved at 11:02" And the Save button is disabled And PublishControl reads "" And the count reads "{words} words · 4,318 characters" Examples: | is_online | badge | publish_label | | false | Draft | Publish | | true | Online | Unpublish | Scenario: Unsaved changes — any edit marks the article dirty Given the user is on /article/12/edit in the Saved state, article "How we price a bathroom renovation", draft When the user types into the body Then SaveStatus reads "Unsaved changes" And the Save button becomes enabled And PublishControl now reads "Save and publish" Scenario: Saving — Save sends the PATCH and disables itself while in flight Given the user is on /article/12/edit with unsaved changes to the body When the user activates Save Then PATCH /v1/article/12 is sent with JSON {"body": ""} And SaveStatus reads "Saving…" And the Save button is disabled Scenario: Ctrl/⌘ S saves the same way as clicking Save Given the user is on /article/12/edit with unsaved changes to the body When the user presses Ctrl+S (or ⌘+S) Then PATCH /v1/article/12 is sent with JSON {"body": ""} And SaveStatus reads "Saving…" Scenario: A successful save returns to the Saved state with the new time Given the user is on /article/12/edit in the Saving state When PATCH /v1/article/12 resolves 200 with the updated article Then SaveStatus reads "Saved at {current time}" And the Save button is disabled again Scenario: SaveStatus switches to a relative time after an hour Given the user is on /article/12/edit and PATCH /v1/article/12 resolved 200 at 11:02 When more than an hour has passed with no further edit or save Then SaveStatus reads "Saved {n} min ago" instead of "Saved at 11:02" Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Retry Given the user is on /article/12/edit with unsaved changes to the body "Revised opening paragraph." When the user activates Save And PATCH /v1/article/12 Then SaveStatus reads "Save failed" with a Retry button And the editor still shows the body "Revised opening paragraph." And no navigation occurs Examples: | failure | | resolves with a 500 response | | never reaches the server | Scenario: Retry resends the same body and can recover Given the user is on /article/12/edit in the Save failed state, with unsaved body "Revised opening paragraph." and PATCH /v1/article/12 having failed with a 500 When the user activates Retry Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."} When it resolves 200 with the updated article Then SaveStatus reads "Saved at {current time}" Scenario: A save against an article deleted elsewhere shows a toast, not the error SaveStatus Given the user is on /article/12/edit with unsaved changes to the body When the user activates Save And PATCH /v1/article/12 resolves 404 Then a toast reads "That article no longer exists." And SaveStatus does not read "Save failed" Scenario: A very long body keeps a neutral count and leaves Save and Publish enabled Given the user is on /article/12/edit with unsaved changes making the body's HTML length 120000 characters Then the count reads "19,845 words · 120,000 characters" with no warning styling And the Save button is enabled And PublishControl is enabled Scenario: Preview shows the rendered title and body without the editing surface Given the user is on /article/12/edit, article titled "How we price a bathroom renovation" with some body content When the user switches Preview on Then the editing toolbar and the editable surface are replaced by a read-only rendering of the title and body in the content type scale When the user switches Preview off Then the editable ArticleEditor and toolbar return, with the same body content unchanged Scenario: Offline blocks Save and Publish, and recovers when the connection returns Given the user is on /article/12/edit with unsaved changes to the body When navigator.onLine becomes false Then SaveStatus shows the wifi-off icon and reads "Waiting for connection" And the Save button and PublishControl are disabled with a tooltip explaining why When navigator.onLine becomes true again Then the Save button re-enables, reflecting the still-unsaved change And SaveStatus reads "Unsaved changes" again Scenario: Leaving with unsaved changes prompts, and "Discard changes" proceeds Given the user is on /article/12/edit with unsaved changes to the body When the user activates the "Articles" breadcrumb link Then ConfirmDialog opens, titled "Leave without saving?", text 'Your changes to "How we price a bathroom renovation" haven't been saved.', cancel "Discard changes", confirm "Keep editing" When the user activates "Discard changes" Then the app navigates to /article And the edited body is not saved Scenario: Leaving with unsaved changes, "Keep editing" stays on the page Given the user is on /article/12/edit with unsaved changes to the body, and the "Leave without saving?" dialog is open When the user activates "Keep editing" Then the dialog closes And the user remains on /article/12/edit with the unsaved body intact Scenario: Leaving a clean article never prompts Given the user is on /article/12/edit in the Saved state, with no unsaved changes When the user activates the "Articles" breadcrumb link Then the app navigates to /article directly, with no ConfirmDialog Scenario: Session expired mid-save keeps the draft and restores it after logging back in Given the user is on /article/12/edit with unsaved changes to the body "Paragraph added just before the session expired." When the user activates Save And PATCH /v1/article/12 resolves 401 Then the body "Paragraph added just before the session expired." is mirrored to sessionStorage keyed by article id 12 And the app navigates to /login?redirect=/article/12/edit&reason=expired When the user logs in again and is returned to /article/12/edit And GET /v1/article/12 resolves 200 with the last-saved body (without that paragraph) Then the editor shows the body "Paragraph added just before the session expired." restored from sessionStorage, not the last-saved body from the GET And SaveStatus reads "Unsaved changes" Scenario: An unknown or deleted article id replaces the editor with an error state Given GET /v1/article/9999 resolves 404 When the user navigates to /article/9999/edit Then the editor shows a single error state: title "This article doesn't exist." and one action "All articles" And no breadcrumb-title, SaveStatus, Save, PublishControl or ArticleEditor renders When the user activates "All articles" Then the app navigates to /article