From a4734b2c9128345b5bca1b63a97f3d896fe753a3 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sun, 27 Sep 2026 02:11:38 +0200 Subject: [PATCH] feat: article editor core --- features/editor_core.feature | 242 ++++++ features/editor_core.qa.md | 129 ++++ web/bun.lock | 1 + web/package.json | 97 +-- web/src/assets/main.css | 18 +- web/src/components/ArticleDetails.vue | 146 ++++ web/src/components/ArticleEditor.vue | 255 +++++++ web/src/components/ArticleStatusBadge.vue | 12 +- web/src/components/AuthShell.vue | 20 +- web/src/components/CharacterCounter.vue | 136 ++++ web/src/components/ConfirmDialog.vue | 51 +- web/src/components/DashboardShell.vue | 69 +- web/src/components/ErrorView.vue | 74 +- web/src/components/NewArticleModal.vue | 16 +- web/src/components/PasswordStrengthHint.vue | 11 +- web/src/components/PublishControl.vue | 58 ++ web/src/components/SaveStatus.vue | 92 +++ web/src/components/UserMenu.vue | 12 +- .../components/__tests__/AuthShell.spec.ts | 13 +- .../__tests__/CharacterCounter.spec.ts | 59 ++ .../__tests__/ConfirmDialog.spec.ts | 71 +- .../__tests__/DashboardShell.spec.ts | 50 +- .../components/__tests__/ErrorView.spec.ts | 24 +- .../__tests__/PublishControl.spec.ts | 53 ++ .../components/__tests__/SaveStatus.spec.ts | 87 +++ web/src/components/__tests__/UserMenu.spec.ts | 50 +- .../authShellLoginConformance.spec.ts | 39 +- .../__tests__/loginCardConformance.spec.ts | 37 +- web/src/composables/__tests__/useApi.spec.ts | 23 +- .../__tests__/useArticleDraft.spec.ts | 127 ++++ web/src/composables/useApi.ts | 36 +- web/src/composables/useArticleDraft.ts | 107 +++ web/src/composables/useColorMode.ts | 4 +- web/src/lib/__tests__/articleIndent.spec.ts | 85 +++ web/src/lib/__tests__/relativeDate.spec.ts | 36 +- web/src/lib/articleIndent.ts | 97 +++ web/src/router/__tests__/routerGuard.spec.ts | 29 +- web/src/router/index.ts | 16 +- web/src/theme/ui.config.ts | 37 +- web/src/views/EditorView.vue | 360 ++++++++- web/src/views/HomeView.vue | 80 +- web/src/views/LoginView.vue | 52 +- web/src/views/PendingView.vue | 6 +- web/src/views/RegisterView.vue | 45 +- web/src/views/RootView.vue | 6 +- web/src/views/VerifyView.vue | 46 +- web/src/views/__tests__/EditorView.spec.ts | 709 ++++++++++++++++++ web/src/views/__tests__/HomeView.spec.ts | 33 +- web/src/views/__tests__/LoginView.spec.ts | 133 +++- web/src/views/__tests__/PendingView.spec.ts | 84 ++- web/src/views/__tests__/RegisterView.spec.ts | 167 +++-- web/src/views/__tests__/VerifyView.spec.ts | 265 +++++-- web/visual/check.ts | 13 +- web/visual/states.ts | 163 ++++ web/vitest.config.ts | 1 + 55 files changed, 4186 insertions(+), 496 deletions(-) create mode 100644 features/editor_core.feature create mode 100644 features/editor_core.qa.md create mode 100644 web/src/components/ArticleDetails.vue create mode 100644 web/src/components/ArticleEditor.vue create mode 100644 web/src/components/CharacterCounter.vue create mode 100644 web/src/components/PublishControl.vue create mode 100644 web/src/components/SaveStatus.vue create mode 100644 web/src/components/__tests__/CharacterCounter.spec.ts create mode 100644 web/src/components/__tests__/PublishControl.spec.ts create mode 100644 web/src/components/__tests__/SaveStatus.spec.ts create mode 100644 web/src/composables/__tests__/useArticleDraft.spec.ts create mode 100644 web/src/composables/useArticleDraft.ts create mode 100644 web/src/lib/__tests__/articleIndent.spec.ts create mode 100644 web/src/lib/articleIndent.ts create mode 100644 web/src/views/__tests__/EditorView.spec.ts diff --git a/features/editor_core.feature b/features/editor_core.feature new file mode 100644 index 0000000..3a2e036 --- /dev/null +++ b/features/editor_core.feature @@ -0,0 +1,242 @@ +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, the app is not offline, and the body's HTML length is at most 10000 characters; + 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, 400 for a body over 10000 characters, 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 CharacterCounter shows "{len} / 10 000" below the editor, using the body's HTML string + length (formatting and images count); at 9000 characters or more (90%, "Near the limit") + it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the + text "{over} characters over. Shorten the text or remove an image. Formatting counts too.", + and disables both Save and PublishControl until the length is 10000 or fewer again + 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 character counter reads "4 318 / 10 000" + + 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: Near the limit — the counter warns at 9000 characters without blocking Save + Given the user is on /article/12/edit with unsaved changes making the body's HTML length + 9240 characters + Then the character counter reads "9 240 / 10 000" with a warning icon + And the Save button remains enabled + + Scenario: Too long — over 10000 characters disables Save and Publish until shortened + Given the user is on /article/12/edit with unsaved changes making the body's HTML length + 10214 characters + Then the character counter reads "10 214 / 10 000" with the text "214 characters over. + Shorten the text or remove an image. Formatting counts too." + And the Save button is disabled + And PublishControl is disabled + When the user shortens the body to 10000 characters or fewer + Then the character counter no longer shows the over-limit text + And the Save button is enabled again + + 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 diff --git a/features/editor_core.qa.md b/features/editor_core.qa.md new file mode 100644 index 0000000..5491005 --- /dev/null +++ b/features/editor_core.qa.md @@ -0,0 +1,129 @@ +# QA: Editor screen — load, edit and save (core) + +## Implementation note for coder (informational, not a QA assertion) + +`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` → +"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on- +load state needs different copy ("This article doesn't exist." / "All articles" → /article), +per `docs/design/states.md`'s HTTP-status-to-copy table. Either widen `ErrorView` with an optional +title/description/action override, or add a third `kind`; this is a same-layer prop widening, not +a new component, so it doesn't need its own slice. + +Known limitation, not a bug to silently fix: per `features/public_article_read.feature`, +`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt +(no 401). So if a session is already stale when the owner opens an *offline* article, the GET +answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this +response alone, and correctly shows the "This article doesn't exist." state. + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mock `useApi`'s `request` for `GET /v1/article/{id}` and `PATCH /v1/article/{id}` per case; no +real backend, no httptest server. Mount EditorPage inside DashboardShell with a resolved verified +session (username "marta") and a test router at `/article/12/edit`. Use fake timers for the +150 ms skeleton delay and the relative-time SaveStatus format. Use `Object.defineProperty`/mocked +`navigator.onLine` and dispatched `window` `online`/`offline` events for the offline scenarios. + +Fixture article (reused across cases unless a scenario overrides a field): id 12, title +"How we price a bathroom renovation", is_online false, body length 4318, created_at +"2026-03-12T09:14:00Z", updated_at "2026-09-26T11:02:00Z" (fixed `now` +"2026-09-26T11:02:00Z" unless a scenario advances it). + +1. **Skeleton only after 150 ms** — mock `GET /v1/article/12` unresolved; assert no skeleton + before 150 ms; advance past it; assert skeleton placeholders for title/content/details; + resolve 200; assert the Saved state renders. Separately, resolve under 150 ms; assert no + skeleton ever renders. +2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET + /v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the + breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save + disabled, PublishControl ("Publish"/"Unpublish"), and character counter "4 318 / 10 000". +3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved + changes", Save enabled, PublishControl now "Save and publish". +4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press + Ctrl+S; in each, assert `PATCH /v1/article/12` is called with the edited body, SaveStatus + "Saving…", Save disabled while unresolved. +5. **Successful save** — from Saving, resolve `PATCH` 200 with the updated article; assert + SaveStatus "Saved at {time}" and Save disabled again. +6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past + one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago". +7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network); + in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still + shows the typed body. +8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with + the same body; resolve 200; assert SaveStatus "Saved at {time}". +9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve + `PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never + reads "Save failed". +10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that + length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled. +11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with + "214 characters over. Shorten the text or remove an image. Formatting counts too.", Save + disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit + text disappears. +12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the + toolbar and editable surface are gone and a read-only rendering of title+body appears; + switch off; assert the editable surface returns with the same content. +13. **Offline blocks and recovers** — from Unsaved changes, set `navigator.onLine` false and + dispatch `window` `offline`; assert SaveStatus shows the wifi-off icon and reads "Waiting + for connection", Save and PublishControl disabled with a tooltip; set `navigator.onLine` + true and dispatch `online`; assert Save re-enables and SaveStatus reads "Unsaved changes" + again. +14. **Leave guard: Discard changes proceeds** — from Unsaved changes, click the "Articles" + breadcrumb link; assert ConfirmDialog opens with the exact title/text/button labels; click + "Discard changes"; assert the router navigates to `/article` and no further `PATCH` fires. +15. **Leave guard: Keep editing stays** — same setup; click "Keep editing"; assert the dialog + closes and the router does not navigate, and the unsaved body is still shown. +16. **No leave guard when clean** — from the Saved state, click the "Articles" breadcrumb link; + assert the router navigates to `/article` with no ConfirmDialog ever rendered. +17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body + "Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert + `sessionStorage` holds that body keyed by article id 12, and the router navigates to + `/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount + EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body + that lacks that paragraph; assert the rendered body is the restored sessionStorage draft + (with the paragraph) and SaveStatus reads "Unsaved changes". +18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at + `/article/9999/edit`; assert only the error state renders (title "This article doesn't + exist.", action "All articles"), with no breadcrumb-title/SaveStatus/Save/PublishControl/ + ArticleEditor; click "All articles"; assert the router navigates to `/article`. + +## Visual (manual verification — desktop and mobile, light and dark) + +Reference: `docs/design/screens/editor.md` and `docs/design/source/RenewCMS Screens +Back-office.dc.html`, group "Editor". Run `task visual:check -- editor` for each state below and +compare against the board frame; add each state's mock setup to `web/visual/states.ts` first, +reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updated_at "Today, +11:02"; created_at "12 Mar 2026, 09:14"; id 12; title "How we price a bathroom renovation"; +3 images). + +- [ ] Loading: skeletons for title, content and details, shaped like the real layout. +- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter + "4 318 / 10 000", details panel (Created/Updated/ID/Images/Public API URL). +- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish". +- [ ] Saving: "Saving…" SaveStatus, disabled Save. +- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec. +- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style. +- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish. +- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden. +- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled + Save/Publish, OfflineBanner visible. +- [ ] Not found (404): the standalone error state, no editor chrome. +- [ ] Mobile (all of the above at 390×844): sticky bottom bar for Save/PublishControl, details + panel as a slideover, single "Edit article" mobile top-bar title instead of the breadcrumb. +- [ ] Keyboard/focus: Tab reaches the breadcrumb, Save, Preview switch and PublishControl in a + sensible order; Ctrl/⌘ S saves from anywhere in the editor surface; the leave-guard + ConfirmDialog traps focus starting on "Discard changes" per ConfirmDialog's a11y spec, and + Esc behaves like dismissing (equivalent to "Keep editing"); the character-counter live + region announces only when crossing the warn/over thresholds, not on every keystroke. + - [ ] Motion: modal open/close and skeleton pulse follow the standard 240 ms / 1.6 s timings and + stop under reduced motion. + +## Manual verification + +- **Real GET/PATCH round trip against a running server** — Vitest only mocks `useApi`; confirm + the real 200/404/401/500 responses and the real `sessionStorage` restoration across an actual + session expiry (`JWT_SECRET`-signed cookie timing out) against `task dev` with a live SQLite + file, since automated tests never exercise the real Go handler, `UpdateArticleUseCase`, or a + real cookie's `exp` claim. +- **Real `beforeunload` prompt** — Vitest can assert `onBeforeRouteLeave`'s ConfirmDialog, but a + real browser tab-close prompt (native `beforeunload`) needs manual confirmation, since jsdom + doesn't model it. diff --git a/web/bun.lock b/web/bun.lock index 48782f8..5a4943a 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@nuxt/ui": "^4.11.2", "@tailwindcss/vite": "^4.1.18", + "@tiptap/core": "3.31.3", "@vueuse/core": "14.4.0", "pinia": "^3.0.4", "tailwindcss": "^4.1.18", diff --git a/web/package.json b/web/package.json index 40633af..d7596e0 100644 --- a/web/package.json +++ b/web/package.json @@ -1,50 +1,51 @@ { - "name": "renewcms-frontend", - "version": "0.0.0", - "private": true, - "type": "module", - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "scripts": { - "dev": "vite", - "build": "run-p type-check \"build-only {@}\" --", - "preview": "vite preview", - "build-only": "vite build", - "type-check": "vue-tsc --build", - "lint": "eslint . --fix --cache", - "format": "prettier --write --experimental-cli src/" - }, - "dependencies": { - "@nuxt/ui": "^4.11.2", - "@tailwindcss/vite": "^4.1.18", - "@vueuse/core": "14.4.0", - "pinia": "^3.0.4", - "tailwindcss": "^4.1.18", - "vue": "^3.5.25", - "vue-router": "^4.6.3" - }, - "devDependencies": { - "@iconify-json/lucide": "^1.2.136", - "@iconify/utils": "^3.1.7", - "@tsconfig/node24": "^24.0.3", - "@types/node": "^24.10.1", - "@vitejs/plugin-vue": "^6.0.2", - "@vue/eslint-config-prettier": "^10.2.0", - "@vue/eslint-config-typescript": "^14.6.0", - "@vue/test-utils": "^2.5.1", - "@vue/tsconfig": "^0.8.1", - "eslint": "^9.39.1", - "eslint-plugin-vue": "~10.5.1", - "jiti": "^2.6.1", - "jsdom": "25", - "npm-run-all2": "^8.0.4", - "playwright": "^1.63.0", - "prettier": "3.6.2", - "typescript": "~5.9.0", - "vite": "^7.2.4", - "vite-plugin-vue-devtools": "^8.0.5", - "vitest": "^5.0.2", - "vue-tsc": "^3.1.5" - } + "name": "renewcms-frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build", + "lint": "eslint . --fix --cache", + "format": "prettier --write --experimental-cli src/" + }, + "dependencies": { + "@nuxt/ui": "^4.11.2", + "@tailwindcss/vite": "^4.1.18", + "@tiptap/core": "3.31.3", + "@vueuse/core": "14.4.0", + "pinia": "^3.0.4", + "tailwindcss": "^4.1.18", + "vue": "^3.5.25", + "vue-router": "^4.6.3" + }, + "devDependencies": { + "@iconify-json/lucide": "^1.2.136", + "@iconify/utils": "^3.1.7", + "@tsconfig/node24": "^24.0.3", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.2", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/test-utils": "^2.5.1", + "@vue/tsconfig": "^0.8.1", + "eslint": "^9.39.1", + "eslint-plugin-vue": "~10.5.1", + "jiti": "^2.6.1", + "jsdom": "25", + "npm-run-all2": "^8.0.4", + "playwright": "^1.63.0", + "prettier": "3.6.2", + "typescript": "~5.9.0", + "vite": "^7.2.4", + "vite-plugin-vue-devtools": "^8.0.5", + "vitest": "^5.0.2", + "vue-tsc": "^3.1.5" + } } diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 4ed6cd9..21ae5af 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -112,7 +112,8 @@ --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; - --shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06); + --shadow-popover: + 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06); --shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18); --ease-standard: cubic-bezier(0.2, 0, 0, 1); @@ -260,9 +261,24 @@ body { .article-content li { margin: 0 0 0.375rem; } +.article-content li > p { + margin: 0; +} .article-content li::marker { color: var(--ui-text-muted); } +.article-content [data-indent='1'] { + padding-left: 1.5rem; +} +.article-content [data-indent='2'] { + padding-left: 3rem; +} +.article-content [data-indent='3'] { + padding-left: 4.5rem; +} +.article-content [data-indent='4'] { + padding-left: 6rem; +} .article-content blockquote { margin: 1.75rem 0; padding-left: 1.25rem; diff --git a/web/src/components/ArticleDetails.vue b/web/src/components/ArticleDetails.vue new file mode 100644 index 0000000..64a6b55 --- /dev/null +++ b/web/src/components/ArticleDetails.vue @@ -0,0 +1,146 @@ + + + diff --git a/web/src/components/ArticleEditor.vue b/web/src/components/ArticleEditor.vue new file mode 100644 index 0000000..58b8843 --- /dev/null +++ b/web/src/components/ArticleEditor.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/web/src/components/ArticleStatusBadge.vue b/web/src/components/ArticleStatusBadge.vue index 9cca609..c861ea4 100644 --- a/web/src/components/ArticleStatusBadge.vue +++ b/web/src/components/ArticleStatusBadge.vue @@ -2,7 +2,7 @@ withDefaults( defineProps<{ online: boolean - size?: 'sm' | 'md' + size?: 'sm' | 'md' | 'lg' }>(), { size: 'md', @@ -11,7 +11,13 @@ withDefaults( diff --git a/web/src/components/CharacterCounter.vue b/web/src/components/CharacterCounter.vue new file mode 100644 index 0000000..94f759f --- /dev/null +++ b/web/src/components/CharacterCounter.vue @@ -0,0 +1,136 @@ + + + diff --git a/web/src/components/ConfirmDialog.vue b/web/src/components/ConfirmDialog.vue index 5a830d3..049570d 100644 --- a/web/src/components/ConfirmDialog.vue +++ b/web/src/components/ConfirmDialog.vue @@ -1,15 +1,21 @@ diff --git a/web/src/components/NewArticleModal.vue b/web/src/components/NewArticleModal.vue index a53ed00..d5c3e6b 100644 --- a/web/src/components/NewArticleModal.vue +++ b/web/src/components/NewArticleModal.vue @@ -16,16 +16,26 @@ function onCancel() { diff --git a/web/src/components/PasswordStrengthHint.vue b/web/src/components/PasswordStrengthHint.vue index 856e53c..d3c8dfb 100644 --- a/web/src/components/PasswordStrengthHint.vue +++ b/web/src/components/PasswordStrengthHint.vue @@ -44,8 +44,15 @@ const items = computed(() =>