118 lines
12 KiB
Markdown
118 lines
12 KiB
Markdown
# Design decisions
|
||
|
||
Product and interface decisions that outlive the design passes. Behaviour comes from `features/`; where the two disagree, stop and ask. Screen copy and states are in `screens/*.md`, `states.md` and `routes.md`.
|
||
|
||
## Search and sharing (SEO)
|
||
|
||
### Interface
|
||
**Placement.** A "Search and sharing" section sits under "Site and authors" in the Editor sidebar and in the mobile Details sheet. It is collapsed by default, shows a one-line summary ("Ready", "2 suggestions" or "Hidden from search") and is remembered per browser like Site and authors. It holds only the slug, the full URL and three actions: "Edit search and sharing", "What the frontend gets" and the suggestion list. The fields and live previews open in a slide-over (desktop) or a 92% bottom sheet stacked over the Details sheet (mobile), because the 280 px sidebar is too narrow for previews and the slide-over rule already covers forms with a live preview. The API preview is a 720 px dialog (desktop) or a bottom sheet (mobile).
|
||
|
||
**Principles.** RenewCMS does not render pages or inject meta tags. Every field is optional and the UI shows its fallback. No scores. Suggestions never block saving or publishing; publishing is allowed regardless. Suggestions are dismissible per article.
|
||
|
||
**Fallback chain (resolved server-side, shown in the UI).**
|
||
| Output | Order |
|
||
|---|---|
|
||
| title | seo_title, then article title; site title_template applied last |
|
||
| description | seo_description, then excerpt, then first sentence of the body, then site default_description |
|
||
| image | og_image_id, then cover_image_id, then site social image |
|
||
| canonical | canonical_url, then site base_url + "/" + slug |
|
||
| robots | noindex true gives "noindex, nofollow"; otherwise site default_robots |
|
||
| og/twitter title and description | social_title, social_description, then the resolved values above |
|
||
|
||
**Soft ranges (counters, never blocks).** Meta title 30–60 characters; description 70–160; slug "very long" above 75 characters. Counter turns neutral-warning (`--ui-warning`) outside the range, never red.
|
||
|
||
**New article fields (PATCH /v1/article/{id}, all optional, snake_case).** `slug`, `excerpt` (max 300), `seo_title` (max 120), `seo_description` (max 320), `cover_image_id`, `cover_image_alt` (max 200; the cover is "ready" only with alt text), `social_title`, `social_description`, `social_image_id`, `canonical_url` (absolute https URL), `noindex` (boolean; only sets a flag in the API, it doesn't change visibility), `seo_dismissed` (string[] of suggestion codes). Slug: lowercase letters, numbers, hyphens; unique per site; 409 `slug_taken` with `suggestion` ("<slug>-2"); auto-generated from the title until edited.
|
||
|
||
**New site fields (PATCH /v1/site/{id}, owner only).** `site_name`, `title_template` (default "%title% | %site%", must contain %title%), `default_description` (max 320), `social_image_id` (existing), `default_robots` ("index" | "noindex"; existing as robots).
|
||
|
||
**New endpoints.**
|
||
- `GET /v1/article/{id}/seo`: resolved object, public for online articles, 404 for drafts to anonymous callers. Shape: `{ title, description, canonical_url, robots, og: { type, title, description, image, image_alt, url, site_name, locale }, twitter: { card, title, description, image, image_alt }, image: { url, alt, source }, authors: [{ name, url, same_as: [] }], published_at, modified_at, fallbacks: { title, description, image, canonical }, json_ld }`. `json_ld` is a ready Article object (headline, description, image, author[] with name, url and sameAs from author profile URL and social links, datePublished, dateModified, mainEntityOfPage).
|
||
- `GET /v1/site/{key}/sitemap?page=&size=`: published URLs only, total in `X-Total-Count`. Items: `{ url, modified_at, noindex }`. Decision: yes, shown on Integration under "Search and sharing" with a copy button; noindex articles are included with the flag so the frontend can skip them.
|
||
- `GET /v1/article/?seo=ready|suggestions|hidden`: filter behind "Needs attention" (suggestions) with `seo_status` and `seo_suggestions` (count) added to list items.
|
||
|
||
**Articles list.** Quiet indicator inside the Status cell (and under the title on mobile cards): nothing when ready, a lightbulb chip "N" for suggestions, an eye-off chip "Hidden" for noindex. Toolbar gets a "Needs attention" toggle next to search; tabs and counts are unchanged. It behaves the same on a single-site install (independent of the Site filter, which stays hidden).
|
||
|
||
**Authors.** No change to the Authors screen. Profile URL (website) and social links feed `authors[].url` and `same_as`, and JSON-LD `author[].sameAs`; the API preview shows them.
|
||
|
||
**Permissions.** Anyone who can edit an article edits its SEO fields. Only the owner edits site SEO defaults. 401 and 403 rows join the existing HTTP table: 403 `owner_only` on Site SEO fields, 401 on a missing session.
|
||
|
||
**Left out on purpose.** Keyword scoring and focus keywords, readability scores, redirects management, schema types beyond Article, hreflang, robots.txt and sitemap rendering (RenewCMS only provides data), automatic image generation for social cards.
|
||
|
||
### Decisions
|
||
|
||
| # | Question | Decision |
|
||
| --- | --- | --- |
|
||
| 1 | Is `slug` immutable after publication? | No. It stays editable. RenewCMS does not manage redirects, so the frontend owns them. No extra warning copy is added to the design. |
|
||
| 2 | SEO object for drafts | `GET /v1/article/{id}/seo` answers drafts to authenticated callers who can read the article, and 404 to anonymous callers. The editor preview needs it. |
|
||
| 3 | Noindex in the sitemap | Included with `noindex: true`, as drawn. The frontend decides. |
|
||
| 4 | Who applies `title_template` | The API. `title` in the SEO object is resolved. The raw `seo_title` and the site template stay readable on their own routes. |
|
||
| 5 | Backfill | Migration generates a slug for every existing article from its title (same slug rule as authors), unique per site, with `-2`, `-3` suffixes. A title with no letter or digit becomes `article`, then `article-2`, `article-3`. It runs once and is idempotent. |
|
||
| 6 | Excerpt in the list payload | Yes. Article items carry `excerpt`. |
|
||
|
||
### Implicit decisions
|
||
|
||
- Suggestions come from one server-side rule set. The same function feeds `seo_status` and `seo_suggestions` in the article list, the collapsed section summary, and the editor panel.
|
||
- The editor panel's "checking" states and the "slug taken" state use one non-persisting endpoint: `POST /v1/article/{id}/seo/check`. Its body takes the same optional SEO fields as `PATCH /v1/article/{id}`, plus an optional `body` (never `site_id` or `author_ids`), and overrides the saved values for the check only. It answers `{ status, suggestions: [{ code, field, params }], slug: { available, suggestion } }`. Copy for each code lives in the SPA, with a French entry. `seo_dismissed` codes are filtered out of `suggestions` and are never counted in the list.
|
||
- The suggestion codes are the ones the board uses: `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`.
|
||
- Cover, social and site social images are media usages: `GET /media/{id}/usage`, `used_in`, the unused filter, merge, purge and delete protection must count them. This also closes the existing gap where author avatars and site social images are missing from `GET /media/{id}/usage`.
|
||
- Roles: anyone who can edit an article edits its SEO fields and runs the check. Only the owner edits site SEO fields (403 "Only the owner can do this."). `GET /v1/site/{key}/sitemap` is public, like the other read routes that serve online articles. SEO edits are logged as an `articles` event; no field values or body text are stored.
|
||
- `PATCH /v1/article/{id}` still requires at least one known field and still answers 400 on `null`. The SEO fields join `body`, `site_id` and `author_ids`.
|
||
|
||
## Sites and authors
|
||
|
||
### Who can do what
|
||
|
||
| Action | Owner | Admin | Other roles |
|
||
|---|---|---|---|
|
||
| Create, edit, set default, delete a site | yes | no | no |
|
||
| Read the site list, choose an article's site | yes | yes | yes, if they can edit the article |
|
||
| Create, edit, delete any author | yes | yes | no |
|
||
| Read authors, pick authors on an article | yes | yes | yes |
|
||
| Create and edit their own linked author (`POST /v1/author/me`) | yes | yes | yes |
|
||
| Create an author from the editor ("Create author…", "This is me") | yes | yes | no |
|
||
|
||
### Decisions
|
||
- One site per article (`site_id` on the article). No per-site publish state: `is_online` stays the global switch. API keys stay global.
|
||
- The site key is immutable after creation. Exactly one default site exists and it cannot be deleted. Deleting a site with articles requires `reassign_to`.
|
||
- Authors are separate from users. An optional, unique link to a user. The slug is globally unique and not tied to a site. Zero authors on an article is allowed and never blocks publishing. Deleting an author with articles requires `reassign_to` (an author id or `none`).
|
||
- Media used as an author avatar or a site's default social image counts as media usage.
|
||
- The default site is seeded once from `PUBLIC_URL`. That variable is the CMS's own address (email links), so the seed is only a starting value that the owner edits. The base URL must be https, except for loopback hosts.
|
||
- Existing articles are backfilled to the default site and get no authors.
|
||
|
||
## Roles and permissions
|
||
|
||
Event names follow the backend: `role.create`, `role.edit`, `role.delete`, `role.reassign`, `user.change_role`, `user.transfer_ownership`, `user.remove`.
|
||
|
||
### Decisions
|
||
|
||
| # | Decision made | Alternative |
|
||
|---|---|---|
|
||
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
|
||
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
|
||
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
|
||
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
|
||
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
|
||
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
|
||
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
|
||
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
|
||
|
||
## Account self-service
|
||
|
||
Authored content is anonymized to "Deleted user". The email-change link lasts 2 hours. One export succeeds per user every 15 minutes, and a direct download is the export flow. "Last administrator" counts Owner. A change request for an address already in use answers like success. The account-deleted email goes to the address the account had.
|
||
|
||
## Interface defaults
|
||
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
|
||
- Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
|
||
- Article pagination: Shipped shapes: the article list body is {items, total, page, size, counts}; the media list body is an array with the total in `X-Total-Count`. Users and authors follow their own routes (see routes.md).
|
||
- The Remove action in the link popover has no confirmation; undo restores the link.
|
||
- The link button is disabled when an image is selected.
|
||
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
|
||
- Mobile toolbar scrolls sideways; no wrap, no overflow menu.
|
||
- Selected nav items keep the sand fill plus weight 500 and `aria-current` (the fill alone is 1.3:1 in light).
|
||
- Syntax colouring dropped from code samples (one rendering).
|
||
- Export "Download started" offers only Close.
|
||
- New article keeps the title counter "n / 200" (title is limited to 200); the body has no limit and no counter.
|
||
- Page title 20/28, page padding 24/16, card padding 16 (foundations.md).
|
||
- Activity position in the sidebar follows AppFrame.
|
||
- The Prototype shows a toast "Not in this prototype" for sidebar items it has no screen for (Media, Activity, Settings).
|