chore: articles localization groundwork

This commit is contained in:
Florian Sylvain committed 2026-10-31 21:22:44 +01:00
1 parent 0bdcca8a90
commit 99a74dc3a2
23 files changed
+3524

No files matched your search

+4
View File
@@ -34,3 +34,7 @@ Every state has a desktop and a mobile frame, and the theme switch gives light a
- Duplicate implementations that look alike are not merged in code: toasts, badges, empty states and modal shells.
- The Back-office board still shows the owner, admin and user navigation. Writer frames use the restricted `user` navigation of `AppFrame`.
- The Schema board group 8 (Permissions) is superseded by the role editor, and `/settings/permissions` redirects to `/settings/roles`.
## Article localization baseline (2026-10-31)
Start with [localization.md](localization.md), then [localization-tables.md](localization-tables.md). Target states are indexed in `screens/localization-*.md`. New references: `source/RenewCMS Screens Localization.dc.html`, `LocaleFrame.dc.html`, `locale-spec.js`, and `LocalizationShell.dc.html`. The shell is isolated so existing boards retain their original AppFrame reference. This imports design artifacts only; no production behavior or visual gate has been verified. The localization handoff supersedes older title immutability and per-site slug rules only for slices that explicitly accept those changes.
+20
View File
@@ -168,3 +168,23 @@ Accessibility of the tri-state cell:
| DeleteAccountDialog | New | UModal (existing destructive ConfirmDialog shell) + UInput x2 + list + UAlert info + ULink | Profile |
| BlockedDialog | New | UModal with dismissible false, no close button; UButton primary for the next step | Profile |
Specs: field heights 40 (44 mobile), buttons 36 (44 mobile), card padding 16, section gap 16. Colors from tokens only. Destructive confirm is disabled at 40% opacity until valid. Implemented in AccountCards.dc.html and the AppFrame modal.
## Localization components
All new components use existing theme tokens, square corners, 32px desktop and 44px mobile action targets unless the referenced frame specifies otherwise. Read `localization.md` and the matching `screens/localization-*.md` first.
| Component | Built from | Props, slots and responsive behavior |
|---|---|---|
| LanguageMenu | UButton + UDropdownMenu, UBadge, UIcon | Readable versions and allowed creation actions; one menu item per action, no nested buttons; bounded scrolling; full language names and selected marker; keyboard focus returns to trigger |
| LocalizedTitle | UForm + UFormField + UInput | Per-version value, language, direction, validation and dirty state; readonly text without update permission; title remains 3–200 characters |
| TranslationDialog | UModal + UForm + UFormField + USelectMenu + UInput + URadioGroup + UAlert + UButton | Language, readable source, title, empty/copy choice; scrollable body; mobile bottom-sheet geometry; preserve entries on errors |
| SourcePane | Custom pane using UButton, UDropdownMenu, UIcon | Readonly article HTML; dir/lang on content; independent overflow; mobile source/edit switch; closing preserves draft and scroll |
| FreshnessBadge | UBadge variant="outline" + refresh icon | Separate from publication badge; text communicates state without color alone |
| ReviewBanner | UAlert + UButton | Capability-gated review, reference and tracked-source actions; unavailable/deleted source states |
| MobileFilterSheet | USlideover side="bottom" + UFormField + USelectMenu + UButton | Site, language, status, author, SEO; apply/clear; active removable chips; URL persistence |
| LocaleTabs | UTabs + UButton + UIcon | Bounded horizontal scrolling, edge cues and named scroll buttons; active tab reachable on keyboard and touch |
| SiteLanguageList | UTable desktop, responsive cards mobile; UDropdownMenu + UBadge | Owner-only mutations; readable metadata without mutation controls; default/disabled markers |
| LocalePager | Existing AppPaginator | Page/size and explicit story/version units; retain existing allowed sizes |
| LocalizationConfirm | Existing confirm dialog using UModal, UAlert, UButton | Language-specific scope; loading/failure; no disclosure of unreadable siblings |
Capability checks belong in existing access helpers/computed state. A generic CapabilityGate framework is not required. Reuse existing typed API client, toast, modal, media and editor components rather than reproducing the mockup helper implementations.
@@ -0,0 +1,55 @@
# Fresh-context implementation prompt
Paste the following into a new Codex chat in the RenewCMS workspace.
```text
Implement article localization in RenewCMS using our Claude Code /slice workflow, adapted to your available agent tools. Act as orchestrator; do not implement the whole feature in one coder pass.
Read first:
- CLAUDE.md and CODING_STANDARDS.md.
- .claude/commands/slice.md.
- .claude/agents/specifier.md, coder.md, cleaner.md, architect.md, hardener.md, qa.md.
- Any applicable AGENTS.md instructions.
- docs/design/README.md and docs/design/localization.md.
- docs/design/localization-tables.md, relevant screens/localization-*.md, routes.md, states.md, components.md.
The user authorized importing the latest Claude Design localization output into docs/design in the previous chat. That import is complete. docs/design/localization.md records corrections and precedence, including compatibility, direct saving of online content, locale handling, conflict semantics and limitations. Treat it as the integration handoff. It does not mean Gherkin scenarios have been accepted.
The imported visual sources are:
- docs/design/source/RenewCMS Screens Localization.dc.html
- docs/design/source/LocaleFrame.dc.html
- docs/design/source/locale-spec.js
- docs/design/source/LocalizationShell.dc.html
LocalizationShell isolates the exported shell from older boards; production must reuse the existing SPA shell. Existing shared design boards were not overwritten. The connected localization prototype is unfinished and imported frames have not been visually validated. Do not claim visual parity or waive visual gates. New screen files enumerate 109 target states across 12 groups. The visual checker may need explicit source/group/viewport integration as a separately scoped change.
Use fresh subagents for stages. Run dependent stages strictly sequentially and hand off actual accepted scenario text, paths, exact changed-file scope and prior reports. Claude's Agent/TodoWrite/AskUserQuestion/Read/Bash names should be mapped to available equivalents. Do not assume Sonnet/Haiku models or PushNotification exist here. Keep reports on disk under a task-specific tmp directory. Report unavailable tools honestly.
Start by inspecting the current repository and relevant existing feature pairs. Propose a concise slice roadmap grounded in the handoff, then run specifier for the first bounded foundational slice. Choose a useful complete unit, not all localization storage, UI and review at once.
Present the first slice's Gherkin and companion QA draft for explicit acceptance. Do not write accepted feature files or start coder before acceptance; this is required by .claude/commands/slice.md and .claude/agents/specifier.md. Explain that source when presenting the approval step. After acceptance, verify the promised files exist under features/ before proceeding.
Then follow /slice faithfully:
- coder uses red/green/refactor and acceptance tests.
- Measure changed lines, files and layer crossings; select fast/full path.
- cleaner and architect only under the documented gates; architect is read-only.
- One scoped mutation pass owned by the appropriate stage, through task mutate only; hardener reuses its results on the full path.
- hardener verifies boundaries, tests, scenario fidelity, and required visual conformance.
- qa audits final handoffs and accepted-scenario coverage and runs required final checks.
- Follow documented retry limits and report blockers rather than inventing a PASS.
Preserve these constraints:
- Git read-only; no commits, checkout, reset or branch writes.
- No source comments except required tool directives.
- No secret, .env or real database reads.
- Dependency direction stays api → useCases → domain ← persistence/mailer.
- Existing API shapes and anonymous/public article listing behavior stay compatible unless a slice explicitly accepts a change.
- Regional locales are preserved; language setup never translates or guesses groups.
- Every version has independent permissions and publishing; unreadable siblings stay undisclosed.
- Saving an online version updates live content under existing update permission; no unpublished-revision workflow is being added.
- Optional reference language and tracked review source are separate.
- Use existing Nuxt UI components, typed API client and design tokens.
- docs/design is read-only during implementation stages. Surface remaining contradictions during specification and obtain a concrete design decision; do not improvise in coder or grant visual exceptions yourself.
Do not ask me to restate the feature or return to Claude Design. Use the imported handoff and repository. Ask only for material unresolved product decisions or the explicit scenario acceptance required by our workflow.
```
+221
View File
@@ -0,0 +1,221 @@
# Localization board tables
Imported from `source/locale-spec.js`. Read `localization.md` first: its explicit corrections supersede conflicting text below. API additions are proposals to be specified and accepted through `/slice`, not existing endpoints.
## Decisions
| Question | Chosen behavior | Rationale | Existing behavior affected | Backend dependency | Release |
| --- | --- | --- | --- | --- | --- |
| Language model | One story group, one article id per language, one version per locale | Reuses the article editor, permissions and publish flow | None for single-language sites | group_id, lang on articles; unique (group_id, lang) | Initial |
| Default vs editing vs source | Three separate roles. Default is a site setting; editing language is the open article; source is chosen per translation | Default is not always the best source | None | source_article_id per version | Initial |
| Locale scope | Per site, list of enabled languages plus one default | Matches site-level settings | Settings → Sites gains Languages | site_languages table | Initial |
| Site locale field | Four distinct values: formatting locale (existing Site.Locale, still used for dates and numbers), default content language (new, seeded from the full Site.Locale and confirmed by the owner), article language (per version), social metadata locale (explicit or derived, never guessed) | Avoids silent reinterpretation | ResolveArticleSEO reads Site.Locale for social metadata (verify in repo); for localized articles it uses the article language | None | Initial |
| Disable vs remove | Disable keeps content and published versions. Remove only at zero versions | No configuration action deletes or unpublishes | None | Count per language; 409 language_in_use | Initial |
| Existing content | Setup assigns a language per site, in one transaction; each article becomes its own story | Metadata only; no guessed grouping | Articles gain lang | Bulk assign endpoint, atomic | Initial |
| Titles | Editable after creation. A title change never alters an existing slug. Slug changes only through explicit Edit slug; no redirects are promised (none in the backend) | Translated titles must be revisable; URLs stay stable. Same rule on single-language sites | CHANGE: titles were immutable. Slug policy applies everywhere | Allow title in PUT; slug rules | Initial |
| Slug uniqueness | Unique per (site, lang) | Same slug in two languages is normal | Today unique per site | Index change | Initial |
| Language control | Dropdown beside the title; rows show version state; interface language unchanged | Compact for 2 to 20 languages | Editor heading | translations[] on GET article | Initial |
| Switching | Opens another article id. Dirty form asks Keep editing, Discard, Save and switch | URL is the state; no lost work | None | None | Initial |
| Add translation | Draft only; copy or start empty; images/authors reused; copied text flagged Needs review | Copying is a start, not completion | None | POST translations; 409 language_exists | Initial |
| Source reference | Optional read-only pane; segment on mobile; no sync | Keeps the single editor default | None | Read access to source | Initial |
| Direction | dir on editor and reference panes only | Shell stays LTR | None | Per-language dir from locale | Initial |
| List: all languages | One row per story; title from default version, else first accessible; coverage chips | Avoids duplicates | Articles list | Grouped query, access-filtered | Initial |
| List: language selected | One row per version; Not in French tab lists stories | Units always stated | Tabs and counts | lang, tab params | Initial |
| Search | All languages: every accessible title. Language: that language only | Predictable | Search | Access-filtered | Initial |
| Publishing | Per version; copy always names the language; no bulk publish | Prevents accidental scope | Publish button copy | None | Initial |
| Deleting | Per version. Last version deletes the story. Story delete needs permission on all | Explicit scope | Delete dialog | None | Initial |
| Freshness | Needs review when the source revision changes after the reviewed revision; Mark as reviewed records it | Reliable, not timestamp-based | None | content_revision on versions; reviewed_source_revision | Initial |
| Revision-bumping fields | Title, body, excerpt, slug, image alt text, SEO and social text. Not authors, cover, site, publication | Only translatable content counts | None | Revision incremented in same write | Initial |
| Site move | Moves the whole story; destination must enable every language; slug conflicts block | No splitting or merging | Site select in editor | Move endpoint, validation | Initial |
| Hidden versions | Titles, counts, coverage, language summaries, previews, errors and deletion confirmations name only versions the user can read | No leakage | List counts, delete dialog | Filter at query level | Initial |
| Machine translation | Not included | Out of scope | None | None | Later |
| Cross-site stories | Not included | Out of scope | None | None | Later |
| Bulk publish / review | Not included | No clear need | None | None | Later |
| Slug policy | Slug generated once at creation. Changed only by Edit slug, before or after publishing. Published change warns: "Links to the old address will stop working. RenewCMS does not create redirects." | One policy, no redirect promise | Slug field | Unique (site, lang) | Initial |
| Permission model | Capabilities per version: read, edit, publish, delete, create-translation, review. UI renders from capabilities[] on the article response, not from role names | Matches role/scope permissions | Editor, list | capabilities[]; server enforces every call | Initial |
| Missing vs hidden | "Not in French" lists stories where French is absent or unreadable to the user. Add French translation on a story with an unreadable French returns a neutral 409 (see Visibility policy) | Discovery cannot leak existence | List | Conflict check ignores read permission; response is neutral | Initial |
| Source-copy access | Read access to the chosen source is rechecked at submit, inside the transaction | Access can change while the dialog is open | Add translation | Neutral 403/404 | Initial |
| Disabled languages | Versions stay readable and editable; publishing blocked until re-enabled; filter lists them under Disabled | No silent unpublishing | Site languages | enabled flag per site language | Initial |
## Capability matrix (per version, enforced server-side)
| Case | Read | Edit | Publish | Delete | Create translation | Review | UI result |
| --- | --- | --- | --- | --- | --- | --- | --- |
| P1 Read-only version | Yes | No | No | No | If can create on site | No | Banner "You can read this version. You can't edit it." No Save, Publish, Delete; formatting toolbar hidden; fields read-only; source reference still available |
| P2 Edit, no publish | Yes | Yes | No | Per scope | Per scope | If can edit | Save shown; Publish hidden; hint "Publishing needs the publish permission" |
| P3 Publish, no edit | Yes | No | Yes | Per scope | Per scope | No | Publish/Unpublish shown, fields read-only (only if existing permissions allow publish without edit) |
| P4 Sibling readable, not editable | Yes | No | No | No | n/a | n/a | Opens read-only; row tag "Read only" |
| P5 Sibling not readable | No | No | No | No | n/a | n/a | Row absent; no count, no placeholder |
| P6 Can create, not modify all | Yes | Own versions | Own | Own | Yes | Own | Story delete and move disabled: "You can't delete this story." Delete this version stays available if allowed |
| Owner / Admin | Yes | Yes | Yes | Yes | Yes | Yes | All readable versions; not inferred from role label alone |
## Field ownership, copy and fallback
| Field | Own per version | Copied on "Copy from source" | Fallback if empty |
| --- | --- | --- | --- |
| Title | Yes | Entered by the editor | None (required) |
| Slug | Yes | Generated from the new title | None |
| Body | Yes | Yes, flagged Needs review | None |
| Excerpt | Yes | Yes | First words of body |
| SEO title and description | Yes | Yes | Title / excerpt, then site defaults |
| Social text | Yes | Yes | SEO text |
| Image alt text | Yes | Yes | None |
| Cover and inline images | Reference, files shared | Yes, no duplicate files | None |
| Authors | Yes | Yes, editable | None |
| Site | Shared by the story | Same | None |
| Publication and timestamps | Yes | No | None |
## API: existing vs proposed
| Capability | Status | Request | Errors |
| --- | --- | --- | --- |
| List articles | Existing, extended | GET /v1/article/?page=&size=&q=&status=&site=&seo= (existing). Proposed: lang, tab=all / missing / review (same names as the URL; no separate mode or needs_review) | 400 invalid page/size (SPA never shows it) |
| Get article | Existing, extended | GET /v1/article/{id}. Proposed fields: lang, story_id, capabilities[], translations[] (readable only), source { article_id, revision, status: tracked / inaccessible / deleted } | 404 for hidden or deleted |
| Create article | Existing, extended | POST /v1/article/. Proposed: lang, story_id, source_article_id, copy (routes to translation rules) | 409 language_unavailable · 422 language_not_enabled |
| Edit | Existing, extended | PATCH /v1/article/{id}. Proposed: if_revision for overwrite recheck | 409 revision_conflict { current_revision } |
| Publish / unpublish | Existing | PATCH /v1/article/{id}/publish, /unpublish. Per version | 403 missing_permission · 422 language_disabled |
| Delete | Existing | DELETE /v1/article/{id}. Last version deletes the story | 403 · 404 |
| Add translation | Proposed | POST /v1/article/{id}/translations { lang, title, source_article_id, copy }. Path id is any readable version; source_article_id selects the copy source; source_revision captured in the transaction | 409 language_unavailable (neutral) · 404 (neutral) · 422 · 403 |
| Mark reviewed | Proposed | POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } | 409 source_changed · translation_changed · source_replaced · 403 |
| Change tracked source | Proposed | PATCH /v1/article/{id}/source { source_article_id / null } | 422 source_cycle · 422 source_other_story · 403 |
| Site languages | Proposed | GET/PUT /v1/site/{key}/languages { default, languages[{ lang, enabled }] }, atomic | 422 default_not_enabled · no_enabled_language · 409 language_in_use |
| Setup | Proposed | GET/POST /v1/language/setup { sites:[{ key, lang }] }, idempotent per site key; articles created during setup get the site default; retry after failure is safe | 403 · 409 setup_in_progress |
| Move story | Proposed | POST /v1/story/{story_id}/move { site }, atomic, revalidates | 409 slug_conflict · 422 language_not_enabled · 403 (neutral) |
| Publish with unsaved edits | Client sequence | PATCH /v1/article/{id} (save) then PATCH /v1/article/{id}/publish. The second call is sent only if the first returns 200. No new endpoint | Save failure: nothing published, edits stay unsaved |
| Public article (two requests) | Proposed | Exact: GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Resolve: GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. Existing article-ID links unchanged | See Public lookup |
## Accessibility and interaction
| Control | Accessible name and keyboard | Focus | Announcement |
| --- | --- | --- | --- |
| Language control | "Language: French. Switch version". Enter or Down opens a menu; arrows move; Enter selects; Escape closes | Into list on open; back to control on close | Polite: "Editing French version" |
| Add translation dialog | Focus trapped; Escape cancels unless creating | First field; back to the control | Polite: "French draft created" |
| Source segment (mobile) | Two-button radiogroup, arrows switch | Stays on the segment | Polite: "Showing English source" |
| Coverage chips | aria-label per chip: "French: draft, needs review" | Row, not chips | None |
| Dialogs on mobile | Bottom sheet, grabber, 44 px targets | As dialogs elsewhere | None |
| Reduced motion | No slide transitions on popover or sheets | | |
## Acceptance scenarios
| # | Given / When / Then |
| --- | --- |
| 1 | Given a single-language site, when creating an article, then no language control appears anywhere. |
| 2 | Given French is enabled, when an editor adds a French translation with "Copy from English", then a draft is created, the editor opens it, and the badge reads Draft and Needs review. |
| 3 | When French is published, then English publication is unchanged. |
| 4 | When English changes title or body after review, then French shows Needs review; changing authors does not. |
| 5 | Given dirty French, when choosing English, then a three-choice dialog appears and no work is lost. |
| 6 | Given a writer who cannot see the German draft, then German appears nowhere in lists, counts, menus or alternates. |
| 7 | When disabling German with 12 versions, then all 12 stay and 3 published stay online. |
| 8 | When two editors create French at once, then the second sees "French is no longer available" with entries kept. |
| 9 | When moving a story to a site missing a language, then the move is blocked and nothing splits. |
| 10 | When the interface language is Arabic or French, the editing language indicator still shows the article language. |
| 11 | Given French has unsaved edits, when the user chooses Save and publish French and the save fails, then nothing is published and the edits stay unsaved with Try saving again. |
| 12 | Given French is missing and unpublished, when a client resolves the story with lang=fr and fallback=default and the default is published, then the response is the default language with requested_lang fr and fallback true; with the default also unpublished it is 404 language_not_available. |
| 13 | Given a site locale pt-BR, when setup seeds the default language, then it reads Portuguese (Brazil) pt-BR and the owner confirms it before anything applies. |
## Existing surfaces audit and verification
| Item | Note |
| --- | --- |
| Home | Recent articles show the default-language title with a language code when not default. No other change. |
| Activity | Events gain the language: "Published French version of …". Existing events unchanged. |
| Article menu | Publish and Delete labels name the language on multilingual sites. |
| API URL copy | Copies the version URL with ?lang= on multilingual sites. |
| SEO preview | Shows the localized slug and canonical. |
| Media usage | Counts a shared image once; lists versions that use it. |
| Verification limit | Repository files (docs/design, docs/api/articles.md, EditorView.vue, ArticleView.vue, NewArticleModal.vue, ArticleDetails.vue, useArticleDraft.ts, articleDomain.go, siteDomain.go, ResolveArticleSEO.go, features/*.feature) were not accessible. Statements about them are assumptions marked verify in repo. The .claude/design paths do not exist; the same files are at the project root. |
| Decided | Slug policy, hidden-version disclosure, Home counting stories, and review storing source_revision only (no text diff) are decided in the other tables. |
## Visibility and anti-enumeration policy
| Situation | User can read it | User cannot read it |
| --- | --- | --- |
| Duplicate translation | "French already exists for this story." Action: Open French version | "French can't be created for this story." No Open action, no title, no hint a version exists |
| Create conflict response | 409 language_exists with article_id | 409 language_unavailable; identical body for exists-unreadable, disabled and lost race |
| Story delete confirmation | Names readable versions: "Delete English and French?" | Unreadable versions are not named or counted. If any blocks deletion: "You can't delete this story." plus Delete this version |
| Source unavailable | Deleted: "The source was deleted." | "The source isn't available to you." |
| Missing translation tab | Row shown; Add French translation | Same row; a 409 shows the neutral copy |
| Counts and summaries | Readable versions only | Never included |
## Public lookup and URLs
| Topic | Decision |
| --- | --- |
| CMS API vs website | CMS API URLs (/v1/...) are never shown as website URLs. Website URLs come from a proposed per-site public_url_pattern (owner), e.g. https://example.com/{lang}/{slug}. Existing convention unverified. |
| Pattern not configured | "Public URL not configured" with a link to site settings. No URL is fabricated. API links by article ID remain. |
| Exact lookup | GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Matches site + language + slug only (slug is unique per site and language). Missing or unpublished: 404 article_not_found. It never falls back and never tries another language's slug |
| Resolution by story | GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. story_id is the stable story identifier, returned on every public response and in alternates[]. No lang means the site default language. Fallback is decided only by story_id |
| Example: requested version published | story st_01, lang=fr, French published: 200, lang fr, fallback false |
| Example: requested missing or unpublished | lang=fr, no published French. Without fallback: 404 language_not_available. With fallback=default and a published default (en): 200, lang en, requested_lang fr, fallback true |
| Example: default also missing or unpublished | lang=fr, fallback=default, French and the default both unavailable: 404 language_not_available. No third language is tried |
| Example: default requested but unpublished | lang=en, English unpublished, fallback=default: 404 language_not_available |
| Compatibility | Article-ID endpoints (GET /v1/article/{id}) and existing public URLs keep working. Setup labels existing articles with the site default language |
| Response identity | Always lang (actual language). alternates[] lists only published, enabled-language versions |
| Canonical | The localized page itself, unless an explicit canonical override is set |
| Default-language change | Does not change what existing URLs return; absent or unpublished default gives 404 unless fallback is requested |
## Locale semantics
| Value | Meaning | Used for |
| --- | --- | --- |
| Site formatting locale | Existing Site.Locale | SPA dates and numbers; kept |
| Default content language | New per-site setting. Setup seeds it from the full Site.Locale when a supported language entry matches (en-GB, pt-BR). If only the language part is supported it seeds that and says so. No locale: the owner must choose. The owner confirms or changes it explicitly; nothing applies unconfirmed | Fallback, new-article default, setup |
| Article language | BCP 47 tag per version (en, fr, pt-BR, ar) | html lang, API lang, hreflang |
| Social metadata locale | Resolved in order: 1) explicit social locale on the site language (set by an owner, e.g. fr: fr_FR); 2) the language tag already has a region (pt-BR gives pt_BR); 3) Site.Locale has the same language part (site en-GB and article en give en_GB); 4) otherwise none: og:locale and og:locale:alternate are omitted for that version and the editor shows Not set. No region is invented for a bare language. og:locale:alternate lists other published versions whose locale resolves by the same rules; the rest are left out | og:locale, og:locale:alternate |
| SEO fallbacks | Empty French SEO fields fall back to French title and excerpt. English site defaults are never applied silently to a non-default language: editor shows "Empty. English site default not used." and the tag is omitted | Editor labels: "Fallback: French title", "No fallback" |
## Article list modes and sample data
| Mode | Units and count | Representative title | Row opens | Search | Sort |
| --- | --- | --- | --- | --- | --- |
| All languages | Stories | Default-language version if readable, else first readable by language code | Representative version | Any readable title | Story updated: latest readable version |
| Language selected | Versions in that language | That version | That version | That language only | Version updated |
| Missing language | Stories lacking it or unreadable in it | As All languages | Representative version | Titles in any readable language | Story updated |
| Filters | Status, Author, SEO, Needs review: a story matches if any readable version matches (language mode: the version) | | | | |
| Story Author, Updated | Union of readable authors; latest readable update | | | | |
| Differing sites | Language filter lists the union; Missing excludes sites that do not enable the language | | | | |
| Dataset (locale-fixtures.js) | Journal: 8 stories, 17 versions, page size 5. Enabled en (default), fr, de, ar; disabled es, it. Versions: en 7 (6 published), fr 4 (2 published, 2 Needs review), de 4 (3 published), ar 1, es 1, it 0. 4 stories have no French version. Needs translation 7, Needs review 2. Every list, tab count, summary and pager is computed from this one fixture. Separate fixtures: Press (3 stories, all translated, nothing-missing state), Atelier (8 languages, long labels), Journal before French (prototype start). | | | | |
## Review source and revision semantics
| Topic | Decision |
| --- | --- |
| Record | { article_id, source_article_id, source_revision, translation_revision, reviewed_by, reviewed_at } |
| Confirmation guard | Dialog carries translation_revision. If either revision moved: 409 source_changed or translation_changed; dialog says "This changed since you opened it. Review again." Nothing is marked |
| Change tracked source | Explicit action Change tracked source. Result: Needs review. Earlier reviews are kept as history |
| Cycles | Prevented (422 source_cycle). Source must be in the same story |
| Access loss vs deletion | Access lost: "The source isn't available to you right now." history kept, Mark reviewed disabled. Deleted: "Source deleted" with Choose a new source |
| Source replacement | Pick another readable version of the story; freshness becomes Needs review |
| What changed | Changed-field summary only (Title, Body, Excerpt, SEO) from field-level revision flags. No text diff this release; label "Changed fields" |
| Concurrent edits | Mark reviewed applies only to the revisions the user saw |
## Title, slug and concurrent edit flows
| Case | Behavior |
| --- | --- |
| Title editing | Inline input in heading style. States: pristine, editing, invalid ("Enter a title"), saving, failed (value kept), unsaved. Accessible label "Title (French)" |
| Slug | See Slug policy. Edit slug lives in Search and sharing |
| Conflict | "Someone else saved this version." Choices: Keep my changes (nothing sent), Download my version (title and body as text), Save over theirs (sends if_revision with the revision fetched now; a newer save gives a new 409), Reload theirs (confirm: "This discards your unsaved changes.") |
| Overwrite scope | "Replaces the title, body and search fields of this version only. Other languages are untouched." |
| Session expiry | Draft kept locally per article id (title, body, search fields, base revision) until saved or discarded. After login: "Restored unsaved changes from <time>." |
## Language configuration lifecycle
| Action | Rule |
| --- | --- |
| Disable default | Blocked: "Choose another default first." |
| Disable last enabled | Blocked: "At least one language must stay enabled." |
| Disable with content | Allowed; versions remain; published stay online; publishing blocked for drafts |
| Re-enable | Row action Re-enable; no data change |
| Remove | Only at zero versions; never the default |
| Save error | Dialog keeps language, default and toggle values; error above the footer |
| Story moves | Disabled-language versions count toward destination requirements |
## Mobile list and prototype scope
| Item | Spec |
| --- | --- |
| Filter sheet | Site, Status, Author, SEO, Language; Apply, Clear all; chips with "Remove filter Status: Draft" |
| Tabs | Scrolls with edge fade and buttons "Scroll tabs left/right"; Not in French always reachable |
| Pager | Same pager and page size as desktop; URL keeps page and size |
| Prototype flows | Wired in RenewCMS Prototype, flow Localization: F1 enable French, F2 create translation, F3 edit beside English, F4 save and publish French, F5 edit English, F6 freshness warning, F7 review, F8 missing tab, F9 switch with unsaved, F10 delete one version, F11 restricted user. Starts from the Journal-before-French fixture |
+103
View File
@@ -0,0 +1,103 @@
# Article localization implementation handoff
Baseline imported 2026-10-31 from `.claude/design`. This is a target design, not a statement that the feature exists. The user authorized this import and reconciliation. Feature and QA acceptance still happen per slice before implementation.
## Authority and imported artifacts
For localization, use this document for integration rules and corrections, `localization-tables.md` for the imported product tables, and `screens/localization-*.md` for the state inventory. Use `source/LocaleFrame.dc.html` for layout and rendered copy. This document supersedes conflicting board notes and older title/slug assumptions. Existing accepted `features/` behavior remains binding until a new accepted slice explicitly changes it. Report any remaining conflict at specification time; do not silently change behavior to match a mockup.
Imported sources are the Localization board, LocaleFrame, locale-spec.js, and LocalizationShell. LocalizationShell is a snapshot of the exporter’s AppFrame, renamed only in LocaleFrame's import. Existing AppFrame and other board sources were not overwritten. Do not introduce a second application shell in production: continue using DashboardShell and existing editor components.
The exported README and correction report are stale and were not imported. The connected Prototype and Handoff boards remain unchanged and do not demonstrate localization. This written handoff replaces the missing localization handoff, not the missing interactive prototype. No rendered visual parity, keyboard flow, browser console check or production test is claimed by this import.
## Product model
A story groups article versions within one site. Each version keeps its article ID, creator/permissions, language, title, body, slug, SEO/social fields, alt text, publication and timestamps. Enforce one version per normalized locale per story. Files are shared references, not duplicated uploads. Authors and image selections are copied independently when requested. All versions share the story's site. Site changes therefore use an atomic story move rather than moving one linked version alone.
Site default, current editing language, temporary comparison reference and tracked review source are distinct. Changing `?ref=` changes only the comparison pane. It does not update the tracked source. No machine translation, cross-site grouping, bulk publishing, synced scrolling or text diff is included.
Use existing ID conventions for new records unless the accepted backend slice establishes a reason to differ. Example `st_01` in mockups is illustrative; it does not mandate string IDs.
## Languages and existing-content setup
Owner manages `/settings/sites/:key/languages`. Editors who need language metadata can read the allowed metadata without gaining site-management access. Gate all row menus and Re-enable actions as well as Add language.
At least one language is enabled and the default is enabled. Choose another default before retiring the current one. Remove only unused, non-default languages. Disable preserves content and online versions; editing remains permitted, but draft publication and new translation creation require re-enabling. Unpublish remains available under existing permissions. Existing disabled-language versions remain accessible through permitted lists and direct IDs.
Preserve full supported locale tags such as `pt-BR`. If only a broader tag is supported, explain the suggested substitution and require confirmation. No locale means an explicit choice, not an inferred translation. Setup assigns a language and a separate story to each existing article; it does not guess translation links.
Setup must be idempotent and atomic for its declared scope. The backend slice must define create/setup concurrency and safe retries before coding: an unassigned article must not become stranded or assigned to a different default halfway through setup. Keep stable article IDs, bodies, publication and API reads. Test migrations through the repository’s applicable persistence tests; HTTP fakes alone do not establish migration correctness.
## Editing, permissions and publication
Retain existing per-version read/update/publish/delete scopes and creator semantics. Translation membership grants no additional permission. Publishing and updating are separate permissions. A user allowed to update an online article can change its live content by saving, even without publish permission; this preserves the current direct-update model. This feature does not add unpublished revisions or editorial approval of updates.
Use this copy for an online editable version: “This version is online. Saving updates the live page right away.” For draft writers without publish: “Saving keeps it as a draft.” Do not claim saving an online article produces a draft.
Both viewports use Save and publish for a dirty offline version when update and publish are allowed. Save succeeds before publish is sent. Save failure retains dirty data and sends no publish request. If save succeeds but publishing fails, content is saved offline; offer retry publication without pretending the save failed. Recheck conflicts/permissions server-side on both operations. No transactional combined endpoint is promised. Unpublish does not discard or implicitly save local edits; explain the resulting saved/offline state and retain the draft buffer.
Titles become editable for single-language and multilingual articles, using existing 3–200 character validation. Title edits never regenerate an existing slug. Explicit slug edits are permitted before or after publication. Warn: “Links to the old address will stop working. RenewCMS does not create redirects.” No redirect capability is promised.
Retain title, body, search fields and base revision after failures. Reload-theirs requires discard confirmation; download local work before discarding when requested. Overwrite uses the revision shown by the conflict dialog as its expected revision; do not silently fetch a newer revision immediately before saving and thereby bypass confirmation of another writer's changes. A subsequent update must produce a fresh conflict.
Local recovery is scoped to authenticated user and article ID. Do not restore another user's content after account switching. Define cleanup on successful save, explicit discard, logout and access revocation in the slice acceptance criteria. Draft recovery must not grant server access.
## Visibility and list semantics
Only readable versions contribute titles, author unions, timestamps, coverage, results and counts. Never include hidden siblings in public alternates or error payloads. Readable creation conflicts may link to the version; otherwise show “French can't be created for this story.” An unreadable-version conflict has no ID, title or Open action. Missing means no readable version, not proof no stored version exists. Do not advertise stronger anti-enumeration guarantees than that policy provides.
Authenticated editorial all-language view counts stories, uses the readable default-language title or deterministic first readable locale, and opens that representative version. Updated is the latest readable version update; authors are the union of readable authors. Selected-language view counts versions. Missing-language view counts stories in sites enabling the target language and searches readable source titles. Other language-filtered searches match that language's titles. All-language search matches any readable title.
Use `lang` and `tab=all|missing|review` consistently in SPA and editorial API requests. Omit default `tab=all`. Missing requires a selected language. Review works in the active story/version mode. Preserve `page`, `size`, `q`, `status`, `site`, `seo` and existing sort syntax. Author filter is a new capability, not shipped behavior. Reset page on filter changes. Preserve the existing page-size choices and URL normalization.
For combined filters in story mode require a single readable version to satisfy the version-level predicates together; do not match an English author's version and a different French draft to fabricate a combined match. The accepted list slice specifies tab counts and their filter scope explicitly, preserving existing count behavior where possible. The union-language filter must distinguish disabled languages; missing excludes sites not enabling the selected language.
Preserve public `/v1/article/` pagination, object shape and access rules. Do not silently change anonymous lists to grouped stories. Define an explicit opt-in editorial grouping contract in the accepted backend list slice; new clients must not depend on an unannounced response-type switch.
## API target and compatibility
Existing `/v1/article/` POST/GET, `/v1/article/{id}` GET/PATCH/DELETE and PATCH publish/unpublish remain. The SPA keeps `/article/:id/edit` and the existing new-article entry. API capability tables describe proposed extensions, not functioning endpoints. Preserve snake_case and existing validation/error conventions; accepted HTTP specs establish exact bodies and status codes. Do not copy mockup JSON errors over current plain-text contracts indiscriminately.
Proposed extensions include language config/setup, article language/story metadata, translation creation, source/review tracking and atomic story moves. Use one translation-creation implementation for both UI entry points. Source must belong to the same story and be readable at submit. Copy snapshots and source revision are captured together server-side. Start-empty creates no tracked source and shows No source until explicitly linked.
Slugs become unique within `(site, lang)`. Exact public lookup is `/v1/public/site/{key}/lang/{lang}/article/{slug}` and never falls back. Story resolution is `/v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default`. Without a published requested version, return not-found unless fallback was explicitly requested and the default version is published. Never try an arbitrary third language. Return actual `lang`, `requested_lang` and fallback status as appropriate. A default change affects requests omitting language; existing article-ID and explicit-language reads remain stable. Remove contrary “existing URLs never change” claims for language-omitted resolution.
Public alternates include eligible published versions only. Disabled published versions stay available by ID/exact read but are excluded from discovery alternates as designed. Decide noindex handling consistently with existing SEO output during the metadata slice.
Website URLs are not CMS API URLs. Use existing site URL conventions where compatible; a proposed owner-configured public URL pattern enables locale-aware links. No pattern/configuration means “Public URL not configured”; do not fabricate page URLs or change legacy canonical output before a slice explicitly accepts that change. Validate placeholders, URL scheme and encoding in the backend contract.
## Locale and SEO corrections
Article language determines content `lang`, direction and alternate-language identity; the SPA interface locale remains user-controlled. Existing `Site.Locale` currently participates in social metadata. Do not describe it as an existing SPA formatting setting: SPA formatting uses its own i18n locale. The feature changes article social metadata resolution deliberately rather than pretending the old field had no SEO purpose.
Preserve full article tags. Resolve social locale from explicit per-language configuration, a supported regional tag mapping, or a matching existing site locale; otherwise omit it and show Not set. Validate mappings instead of inventing a region. Bare languages do not imply a particular country. Respect existing canonical overrides.
Local SEO fallback uses the local title/body/excerpt according to existing fallback behavior. Do not silently inject a different language's site text. If a site textual fallback is used, its language must match the article language; default-language changes do not relabel existing site text. Image defaults may still be shared, with per-version alt text. The metadata slice must specify exact fallback order and conditional copy; “Description empty, tag omitted” is not universally correct when a local body/excerpt can supply it.
## Review and concurrency
Store source identity, source content revision, translation revision, reviewer and review time. Review confirmation must check both revisions and that the tracked source is unchanged. Source replacement must be in the same story, readable and cycle-free. Replacing it sets Needs review. Keep past review history when the source is deleted or access is temporarily lost; do not reveal unreadable source metadata. Access loss disables review while allowing permitted translation edits.
Content revision changes include title/body/excerpt/slug/alt/SEO/social text. Authors, site moves, image selection and publication do not increment the localization content revision. Use a separate edit concurrency version if needed so non-content updates still cannot be overwritten silently. Changed-field summaries must aggregate fields changed since the reviewed source revision, not merely the latest update. No text diff or immutable historical-source viewer is promised.
## Visual validation and implementation gaps
The board contains 12 groups and 109 states. Screen files map stable IDs to exported selectors. Markups are illustrations, not production accessible controls. Implement actual Nuxt UI input/menu/tab behavior. Retain the full existing editor toolbar; the shortened mockup toolbar does not authorize removing features.
The existing visual checker must be extended to discover localization's groups, state tables and 1024px reference where needed, with scoped fixtures and API mocks. Do not claim `task visual:check` can already verify the import. No threshold increases or exceptions are authorized. Clock-sensitive skips only apply to states whose rendering actually depends on current time, not whole features containing dates.
Known board limitations to settle in the relevant accepted slice: partial sample rows despite full-page totals; alternate empty-state fixtures; desktop/mobile action parity; read-only config controls; title validation bounds; source reference SEO metadata; restore/reload interactions; and state fixtures consistent across lists and editor. If matching the board would violate this handoff or an accepted scenario, document the discrepancy and obtain the workflow's explicit design decision before coding. Do not waive visual checks or silently improve the reference in a coder stage.
## Recommended slice order
1. Language value rules, site configuration and existing-content setup/migration contracts.
2. Story/version storage, unique constraints and authorized translation creation.
3. Article update/title/slug and revision conflict contracts, preserving public compatibility.
4. Site language settings and setup SPA.
5. Editor language navigation, translation dialog and publication scope.
6. Editorial grouped/language/missing lists with mobile filters and pagination.
7. Public story resolution, localized URL configuration and SEO metadata.
8. Optional comparison pane, tracked-source review and freshness UI.
9. Atomic story moves/deletion and integration regression coverage.
Split further to keep accepted slices reviewable. Include required backend support before each SPA slice. Each slice follows specifier acceptance → coder → size-gated cleaner/architect → hardener → qa. This order is planning guidance, not acceptance of Gherkin or permission to skip a gate.
+6
View File
@@ -81,3 +81,9 @@ Needs backend:
| /account/email/confirm?c=CODE | Verify new email (AuthCard). States in screens/account.md | Session required; without one, inline log in (as /register/validate) | POST /v1/auth/email/confirm |
| /profile | Gains change email, Your data, Danger zone | Any verified user | POST /v1/auth/email/change, POST /v1/auth/email/resend, DELETE /v1/auth/email/change, GET /v1/user/me/export, DELETE /v1/user/me |
| /login?reason=account-deleted | One-time message after deletion | Public | none |
## Localization target routes
See [localization.md](localization.md) for authoritative API and compatibility decisions. SPA: `/settings/sites/:key/languages`, `/articles?site=&lang=&tab=all|missing|review&q=&status=&author=&seo=&page=&size=`, `/article/:id/edit?ref=:sourceId`, and `?dialog=add-translation&lang=fr`. Existing `/article/new` entry remains; do not introduce `/articles/new`. Existing guards and per-version permissions remain binding. Language settings are owner-managed; readable language metadata for editors uses the permitted site metadata surface. `author` filtering is a proposed extension, not an existing API capability.
Use only `lang` and `tab` for localization list selection; no `mode` or `needs_review` synonym. No language means grouped story mode for authenticated editorial lists; `lang` selects versions; `tab=missing` selects stories without a readable selected-language version; `tab=review` selects needs-review results in the active mode. Public listing compatibility is preserved separately.
@@ -0,0 +1,24 @@
# Add a translation
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L4 Add a translation".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/article/{id}/edit?dialog=add-translation&lang=fr`
### Add a translation (group "L4 Add a translation", 8 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Copy from source | Editor → language menu → Add translation | `loc.l4.form` | `at:form` |
| 2 | Start empty | Title only | `loc.l4.empty` | `at:empty` |
| 3 | Creating | Submit in flight | `loc.l4.saving` | `at:saving` |
| 4 | Exists, readable | 409 language_exists, Open offered | `loc.l4.dup` | `at:dup` |
| 5 | Exists, not readable | Neutral 409, no Open | `loc.l4.dupHidden` | `at:dupHidden` |
| 6 | Failed | 5xx, entries kept | `loc.l4.fail` | `at:fail` |
| 7 | Source unavailable | Source deleted or access lost mid-flow | `loc.l4.gone` | `at:gone` |
| 8 | Long dialog | Long title, many authors and images; body scrolls | `loc.l4.long` | `at:long` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,27 @@
# Article list and coverage
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L7 Article list and coverage".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/articles?site&lang&tab=all|missing|review&q&status&author&seo&page&size`
### Article list and coverage (group "L7 Article list and coverage", 11 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | All languages | Stories | `loc.l7.all` | `li:all` |
| 2 | French selected | Versions | `loc.l7.fr` | `li:fr` |
| 3 | Not in French | Missing-translation discovery | `loc.l7.frMissing` | `li:frMissing` |
| 4 | Nothing missing | Separate fixture: Press (3 stories, all translated). Empty; no summary, no pager | `loc.l7.emptyMissing` | `li:emptyMissing` |
| 5 | No results | Search "zinc"; every count 0 | `loc.l7.noResults` | `li:noResults` |
| 6 | Loading | | `loc.l7.frLoading` | `li:frLoading` |
| 7 | Failed | Filters kept | `loc.l7.listFail` | `li:listFail` |
| 8 | Limited access | No dashed missing chips | `loc.l7.limited` | `li:limited` |
| 9 | Mobile filter sheet | Journal, French, Draft: 2 results | `loc.l7.filters` | `li:filters` |
| 10 | Tabs scrolled | Not in French reachable; fade and chevrons | `loc.l7.tabsScroll` | `li:tabsScroll` |
| 11 | Long labels, many languages, filters | Separate fixture: Atelier (8 languages), active filter chips | `loc.l7.long` | `li:long` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,21 @@
# Permissions, conflicts, site moves
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L11 Permissions, conflicts, site moves".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `various`
### Permissions, conflicts, site moves (group "L11 Permissions, conflicts, site moves", 5 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Session expired | Draft kept locally | `loc.l11.session` | `pm:session` |
| 2 | Concurrent update | Four choices, local work kept | `loc.l11.conflict` | `pm:conflict` |
| 3 | Move: language missing | | `loc.l11.move` | `pm:move` |
| 4 | Move: address conflict | | `loc.l11.moveSlug` | `pm:moveSlug` |
| 5 | Move: no permission | Neutral | `loc.l11.movePerm` | `pm:movePerm` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,34 @@
# Editor: language navigation
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L5 Editor: language navigation".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/article/{id}/edit (one id per version)`
### Editor: language navigation (group "L5 Editor: language navigation", 18 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Default (French draft) | Editable title, language menu, badges | `loc.l5.default` | `ed:default` |
| 2 | Menu: four languages | Open menu | `loc.l5.menu` | `ed:menu` |
| 3 | Menu: two languages | Two-language site | `loc.l5.two` | `ed:two` |
| 4 | Menu: many languages | Scrolls inside the viewport, with filter | `loc.l5.many` | `ed:many` |
| 5 | Unsaved changes | Dirty badge; Save enabled | `loc.l5.dirty` | `ed:dirty` |
| 6 | Unsaved draft: Save and publish | Button reads Save and publish French; Save stays separate | `loc.l5.dirtyPublish` | `ed:dirtyPublish` |
| 7 | Unsaved online version | Save updates the live page; Unpublish stays available | `loc.l5.onlineDirty` | `ed:onlineDirty` |
| 8 | Save failed | Stays unsaved; Try saving again; nothing published | `loc.l5.saveFail` | `ed:saveFail` |
| 9 | Switch with unsaved changes | Choose English while dirty | `loc.l5.unsaved` | `ed:unsaved` |
| 10 | Saving | Save in flight | `loc.l5.saving` | `ed:saving` |
| 11 | Saved | Toast | `loc.l5.saved` | `ed:saved` |
| 12 | Loading version | Slow switch | `loc.l5.loading` | `ed:loading` |
| 13 | Switch failed | Stays on current version | `loc.l5.navFail` | `ed:navFail` |
| 14 | Version unavailable | Deleted or no access (neutral) | `loc.l5.gone` | `ed:gone` |
| 15 | Title: editing | Focus | `loc.l5.focus` | `ed:focus` |
| 16 | Title: invalid | Empty | `loc.l5.invalid` | `ed:invalid` |
| 17 | Title: saving | | `loc.l5.titleSaving` | `ed:titleSaving` |
| 18 | Title: save failed | Text kept, still unsaved, Try again | `loc.l5.failed` | `ed:failed` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,32 @@
# Site languages
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L1 Site languages".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/settings/languages`
### Site languages (group "L1 Site languages", 16 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Single language | Owner opens Languages on Archive (one language, pt-BR kept) | `loc.l1.single` | `sl:single` |
| 2 | Multilingual | Six languages, two disabled (Spanish, Italian) | `loc.l1.multi` | `sl:multi` |
| 3 | Loading | Request in flight | `loc.l1.loading` | `sl:loading` |
| 4 | Load failed | Network or server error | `loc.l1.error` | `sl:error` |
| 5 | Add a language | Add language | `loc.l1.add` | `sl:add` |
| 6 | Add failed | Server error, selection kept | `loc.l1.saveFail` | `sl:saveFail` |
| 7 | Duplicate language | Already enabled | `loc.l1.adddup` | `sl:adddup` |
| 8 | Make default | Row menu | `loc.l1.default` | `sl:default` |
| 9 | Disable with content | Row menu → Disable German | `loc.l1.disable` | `sl:disable` |
| 10 | Disable default blocked | Disable English | `loc.l1.disableDefault` | `sl:disableDefault` |
| 11 | Disable last blocked | Only one enabled | `loc.l1.disableLast` | `sl:disableLast` |
| 12 | Re-enable | Spanish row → Re-enable | `loc.l1.reenable` | `sl:reenable` |
| 13 | Remove (no content) | Italian (0 versions) | `loc.l1.remove` | `sl:remove` |
| 14 | Remove blocked | Spanish has versions | `loc.l1.removeBlocked` | `sl:removeBlocked` |
| 15 | Read-only (403) | Non-owner: list stays readable; no Add language, Re-enable or row menus | `loc.l1.readonly` | `sl:readonly` |
| 16 | Saved | After Add language | `loc.l1.saved` | `sl:saved` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,20 @@
# Metadata and public API
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L10 Metadata and public API".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `Search and sharing panel · /integration`
### Metadata and public API (group "L10 Metadata and public API", 4 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Search and sharing: French | Localized slug, canonical, alternates | `loc.l10.seo` | `seo:` |
| 2 | Public URL not configured | No pattern, no fabricated URL | `loc.l10.unset` | `seo:unset` |
| 3 | Social locale not set | fr has no region and none is configured: og:locale omitted | `loc.l10.noSocial` | `seo:noSocial` |
| 4 | Integration: languages | Proposed requests | `loc.l10.api` | `api:` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,20 @@
# New article
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L3 New article".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/articles (New article modal)`
### New article (group "L3 New article", 4 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Single language | Title and site only | `loc.l3.single` | `na:single` |
| 2 | Multilingual: new story | Language defaults to the site default | `loc.l3.multi` | `na:multi` |
| 3 | Translation of a story | Story picker + language | `loc.l3.translation` | `na:translation` |
| 4 | Site changed | Selected language not offered by new site | `loc.l3.sitechange` | `na:sitechange` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,23 @@
# Permission states (per version)
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L5b Permission states (per version)".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/article/{id}/edit`
### Permission states (per version) (group "L5b Permission states (per version)", 7 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Read-only version | No Save, Publish, Delete, toolbar | `loc.l5b.ro` | `ed:ro` |
| 2 | Read-only: menu | No Add translation row | `loc.l5b.roMenu` | `ed:roMenu` |
| 3 | Edit, no publish | Save only | `loc.l5b.p2` | `ed:p2` |
| 4 | Publish, no edit | Publish only; fields read-only | `loc.l5b.p3` | `ed:p3` |
| 5 | Sibling readable, not editable | German tagged Read only | `loc.l5b.p4` | `ed:p4` |
| 6 | Sibling not readable | German absent, no count | `loc.l5b.p5` | `ed:p5` |
| 7 | Can create, not modify all | Story delete and move disabled | `loc.l5b.p6` | `ed:p6` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,26 @@
# Publishing and deletion
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L8 Publishing and deletion".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `same as editor`
### Publishing and deletion (group "L8 Publishing and deletion", 10 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Publish French | Confirm, English untouched | `loc.l8.publish` | `pd:publish` |
| 2 | Save and publish French | Unsaved edits: one action saves first, then publishes | `loc.l8.saveAndPublish` | `pd:saveAndPublish` |
| 3 | Save failed, nothing published | Toast; edits kept and retryable | `loc.l8.saveFailPublish` | `pd:saveFailPublish` |
| 4 | Unpublish French | Confirm | `loc.l8.unpublish` | `pd:unpublish` |
| 5 | Delete French version | Confirm | `loc.l8.delete` | `pd:delete` |
| 6 | Delete a source version | Warns about review reference | `loc.l8.deleteSource` | `pd:deleteSource` |
| 7 | Delete last version | Deletes the story | `loc.l8.deleteLast` | `pd:deleteLast` |
| 8 | Delete story blocked | Neutral, no version named | `loc.l8.deleteStory` | `pd:deleteStory` |
| 9 | Published | Toast | `loc.l8.done` | `pd:done` |
| 10 | Publish failed | Toast | `loc.l8.fail` | `pd:fail` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,27 @@
# Source reference
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L6 Source reference".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/article/{id}/edit?ref=:sourceId`
### Source reference (group "L6 Source reference", 11 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Side by side | Show source reference | `loc.l6.split` | `src:split` |
| 2 | Reference hidden | Hide source reference; button returns | `loc.l6.closed` | `src:closed` |
| 3 | Reopened | Show source reference again; scroll positions kept | `loc.l6.reopen` | `src:reopen` |
| 4 | Change reference | Menu; tracked source stays English | `loc.l6.changeRef` | `src:changeRef` |
| 5 | Narrow desktop (1024 px) | True 1024 px frame | `loc.l6.narrow` | `src:narrow` |
| 6 | Long content | Both panes scroll independently | `loc.l6.long` | `src:long` |
| 7 | Arabic beside English | dir=rtl on the Arabic title and body only | `loc.l6.rtl` | `src:rtl` |
| 8 | Mobile: editing | Segment | `loc.l6.mobileEdit` | `src:mobileEdit` |
| 9 | Mobile: source | Segment | `loc.l6.mobileSrc` | `src:mobileSrc` |
| 10 | Mobile: closed | Show source reference button | `loc.l6.mobileClosed` | `src:mobileClosed` |
| 11 | Reference unavailable | Deleted or unreadable | `loc.l6.unavail` | `src:unavail` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
@@ -0,0 +1,26 @@
# Freshness and review
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L9 Freshness and review".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `editor banner · list badge`
### Freshness and review (group "L9 Freshness and review", 10 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Source updated | English changed after review | `loc.l9.warn` | `fr:warn` |
| 2 | Published, needs review | | `loc.l9.pub` | `fr:pub` |
| 3 | Mark reviewed | Confirm with revisions | `loc.l9.review` | `fr:review` |
| 4 | Source changed again | Revision 9 while reviewing 8 | `loc.l9.changed` | `fr:changed` |
| 5 | Translation changed | Saved by someone else while confirming | `loc.l9.changedTr` | `fr:changedTr` |
| 6 | Choose tracked source | Cycle prevented | `loc.l9.replace` | `fr:replace` |
| 7 | Source unavailable (access) | History kept | `loc.l9.inaccessible` | `fr:inaccessible` |
| 8 | Source deleted | Replace | `loc.l9.gone` | `fr:gone` |
| 9 | Reviewed | Toast | `loc.l9.done` | `fr:done` |
| 10 | Review failed | Toast | `loc.l9.fail` | `fr:fail` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
+21
View File
@@ -0,0 +1,21 @@
# First-time setup for existing articles
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L2 First-time setup for existing articles".
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
Route: `/settings/sites/:key/languages (banner) · /settings/languages/setup`
### First-time setup for existing articles (group "L2 First-time setup for existing articles", 5 states)
| # | State | Trigger | Stable ID | Frame selector |
|---|---|---|---|---|
| 1 | Ready to assign | Journal (en), Notes (no locale, must choose), Archive (pt-BR kept) | `loc.l2.ready` | `su:ready` |
| 2 | Site with no articles | Gallery has no articles; en-GB kept, confirm and Save | `loc.l2.empty` | `su:empty` |
| 3 | Applying | POST in flight | `loc.l2.applying` | `su:applying` |
| 4 | Done | 16 articles labelled | `loc.l2.done` | `su:done` |
| 5 | Failed | Nothing changed; choices kept | `loc.l2.fail` | `su:fail` |
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
+343
View File
@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<dc-import name="LocalizationShell" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" src-pane="{{ srcPane }}" ed-banner="{{ edBanner }}" hint-size="100%,100%"></dc-import>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile, v = s.v, x = s.x || ''
const P = t => Object.fromEntries(t.split(';').filter(q => q.trim()).map(q => { const i = q.indexOf(':'); let k = q.slice(0, i).trim(); const val = q.slice(i + 1).trim(); if (!k.startsWith('--')) { k = k.replace(/^-webkit-/, 'Webkit-').replace(/-([a-z])/g, (_, c) => c.toUpperCase()) } return [k, val] }))
const h = React.createElement
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const A = (tag, pr, t, ...c) => h(tag, { ...pr, style: P(t) }, ...c)
const Ic = (n, z = 16) => S('display:inline-block;flex-shrink:0;width:' + z + 'px;height:' + z + 'px;background-color:currentColor;-webkit-mask:url(https://api.iconify.design/lucide/' + n + '.svg) center/contain no-repeat;mask:url(https://api.iconify.design/lucide/' + n + '.svg) center/contain no-repeat')
const HL = 'color:var(--ui-text-highlighted)', MU = 'color:var(--ui-text-muted)', TO = 'color:var(--ui-text-toned)'
const mono = "font-family:'IBM Plex Mono',monospace;font-size:12px"
const Btn = (l, k = 'secondary', o = {}) => {
const hh = m ? 44 : (o.sm ? 28 : 32)
const st = { primary: 'background:var(--ui-primary);color:var(--ui-bg)', secondary: 'background:var(--ui-bg);color:var(--ui-text-highlighted);border:1px solid var(--ui-border-accented)', ghost: 'color:var(--ui-text-toned)', danger: 'background:var(--ui-error);color:var(--ui-bg)' }[k]
return A('span', { role: 'button', 'aria-label': o.al || (o.ic && !l ? o.ic : undefined), 'aria-disabled': !!o.dis || undefined, 'aria-busy': o.busy || undefined }, 'display:inline-flex;align-items:center;justify-content:center;gap:6px;height:' + hh + 'px;padding:0 ' + (o.ic && !l ? 0 : 12) + 'px;' + (o.ic && !l ? 'width:' + hh + 'px;' : '') + 'font-weight:500;white-space:nowrap;flex-shrink:0;' + st + ';' + (o.dis ? 'opacity:.4;' : '') + (o.w ? 'width:' + o.w + ';' : ''), o.busy && Ic('loader-circle', 14), o.ic && Ic(o.ic, 16), l)
}
const TN = { neutral: 'background:var(--ui-bg-elevated);color:var(--ui-text-toned)', success: 'background:var(--rc-success-soft);color:var(--ui-success)', info: 'background:var(--rc-info-soft);color:var(--ui-info)', warning: 'background:var(--rc-warning-soft);color:var(--ui-warning)', error: 'background:var(--rc-error-soft);color:var(--ui-error)' }
const Bd = (l, tone = 'neutral', ic) => S('display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 6px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + TN[tone], ic && Ic(ic, 12), l)
// freshness uses an outline so it never reads as a publication state
const Fr = (l, tone = 'warning', ic = 'refresh-cw') => S('display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 6px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;border:1px solid currentColor;background:var(--ui-bg);color:var(--ui-' + tone + ')', Ic(ic, 12), l)
const Pub = st => st === 'pub' ? Bd('Published', 'success', 'circle-check') : Bd('Draft', 'neutral', 'pencil')
const Alert = (tone, ic, title, text, act) => D('display:flex;gap:10px;padding:12px 14px;align-items:flex-start;' + TN[tone].split(';')[0] + ';color:var(--ui-text-highlighted)', S('color:var(--ui-' + (tone === 'neutral' ? 'text-toned' : tone) + ')', Ic(ic)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S(TO + ';text-wrap:pretty', text), act && D('margin-top:8px;display:flex;gap:8px;flex-wrap:wrap', ...[].concat(act))))
const Fd = (label, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', S('font-weight:500;' + HL, label, o.opt && S(MU + ';font-weight:400', ' (optional)')),
D('display:flex;align-items:' + (o.area ? 'flex-start' : 'center') + ';gap:8px;min-height:' + (m ? 44 : 32) + 'px;padding:' + (o.area ? 8 : 0) + 'px 10px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (o.ro ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (o.focus ? 'outline:2px solid var(--ui-primary);outline-offset:1px;' : ''), o.pre && S(MU + ';' + mono + ';white-space:nowrap', o.pre), A('span', { dir: o.dir, lang: o.lang }, 'flex:1;min-width:0;' + (o.mono ? mono + ';' : '') + (o.val === false ? MU : HL) + ';overflow:hidden;text-overflow:ellipsis;' + (o.area ? 'white-space:normal' : 'white-space:nowrap'), val), o.sel && Ic('chevrons-up-down', 14), o.lock && Ic('lock', 14)),
o.err ? S('display:flex;gap:6px;color:var(--ui-error);font-size:12px;line-height:18px', Ic('circle-x', 14), o.err) : o.help && S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', o.help))
const Radio = (l, d, on) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), S('width:16px;height:16px;margin-top:2px;flex-shrink:0;border:1px solid var(--ui-border-accented);display:flex;align-items:center;justify-content:center', on && S('width:8px;height:8px;background:var(--ui-primary)')), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500;' + HL, l), d && S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', d)))
const Dlg = (o, body, foot) => D('position:absolute;inset:0;z-index:' + (o.z || 50) + ';background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding-top:' + (m ? 0 : o.top ?? 100) + 'px',
A('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': o.title }, 'width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:' + (m ? '92%' : 'calc(100% - ' + ((o.top ?? 100) + 24) + 'px)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;' + (m ? 'border-top:1px solid var(--ui-border)' : ''),
m && D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
D('padding:20px 24px 0;display:flex;gap:12px;align-items:flex-start', D('flex:1;display:flex;flex-direction:column;gap:4px;min-width:0', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, o.title), o.desc && S(TO + ';text-wrap:pretty', o.desc)), Btn('', 'ghost', { ic: 'x', sm: true, al: 'Close' })),
D('padding:16px 24px 0;display:flex;flex-direction:column;gap:14px;overflow-y:auto;min-height:0', ...[].concat(body)),
D('padding:20px 24px;flex-shrink:0;flex-wrap:wrap;display:flex;gap:8px;' + (m ? 'flex-direction:column-reverse' : 'justify-content:flex-end'), ...[].concat(foot))))
const Toast = (tone, ic, t, d) => D('position:absolute;z-index:80;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:12px;display:flex;gap:10px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--ui-' + tone + ');' + TN[tone].split(';')[0], Ic(ic)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + HL, t), d && S(TO, d)))
const Sk = n => D('display:flex;flex-direction:column;gap:10px', ...Array.from({ length: n }, (_, i) => D('height:' + (m ? 56 : 44) + 'px;background:var(--ui-bg-elevated);opacity:' + (1 - i * .12))))
const Page = (title, sub, act, ...c) => D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:16px;padding:' + (m ? '16px 16px 88px' : '24px') + ';position:relative', D('display:flex;gap:12px;align-items:flex-start;' + (m ? 'flex-direction:column' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, title), sub && S(TO + ';text-wrap:pretty', sub)), act), ...c)
const Card = (...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', ...c)
const ROW = 'display:flex;align-items:center;gap:12px;border-top:1px solid var(--ui-border);padding:10px 16px;min-height:' + (m ? 56 : 48) + 'px'
const Av = i => S('width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', i)
let main = null, shell = { kind: 'slot', display: 'Marta Alves', role: s.role }, ov = []
// ---- data
const FX = window.LOCFX, S1 = FX.STORIES[0], S5 = FX.STORIES[4]
const LV = (k, n, c, st) => ({ n, c, lang: k, dir: FX.DIR[k] || 'ltr', t: k === 'en' ? (st || S1).title : (st || S1).tr[k], p: FX.P[k] })
const EN = LV('en', 'English', 'EN'), FR = LV('fr', 'French', 'FR'), AR = LV('ar', 'Arabic', 'AR'), DE = LV('de', 'German', 'DE')
const CAPS = { full: { e: 1, p: 1, d: 1, c: 1, rv: 1 }, ro: {}, p2: { e: 1, d: 1, c: 1, rv: 1 }, p3: { p: 1 }, p6: { e: 1, p: 1, d: 1, c: 1, rv: 1, part: 1 } }
// ---- language menu (menu button + menu; rows are single menu items, no nested controls)
const LangBtn = (cur, o = {}) => A('span', { role: 'button', 'aria-haspopup': 'menu', 'aria-expanded': !!o.open, 'aria-label': 'Language: ' + cur + '. Switch version' }, 'display:inline-flex;align-items:center;gap:8px;height:' + (m ? 44 : 32) + 'px;padding:0 10px;border:1px solid ' + (o.open ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;flex-shrink:0;max-width:100%;width:max-content', Ic('languages'), S('min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', cur), o.code && S(mono + ';flex-shrink:0;' + MU, o.code), Ic('chevrons-up-down', 14))
const Tag = (l, ic) => S('display:inline-flex;align-items:center;gap:4px;font-size:12px;' + MU, ic && Ic(ic, 12), l)
const LRow = r => { const cur = r.cur, add = r.add
return A('div', { role: 'menuitem', 'aria-current': cur || undefined, tabIndex: -1 }, 'display:flex;align-items:center;gap:10px;min-height:' + (m ? 52 : 40) + 'px;padding:6px 12px;' + (cur ? 'background:var(--rc-sand);color:var(--rc-on-sand)' : add ? 'color:var(--ui-text-toned)' : ''), S('width:16px', cur && Ic('check')), D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (cur ? 600 : 500), r.n), S(mono + (cur ? '' : ';' + MU), r.c)),
add ? S('display:inline-flex;align-items:center;gap:4px;font-weight:500', Ic('plus', 14), 'Add translation') : D('display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end', r.tag && Tag(r.tag, r.tagIc), Pub(r.st), r.fr && Fr('Needs review'), r.dis && Bd('Disabled', 'neutral', 'eye-off'))) }
const LangPop = (rows, o = {}) => D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;max-height:70%;border-top:1px solid var(--ui-border)' : 'left:' + (o.left ?? 292) + 'px;top:' + (o.top ?? 150) + 'px;width:400px;max-height:' + (o.max ?? 420) + 'px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column',
m && D('display:flex;justify-content:center;padding:8px 0 0;flex-shrink:0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
D('padding:10px 12px 6px;display:flex;flex-direction:column;gap:6px;flex-shrink:0', S('font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Versions you can open'), o.find && Fd('', 'Find a language', { val: false })),
A('div', { role: 'menu', 'aria-label': 'Versions of this story' }, 'display:flex;flex-direction:column;overflow-y:auto;min-height:0', ...rows.map(LRow)),
D('padding:10px 12px;border-top:1px solid var(--ui-border);display:flex;gap:8px;flex-shrink:0;' + MU + ';font-size:12px;line-height:18px', Ic('info', 14), 'Switching opens that version. The interface language stays the same.'))
const MENUS = {
all: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1 }],
two: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }],
many: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'English (United Kingdom)', c: 'EN-GB', st: 'draft' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Italian', c: 'IT', st: 'draft' }, { n: 'Japanese', c: 'JA', st: 'pub' }, { n: 'Portuguese (Brazil)', c: 'PT-BR', add: 1 }, { n: 'Spanish', c: 'ES', add: 1 }, { n: 'Chinese (Simplified)', c: 'ZH-HANS', add: 1 }, { n: 'Arabic', c: 'AR', add: 1 }, { n: 'Dutch', c: 'NL', add: 1 }, { n: 'Polish', c: 'PL', add: 1 }],
ro: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
p4: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1, tag: 'Read only', tagIc: 'lock' }],
p5: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }]
}
// ---- editor pieces
const ToolRow = ro => D('display:flex;gap:2px;padding:6px;border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden;flex-shrink:0;' + TO, A('div', { role: 'toolbar', 'aria-label': 'Formatting' }, 'display:flex;gap:2px', ...['bold', 'italic', 'underline', 'heading-2', 'list', 'list-ordered', 'quote', 'link', 'image'].map(n => A('span', { role: 'button', 'aria-label': n }, 'width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;flex-shrink:0', Ic(n)))))
const Body = (L, o = {}) => A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-multiline': 'true', 'aria-readonly': o.ro || undefined, 'aria-label': (o.label || 'Body') + ' (' + L.n + ')', tabIndex: 0 }, 'position:relative;display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--ui-border);background:' + (o.ro ? 'var(--ui-bg-muted)' : 'var(--ui-bg)') + ';' + ((m && !o.pane) ? 'flex:none;' : 'flex:1;min-height:0;overflow:auto;') + 'font:400 ' + (L.dir === 'rtl' ? '17px/30px' : '15px/26px') + ' \'Source Serif 4\',serif;color:var(--ui-text)', ...(o.long ? L.p.concat(L.p, L.p) : L.p).map(t => S('text-wrap:pretty', t)))
const TitleField = (L, o) => {
const cap = o.cap, v = o.titleVal ?? L.t
if (!cap.e) return D('display:flex;flex-direction:column;gap:2px', S('font-size:12px;' + MU, 'Title (' + L.n + ')'), A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-readonly': 'true', 'aria-label': 'Title (' + L.n + ')', tabIndex: 0 }, 'font:600 18px/26px \'IBM Plex Sans\',sans-serif;' + HL, v))
const st = o.titleState, err = st === 'invalid' || st === 'failed'
return D('display:flex;flex-direction:column;gap:4px', S('font-size:12px;' + MU, 'Title (' + L.n + ')'),
D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 36) + 'px;padding:2px 10px;border:1px solid ' + (err ? 'var(--ui-error)' : st === 'focus' || st === 'dirty' ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);' + (st === 'focus' ? 'outline:2px solid var(--ui-primary);outline-offset:1px' : ''),
A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-label': 'Title (' + L.n + ')', 'aria-invalid': err || undefined, tabIndex: 0 }, 'flex:1;min-width:0;font:600 18px/26px \'IBM Plex Sans\',sans-serif;' + (st === 'invalid' ? MU : HL) + ';text-wrap:pretty', st === 'invalid' ? 'Title' : v), st === 'saving' ? Ic('loader-circle', 14) : Ic('pencil', 14)),
st === 'invalid' ? S('display:flex;gap:6px;color:var(--ui-error);font-size:12px', Ic('circle-x', 14), 'Enter a title.') : st === 'failed' ? D('display:flex;gap:8px;align-items:center;flex-wrap:wrap;color:var(--ui-error);font-size:12px', Ic('circle-x', 14), 'Couldn\'t save the title. Your text is kept.', Btn('Try again', 'secondary', { sm: true })) : st === 'saving' ? S(MU + ';font-size:12px', 'Saving title…') : st === 'dirty' || st === 'focus' ? S(MU + ';font-size:12px', 'Changing the title doesn\'t change the address. Edit the address in Search and sharing.') : null)
}
const PubAct = (L, o) => ({ l: o.pub === 'pub' ? 'Unpublish ' + L.n : (o.dirty ? 'Save and publish ' : 'Publish ') + L.n, dis: !!o.saving || !!o.saveBlock })
const Life = (L, o) => (o.pub === 'pub' ? 'This version is online. Saving updates the live page right away.' : 'Saving keeps this version as a draft. It isn\'t public until you publish it.') + (o.cap.p && o.pub !== 'pub' ? ' Save and publish saves first, then publishes.' : '')
const SaveLbl = o => o.saving ? 'Saving…' : o.saveFail ? 'Try saving again' : 'Save'
const SaveDis = o => (!o.dirty && !o.saving && !o.saveFail) || !!o.saveBlock
const Edit = (L, o) => {
const cap = o.cap, sr = !!o.src, pa = PubAct(L, o)
const state = o.saveFail ? Bd('Save failed', 'error', 'circle-x') : o.dirty ? Bd('Unsaved changes', 'neutral', 'circle-dot') : null
const btns = []
btns.push(Btn(sr ? 'Hide source reference' : 'Show source reference', 'secondary', { ic: 'columns-2' }))
if (cap.e) btns.push(Btn(SaveLbl(o), 'secondary', { busy: o.saving, dis: SaveDis(o) }))
if (cap.p) btns.push(Btn(pa.l, 'primary', { dis: pa.dis || o.pubDis }))
if (cap.d || cap.c) btns.push(Btn('', 'ghost', { ic: 'ellipsis', al: 'More actions' }))
return D('display:flex;flex-direction:column;gap:10px;flex-shrink:0', D('display:flex;align-items:center;gap:8px;' + MU, Ic('arrow-left', 14), S('font-size:12px', 'Articles')),
TitleField(L, o),
D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', LangBtn(L.n, { code: L.c, open: o.open }), Pub(o.pub || 'draft'), o.fresh && Fr(o.fresh), state, !cap.e && Bd('Read only', 'neutral', 'lock'), ...(m ? [] : [D('flex:1'), ...btns])),
m && D('display:flex;gap:8px', ...btns.slice(0, 1)),
cap.e && o.saveFail && !o.titleState && Alert('error', 'circle-x', 'Couldn\'t save ' + L.n, 'Your changes are kept here. Nothing was published. Try saving again.'),
cap.e && o.dirty && !o.saveFail && !o.titleState && S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', Life(L, o)),
!cap.e && Alert('neutral', 'lock', cap.p ? 'You can publish this version but not edit it' : 'You can read this version', cap.p ? 'Fields are read-only. Publish and Unpublish stay available. The source reference is still available.' : 'You can\'t edit it. The source reference and versions you can read stay available.'),
cap.e && !cap.p && Alert('neutral', 'info', 'You can edit this version but not publish it', (o.pub === 'pub' ? 'This version is online, so saving updates the live page right away.' : 'Saving keeps it as a draft.') + ' Publishing and unpublishing need the publish permission.'))
}
const MBar = (L, o) => { if (!m) return null; const cap = o.cap, pa = PubAct(L, o), bs = []; if (cap.e) bs.push(Btn(SaveLbl(o), 'secondary', { busy: o.saving, dis: SaveDis(o) })); if (cap.p) bs.push(D('flex:1 1 170px;display:flex', Btn(pa.l, 'primary', { w: '100%', dis: pa.dis }))); if (cap.d || cap.c) bs.push(Btn('', 'ghost', { ic: 'ellipsis', al: 'More actions' })); return bs.length ? D('position:absolute;left:0;right:0;bottom:0;z-index:30;background:var(--ui-bg);border-top:1px solid var(--ui-border);padding:12px 16px;display:flex;gap:8px;flex-wrap:wrap', ...bs) : null }
const Seg = (on, L, S0) => A('div', { role: 'radiogroup', 'aria-label': 'Show' }, 'display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0', ...['Source (' + S0.n + ')', 'Edit (' + L.n + ')'].map((t, i) => A('span', { role: 'radio', 'aria-checked': (on ? i === 0 : i === 1) }, 'flex:1;height:44px;display:flex;align-items:center;justify-content:center;font-weight:500;background:' + ((on ? i === 0 : i === 1) ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:' + ((on ? i === 0 : i === 1) ? 'var(--ui-bg)' : 'var(--ui-text-toned)'), t)))
const Src = (S0, o = {}) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;gap:10px',
D('display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);flex-wrap:wrap', Ic('book-open', 14), S('font-weight:600;' + HL, 'Reference: ' + S0.n), S(mono + ';' + MU, S0.c), Pub('pub'), S('margin-left:auto;display:inline-flex;gap:4px;' + MU + ';font-size:12px', Ic('lock', 14), 'Read only'), Btn('Change reference', 'secondary', { sm: true })),
S(MU + ';font-size:12px;line-height:18px', 'Reference only. The tracked source for review is English, revision 8. Changing the reference doesn\'t change it.'),
o.gone ? Alert('warning', 'triangle-alert', 'This reference isn\'t available', 'It was deleted or you can no longer read it. Your French version isn\'t affected.', Btn('Choose another reference', 'secondary', { sm: true })) : [
o.chg && Alert('neutral', 'info', 'Changed fields since French was reviewed (revision 7 → 8)', 'Title, Body (2 paragraphs). Fields are listed, not compared line by line.'),
A('div', { dir: S0.dir, lang: S0.lang, role: 'heading', 'aria-level': '2', tabIndex: 0 }, 'font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, S0.t),
Body(S0, { ro: true, label: 'Reference', long: o.long, pane: true })])
const EdPage = (L, o, extra) => D('flex:1;min-width:0;min-height:0;overflow:' + (m ? 'auto' : 'hidden') + ';display:flex;flex-direction:column;gap:12px;padding:' + (m ? '16px 16px 128px' : '24px') + ';position:relative', Edit(L, o), ...[].concat(extra || []), (o.cap.e && !(m && o.srcMob)) ? ToolRow() : null, (m && o.srcMob) ? null : Body(L, { ro: !o.cap.e, long: o.long }), (!m && o.seoNote) ? Alert('neutral', 'info', 'Search and sharing: description is empty', 'No fallback: the English site default isn\'t used for French. Add a French description or the tag is left out.') : null)
const Editor = (L, o, extra) => {
const S0 = o.S0 || EN
if (!o.src) return D('flex:1;min-height:0;display:flex;position:relative', EdPage(L, o, extra), MBar(L, o))
if (m) { const on = !!o.srcMob; return D('flex:1;min-height:0;position:relative;display:flex;flex-direction:column', D('flex:1;min-height:0;display:flex;flex-direction:column;gap:12px;padding:16px 16px 128px', Seg(on, L, S0), on ? Src(S0, o) : D('flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:12px', Edit(L, { ...o, src: 1 }), o.cap.e && ToolRow(), Body(L, { ro: !o.cap.e, long: o.long }))), MBar(L, o)) }
return D('flex:1;min-height:0;display:flex', D('width:' + (o.narrow ? '42%' : '50%') + ';flex-shrink:0;min-width:0;border-right:1px solid var(--ui-border);overflow:hidden;padding:24px;display:flex;flex-direction:column;background:var(--ui-bg-muted)', Src(S0, o)), EdPage(L, o, extra))
}
// ---- site shells
const setS = { nav: 'settings', crumbs: ['Settings', 'Sites', 'Journal', 'Languages'], mTitle: 'Journal · Languages', mLead: 'arrow-left' }
const edS = { nav: 'articles', crumbs: ['Articles', 'Editor'], mTitle: 'Editor', mLead: 'arrow-left' }
const liS = { nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles' }
// ---- site languages
const LNG = FX.LANGS.map(l => { const c = FX.count(l.code, FX.STORIES); return [l.name, l.code, l.def ? 'Default' : l.enabled ? '' : 'Disabled', c[0], c[1], l.social] })
const LRows = ro => LNG.map(r => D(ROW + ';opacity:' + (r[2] === 'Disabled' ? .8 : 1), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + HL + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r[0]), S(mono + ';' + MU, r[1] + ' · social locale ' + (r[5] === 'Not set' ? 'not set' : r[5]))), r[2] === 'Default' ? Bd('Default', 'info', 'star') : r[2] === 'Disabled' ? Bd('Disabled', 'neutral', 'eye-off') : null, !m && S(MU + ';width:190px', r[3] + (r[3] === 1 ? ' version · ' : ' versions · ') + r[4] + ' published'), !ro && r[2] === 'Disabled' ? Btn('Re-enable', 'secondary', { sm: true }) : null, !ro && Btn('', 'ghost', { ic: 'ellipsis', sm: true, al: 'Actions for ' + r[0] })))
const slPage = () => {
const ro = x === 'readonly', single = x === 'single', site = single ? 'Archive' : 'Journal', T = 'Languages', SUB = 'Content languages for ' + site + '. Each article version is written in one language.'
if (x === 'loading') return Page(T, SUB, null, Sk(4))
if (x === 'error') return Page(T, SUB, null, Alert('error', 'circle-x', 'Couldn\'t load languages', 'Check your connection and try again.', Btn('Try again', 'secondary', { sm: true })))
return Page(T, SUB, !ro && Btn('Add language', 'primary', { ic: 'plus' }),
ro && Alert('neutral', 'lock', 'Only owners can change languages', 'You can see which languages are enabled and how many versions each has. Ask an owner to add, disable or change a language.'),
x === 'disableDefault' && Alert('warning', 'triangle-alert', 'English is the default', 'Choose another default first, then disable English.'),
x === 'disableLast' && Alert('warning', 'triangle-alert', 'At least one language must stay enabled', 'Journal needs a language for new articles.'),
single ? Card(S('font-weight:600;' + HL, 'Archive uses one language'), S(TO, 'New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.'), D('display:flex;gap:10px;align-items:center', Bd('Default', 'info', 'star'), S('font-weight:500;' + HL, 'Portuguese (Brazil)'), S(mono + ';' + MU, 'pt-BR'))) :
D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', 'Language'), ...LRows(ro)),
single && Alert('info', 'info', 'Regional language kept: pt-BR', 'Setup kept the full site locale, so the default content language is Portuguese (Brazil), not plain Portuguese. The site locale still formats dates and numbers. Social metadata locale: pt_BR, taken from the language tag.'),
!single && !ro && S(MU + ';font-size:12px;text-wrap:pretty', 'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it.'))
}
const slOv = () => {
const addBody = (err) => [err && Alert('error', 'circle-x', 'Couldn\'t save languages', 'Nothing changed. Your selection is kept. Try again.'), Fd('Language', 'Portuguese (Brazil)', { sel: true, focus: !err }), Fd('Language code', 'pt-BR', { mono: true, ro: true, lock: true, help: 'Used in the API and in page language metadata.' }), Fd('Social metadata locale', 'pt_BR', { mono: true, ro: true, lock: true, help: 'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.' })]
if (x === 'add') return Dlg({ title: 'Add a language', desc: 'Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries.' }, addBody(), [Btn('Cancel'), Btn('Add language', 'primary')])
if (x === 'saveFail') return Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, addBody(true), [Btn('Cancel'), Btn('Try again', 'primary')])
if (x === 'adddup') return Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, [Fd('Language', 'French', { sel: true, err: 'French is already enabled for Journal.' })], [Btn('Cancel'), Btn('Add language', 'primary', { dis: true })])
if (x === 'default') return Dlg({ title: 'Make French the default language?', desc: 'New articles start in the default language, and it is the first choice for each story\'s title in the list.' }, [Alert('info', 'info', 'No content changes', 'Existing versions keep their language, status and links. Public requests that name no language will resolve French where a published French version exists, and return 404 where one doesn\'t. Requests that name a language aren\'t affected.')], [Btn('Cancel'), Btn('Make French default', 'primary')])
if (x === 'disable') return Dlg({ title: 'Disable German?', desc: 'German is used by 4 versions: 1 draft and 3 published.' }, [Alert('warning', 'triangle-alert', 'Nothing is deleted or unpublished', 'The 3 published German versions stay online. All 4 versions stay readable and editable. The draft can\'t be published until you re-enable German. Nobody can create new German versions.')], [Btn('Cancel'), Btn('Disable German', 'primary')])
if (x === 'reenable') return Dlg({ title: 'Re-enable Spanish?', desc: 'Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes.' }, [], [Btn('Cancel'), Btn('Re-enable Spanish', 'primary')])
if (x === 'remove') return Dlg({ title: 'Remove Italian?', desc: 'No article uses Italian. You can add it again later.' }, [], [Btn('Cancel'), Btn('Remove Italian', 'danger')])
if (x === 'removeBlocked') return Dlg({ title: 'Spanish can\'t be removed', desc: 'Spanish is used by 1 version. Disable the language to keep it, or delete it first.' }, [], [Btn('Close'), Btn('Disable Spanish', 'primary')])
if (x === 'saved') return [Toast('success', 'circle-check', 'French added', 'Writers can now create French versions.')]
return null
}
// ---- setup
const SUV = FX.SETUP, SUN = SUV.reduce((a, r) => a + r.n, 0)
const suVal = (r, chosen) => r.name ? r.name + ' · ' + r.tag : chosen ? 'English · en' : 'Choose a language'
const suBody = () => {
if (x === 'empty') return Page('Languages', 'Content languages for Gallery.', null, Card(S('font-weight:600;' + HL, 'Confirm the default language'), S(TO, 'Gallery has no articles yet. New articles will be written in this language.'), Fd('Default content language', 'English (United Kingdom) · en-GB', { sel: true, help: 'Seeded from site locale en-GB. The full locale is kept. Confirm or change it.' }), D('display:flex', Btn('Save', 'primary'))))
const done = x === 'done', busy = x === 'applying', fail = x === 'fail', chosen = x !== 'ready'
return Page('Set article languages', 'Existing articles don\'t have a language yet. Confirm or change the language for each site.', null,
done ? Alert('success', 'circle-check', 'Languages assigned', SUN + ' articles now have a language. Nothing was translated, and no articles were grouped.', Btn('Open Articles', 'secondary', { sm: true })) :
fail ? Alert('error', 'circle-x', 'Nothing was changed', 'The update failed, so no article has a language yet. Your choices are kept. Try again; running it twice is safe.', Btn('Try again', 'secondary', { sm: true })) :
busy ? Alert('info', 'loader-circle', 'Assigning languages', 'Articles created while this runs get the site\'s default language.') :
Alert('info', 'info', 'This labels articles. It doesn\'t translate them.', 'Each existing article becomes the only version of its own story. Each site\'s language is seeded from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.'),
D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...SUV.map(r => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1;min-width:140px;display:flex;flex-direction:column', S('font-weight:600;' + HL, r.site), S(MU + ';font-size:12px', r.n + ' articles · ' + r.note)), D('width:' + (m ? '100%' : '300px'), Fd('', suVal(r, chosen), { sel: !done, val: !!(r.name || chosen), help: done ? null : r.name ? 'Seeded from site locale ' + r.locale + '. Confirm or change.' : chosen ? null : 'Required before you can apply.' }))))),
!done && D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Btn(busy ? 'Assigning…' : 'Confirm and assign to ' + SUN + ' articles', 'primary', { busy, dis: busy || x === 'ready' }), S(MU + ';font-size:12px', 'Counts are articles in ' + SUV.length + ' sites.')))
}
// ---- list
const FXS = FX.STORIES, LN = { en: 'English', fr: 'French', de: 'German', ar: 'Arabic', es: 'Spanish' }
const upMap = v => Object.fromEntries(Object.entries(v).map(([k, q]) => [k.toUpperCase(), q]))
const Cov = (map, o = {}) => D('display:flex;gap:6px;flex-wrap:wrap', ...(o.langs || FX.ENABLED.map(k => k.toUpperCase()).concat(map.ES ? ['ES'] : [])).filter(k => !o.hideMissing || map[k]).map(k => { const q = map[k], st = q && q[0], nr = q && q[1]; return A('span', { 'aria-label': k + ': ' + (st === 'pub' ? 'published' : st === 'draft' ? 'draft' : 'no version') + (nr ? ', needs review' : '') + (k === 'ES' ? ', language disabled' : '') }, 'display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 6px;font:500 12px \'IBM Plex Mono\',monospace;' + (st ? 'border:1px solid var(--ui-border-accented);' + HL : 'border:1px dashed var(--ui-border-accented);' + MU), k, st === 'pub' ? Ic('circle-check', 12) : st === 'draft' ? Ic('pencil', 12) : Ic('minus', 12), nr && Ic('refresh-cw', 12)) }))
const Pager = (total, unitName, pages, size = 5) => D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;' + (m ? 'justify-content:space-between' : ''), S(MU + ';font-size:12px;' + (m ? 'flex:1 1 100%' : 'flex:1'), 'Showing 1–' + Math.min(size, total) + ' of ' + total + ' ' + unitName),
D('display:flex;align-items:center;gap:8px;' + MU + ';font-size:12px', 'Rows', D('', Fd('', String(size), { sel: true }))),
D('display:flex;align-items:center;gap:6px', Btn('', 'secondary', { ic: 'chevron-left', al: 'Previous page', dis: true, sm: !m }), S(HL + ';font-weight:500;padding:0 6px', m ? 'Page 1 of ' + pages : '1 of ' + pages), Btn('', 'secondary', { ic: 'chevron-right', al: 'Next page', dis: pages < 2, sm: !m })))
const Chip = l => D('display:inline-flex;align-items:center;gap:6px;height:' + (m ? 36 : 24) + 'px;padding:0 8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);' + HL + ';font-size:12px', l, A('span', { role: 'button', 'aria-label': 'Remove filter ' + l }, 'display:inline-flex', Ic('x', 14)))
const AT = [{ t: 'A complete guide to renovating a ground-floor kitchen and bathroom in a listed building, with every cost itemised for owners and tenants', v: { EN: ['pub', 0], 'EN-GB': ['draft', 0], FR: ['draft', 1], DE: ['pub', 0], 'PT-BR': ['draft', 0], 'ZH-HANS': ['pub', 0], JA: ['pub', 0], AR: ['draft', 0] }, by: 'Marta Alves', upd: '2 h ago' }, { t: 'Heat pumps for stone farmhouses', v: { EN: ['pub', 0], 'EN-GB': ['pub', 0], FR: ['draft', 0], DE: ['pub', 0], JA: ['draft', 0] }, by: 'Luis Ferrer', upd: 'Yesterday' }, { t: 'Sourcing reclaimed oak beams', v: { EN: ['draft', 0], 'EN-GB': ['draft', 0] }, by: 'Camille Roche', upd: '14 Oct' }]
const liPage = () => {
const press = x === 'emptyMissing', atel = x === 'long', lim = x === 'limited', fail = x === 'listFail', none = x === 'noResults', load = x === 'frLoading', draftF = x === 'filters'
const missView = x === 'frMissing' || x === 'tabsScroll' || press, frView = x === 'fr' || load || draftF || missView, active = draftF || atel
const DS = press ? FX.PRESS : FXS, isD = q => q && q[0] === 'draft'
const frV = DS.filter(s => s.v.fr && (!draftF || isD(s.v.fr))), frM = DS.filter(s => !s.v.fr && (!draftF || Object.values(s.v).some(isD)))
let tabs
if (frView) tabs = [['All', frV.length], ['Published', frV.filter(s => s.v.fr[0] === 'pub').length], ['Drafts', frV.filter(s => isD(s.v.fr)).length], ['Needs review', frV.filter(s => s.v.fr[1]).length], ['Not in French', frM.length]]
else if (atel) tabs = [['All stories', 3], ['Needs translation', 3], ['Needs review', 1]]
else if (none) tabs = [['All stories', 0], ['Needs translation', 0], ['Needs review', 0]]
else if (lim) tabs = [['All stories', 3], ['Needs translation', 3], ['Needs review', 0]]
else tabs = [['All stories', DS.length], ['Needs translation', DS.filter(s => FX.ENABLED.some(k => !s.v[k])).length], ['Needs review', DS.filter(s => Object.values(s.v).some(q => q[1])).length]]
if (load || fail) tabs = tabs.map(q => [q[0], '–'])
const tabOn = missView ? 4 : 0, site = press ? 'Press' : atel ? 'Atelier' : 'Journal'
const filt = () => D('display:flex;gap:8px;flex-wrap:wrap;align-items:center', D('flex:1;min-width:' + (m ? '0' : '220px'), Fd('', none ? 'zinc' : frView ? 'Search French titles' : 'Search titles in every language', { val: none })),
m ? Btn('Filters' + (active ? ' · 3' : ''), 'secondary', { ic: 'sliders-horizontal', al: active ? 'Filters, 3 active' : 'Filters' }) : [Fd('', site, { sel: true }), Fd('', frView ? 'French' : 'All languages', { sel: true }), Fd('', active ? 'Draft' : 'Any status', { sel: true }), Fd('', 'Any author', { sel: true }), Fd('', atel ? 'SEO: Needs attention' : 'Any SEO state', { sel: true })])
const chips = active && D('display:flex;gap:6px;flex-wrap:wrap', ...(draftF ? [Chip('Site: Journal'), Chip('Language: French'), Chip('Status: Draft')] : [Chip('Site: Atelier'), Chip('Status: Draft'), Chip('SEO: Needs attention')]), Btn('Clear all', 'ghost', { sm: true }))
const shift = x === 'tabsScroll' ? -96 : 0
const tabRow = D('position:relative;border-bottom:1px solid var(--ui-border);display:flex;align-items:center;gap:4px', m && Btn('', 'ghost', { ic: 'chevron-left', sm: true, al: 'Scroll tabs left', dis: !shift }),
A('div', { role: 'tablist', 'aria-label': 'Article views' }, 'flex:1;min-width:0;overflow:hidden;display:flex;gap:' + (m ? 16 : 24) + 'px;position:relative', D('display:flex;gap:' + (m ? 16 : 24) + 'px;flex-shrink:0;transform:translateX(' + (m ? shift : 0) + 'px)', ...tabs.map((q, i) => A('div', { role: 'tab', 'aria-selected': i === tabOn }, 'padding:' + (m ? '12px 0' : '8px 0') + ';white-space:nowrap;font-weight:' + (i === tabOn ? 600 : 500) + ';color:' + (i === tabOn ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';border-bottom:2px solid ' + (i === tabOn ? 'var(--ui-primary)' : 'transparent'), q[0] + ' ', S(MU + ';font-weight:400', q[1])))), m && D('position:absolute;right:0;top:0;bottom:0;width:28px;background:linear-gradient(90deg,transparent,var(--ui-bg))'), m && shift && D('position:absolute;left:0;top:0;bottom:0;width:28px;background:linear-gradient(270deg,transparent,var(--ui-bg))')), m && Btn('', 'ghost', { ic: 'chevron-right', sm: true, al: 'Scroll tabs right' }))
const Row = (title, sub, right, lg) => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', A('span', { lang: lg }, HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', title), sub && S(MU + ';font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', sub)), right)
const Story = (title, sub, map, o, by, upd) => D(ROW + ';' + (m ? 'flex-direction:column;align-items:stretch;gap:6px' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S(HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', title), sub && S(MU + ';font-size:12px', sub)), D((m ? '' : 'width:230px;') + 'flex-shrink:0', Cov(map, o)), m ? S(MU + ';font-size:12px', by + ' · ' + upd) : S(MU + ';width:120px', by), !m && S(MU + ';width:90px', upd))
const card = 'background:var(--ui-bg);border:1px solid var(--ui-border)', head = !m && D('display:flex;gap:12px;padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', S('flex:1', 'Story'), S('width:230px', 'Languages'), S('width:120px', 'Author'), S('width:90px', 'Updated'))
let body, total = 0, unit = '', pages = 1
if (fail) body = Alert('error', 'circle-x', 'Couldn\'t load articles', 'Your filters and page are kept. Try again.', Btn('Try again', 'secondary', { sm: true }))
else if (load) body = Sk(5)
else if (press) body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('circle-check', 28), S('font-weight:600;' + HL, 'Every story you can see has a French version'), S(TO + ';max-width:360px', 'Stories without a French version will appear here.'))
else if (none) body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('search', 28), S('font-weight:600;' + HL, 'No stories match "zinc"'), S(TO, 'Search covers titles in every language you can read.'), Btn('Clear search', 'secondary', { sm: true }))
else if (missView) { const rep = s => ['en', 'fr', 'de', 'ar', 'es'].find(k => s.v[k]); body = D(card, ...frM.map(s => { const k = rep(s), q = s.v[k]; return Row(s.title, 'Opens ' + LN[k] + ' · ' + (q[0] === 'pub' ? 'published' : 'draft') + (k === 'es' ? ' · language disabled' : ''), Btn(m ? '' : 'Add French translation', 'secondary', { sm: true, ic: 'plus', al: 'Add French translation to ' + s.title })) })); total = frM.length; unit = 'stories with no French version you can read' }
else if (frView) { body = D(card, ...frV.map(s => Row(s.tr.fr, m ? s.by + ' · ' + s.upd : null, D('display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end', Pub(s.v.fr[0]), s.v.fr[1] ? Fr('Needs review') : null, !m && S(MU + ';width:120px;margin-left:12px', s.by), !m && S(MU + ';width:80px', s.upd)), 'fr'))); total = frV.length; unit = 'French versions' }
else if (atel) { body = D(card, head, ...AT.map(r => Story(r.t, null, r.v, { langs: ['EN', 'EN-GB', 'FR', 'DE', 'PT-BR', 'ZH-HANS', 'JA', 'AR'] }, r.by, r.upd))); total = 3; unit = 'stories' }
else { const rows = lim ? [FXS[1], FXS[2], FXS[3]] : FXS.slice(0, 5); body = D(card, head, ...rows.map(s => Story(s.title, s.v.es && !s.v.en ? 'Spanish only · language disabled on Journal' : null, lim ? { EN: s.v.en } : upMap(s.v), { hideMissing: lim }, s.by, s.upd))); total = lim ? 3 : FXS.length; unit = lim ? 'stories you can read' : 'stories'; pages = lim ? 1 : Math.ceil(total / 5) }
return D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:' + (m ? 12 : 16) + 'px;padding:' + (m ? '16px 16px 16px' : '24px') + ';position:relative', D('display:flex;align-items:center;gap:12px', S('flex:1;font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, 'Articles'), Btn(m ? '' : 'New article', 'primary', { ic: 'plus', al: 'New article' })), lim && Alert('neutral', 'lock', 'Showing only what you can read', 'Languages, counts and missing-translation results reflect versions you have permission to see.'), filt(), chips, tabRow, body, total ? Pager(total, unit, pages) : null)
}
const sheet = () => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'center') + ';justify-content:center', A('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Filters' }, 'width:100%;max-width:' + (m ? '100%' : '420px') + ';max-height:92%;background:var(--ui-bg);display:flex;flex-direction:column', D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
D('padding:16px 16px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, 'Filters'), Btn('', 'ghost', { ic: 'x', al: 'Close filters' })),
D('padding:16px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;min-height:0', Fd('Site', 'Journal', { sel: true }), Fd('Language', 'French', { sel: true }), Fd('Status', 'Draft', { sel: true }), Fd('Author', 'Any author', { sel: true }), Fd('SEO', 'Any SEO state', { sel: true })),
D('padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', Btn('Clear all', 'secondary'), D('flex:1;display:flex', Btn('Show 2 results', 'primary', { w: '100%' })))))
// ---- new article
const naOv = () => {
const multi = x !== 'single'
return Dlg({ title: 'New article', desc: x === 'translation' ? 'Create a translation of a story you can read.' : 'Give the article a title. You can write the rest in the editor.' }, [
x === 'translation' ? [Fd('Story', 'Roof inspection checklist', { sel: true, help: 'No French version yet. Copies from English · published' }), Fd('Language', 'French', { sel: true }), Fd('Title', 'Liste de contrôle pour l\'inspection de la toiture', { focus: true, lang: 'fr' })] :
[Fd('Title', x === 'sitechange' ? 'Spring newsletter' : 'Title', { val: x === 'sitechange', focus: x !== 'sitechange', help: '0 / 200' }), Fd('Site', x === 'sitechange' ? 'Notes' : 'Journal', { sel: true }),
multi && Fd('Language', 'English', { sel: true, help: x === 'sitechange' ? 'Notes only offers English, so the language was reset from French.' : 'Default for Journal.' }),
multi && D('display:flex;flex-direction:column;gap:8px', Radio('New story', 'Starts a story in this language.', true), Radio('Translation of an existing story', 'Uses the same rules as Add translation.', false))]
], [Btn('Cancel'), Btn(x === 'translation' ? 'Create French draft' : 'Create article', 'primary')])
}
// ---- add translation
const FRT = S1.tr.fr, FRT5 = 'Liste de contrôle pour l\'inspection de la toiture'
const LS = st => LV('en', 'English', 'EN', st)
const atOv = () => {
const empty = x === 'empty', long = x === 'long', dupCase = x === 'dup' || x === 'dupHidden', st = dupCase ? S1 : S5, ft = dupCase ? FRT : FRT5, rev = st.v.en[2]
const f = [Fd('Language', 'French', { sel: true }), Fd('Source version', 'English · published · revision ' + rev, { sel: true, help: 'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.' }), Fd('Title in French', long ? ft + ' avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes' : ft, { focus: x === 'form', lang: 'fr', help: 'Copying text is a starting point: it stays in English until you translate it.' }),
D('display:flex;flex-direction:column;gap:8px', S('font-weight:500;' + HL, 'Starting content'), Radio('Start empty', 'Only the title. Freshness shows No source until you pick a tracked source.', empty), Radio('Copy from English', 'Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren\'t duplicated. The version starts as Needs review.', !empty))]
if (long) f.push(Alert('neutral', 'info', 'Authors', 'Marta Alves, Camille Roche, Luis Ferrer, Ana Pereira, Jonas Weber and 4 more will be copied. You can change them in the editor.'), Alert('neutral', 'info', 'Images', 'Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it.'))
if (x === 'dup') return Dlg({ title: 'Add a translation' }, [Alert('error', 'circle-x', 'French already exists for this story', 'Open it to continue. Your entries are kept here.', Btn('Open French version', 'secondary', { sm: true })), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
if (x === 'dupHidden') return Dlg({ title: 'Add a translation' }, [Alert('error', 'circle-x', 'French can\'t be created for this story', 'Try a different language, or ask an owner. Your entries are kept.'), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
if (x === 'fail') return Dlg({ title: 'Add a translation' }, [Alert('error', 'circle-x', 'Couldn\'t create the translation', 'Your entries are kept. Try again.'), ...f], [Btn('Cancel'), Btn('Try again', 'primary')])
if (x === 'gone') return Dlg({ title: 'Add a translation' }, [Alert('warning', 'triangle-alert', 'The source isn\'t available', 'It was deleted or you can no longer read it. Choose another source you can read, or start empty.'), Fd('Source version', 'Choose a version', { sel: true, val: false }), Fd('Title in French', ft)], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
if (x === 'saving') return Dlg({ title: 'Add a translation' }, f, [Btn('Cancel', 'secondary', { dis: true }), Btn('Creating…', 'primary', { busy: true })])
return Dlg({ title: 'Add a translation', desc: 'Creates a French draft of "' + st.title + '". It isn\'t published.', top: long ? 24 : 60 }, f, [Btn('Cancel'), Btn('Create French draft', 'primary')])
}
// ---- publish / delete
const pdOv = () => ({
publish: () => Dlg({ title: 'Publish French?', desc: 'Only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Publish French', 'primary')]),
unpublish: () => Dlg({ title: 'Unpublish French?', desc: 'The French version goes back to draft and stops being public. English stays online.' }, [], [Btn('Cancel'), Btn('Unpublish French', 'primary')]),
delete: () => Dlg({ title: 'Delete the French version?', desc: '"' + FRT + '" will be deleted. Other versions stay.' }, [], [Btn('Cancel'), Btn('Delete French version', 'danger')]),
deleteSource: () => Dlg({ title: 'Delete the English version?', desc: 'English is the tracked source for versions you can read.' }, [Alert('warning', 'triangle-alert', 'Review tracking will pause', 'Versions that track English stay as they are. Their review history is kept, and they ask you to choose a new source.')], [Btn('Cancel'), Btn('Delete English version', 'danger')]),
deleteLast: () => Dlg({ title: 'Delete the only version?', desc: 'English is the only version of this story. Deleting it deletes the story.' }, [], [Btn('Cancel'), Btn('Delete article', 'danger')]),
saveAndPublish: () => Dlg({ title: 'Save and publish French?', desc: 'Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Save and publish French', 'primary')]),
saveFailPublish: () => [Toast('error', 'circle-x', 'Couldn\'t save French', 'Nothing was published. Your changes are kept. Try again.')],
deleteStory: () => Dlg({ title: 'You can\'t delete this story', desc: 'Deleting a story needs delete permission on every version. You can still delete the version you are editing.' }, [], [Btn('Close'), Btn('Delete French version', 'danger')]),
fail: () => [Toast('error', 'circle-x', 'Couldn\'t publish French', 'Nothing changed. Try again.')],
done: () => [Toast('success', 'circle-check', 'French published', 'Other versions weren\'t changed.')]
}[x]?.())
// ---- freshness
const frOv = () => ({
review: () => Dlg({ title: 'Mark French as reviewed?', desc: 'This records that French revision 5 matches English revision 8 (14 Oct 2026). It doesn\'t change publication.' }, [Alert('neutral', 'info', 'Changed fields', 'Title, Body. Authors and the cover image don\'t count.')], [Btn('Cancel'), Btn('Mark as reviewed', 'primary')]),
changed: () => Dlg({ title: 'English changed again', desc: 'English is now at revision 9. You were reviewing revision 8.' }, [Alert('warning', 'triangle-alert', 'Review not recorded', 'Check the latest changes first. Nothing was marked.')], [Btn('Cancel'), Btn('Show source reference', 'secondary'), Btn('Review revision 9', 'primary')]),
changedTr: () => Dlg({ title: 'French changed while you were reviewing', desc: 'Camille Roche saved French revision 6 after you opened this.' }, [Alert('warning', 'triangle-alert', 'Review not recorded', 'This changed since you opened it. Review again.')], [Btn('Cancel'), Btn('Reload French', 'primary')]),
replace: () => Dlg({ title: 'Choose a tracked source', desc: 'Pick a version of this story you can read. French will show Needs review until you review it against the new source.' }, [Radio('English', 'Published · revision 8', false), Radio('German', 'Published · revision 3', true), Alert('error', 'circle-x', 'German can\'t be the source', 'It tracks French as its source. A source can\'t track back to this version.')], [Btn('Cancel'), Btn('Use German as source', 'primary', { dis: true })]),
done: () => [Toast('success', 'circle-check', 'French marked as reviewed', 'Reviewed against English revision 8.')],
fail: () => [Toast('error', 'circle-x', 'Couldn\'t mark as reviewed', 'Nothing was recorded. Try again.')]
}[x]?.())
// ---- permissions / conflicts
const pmOv = () => ({
session: () => Dlg({ title: 'Your session expired', desc: 'Log in to continue. Your title, body and search fields for this French version are kept in this browser and restored after you log in, with a notice.' }, [], [Btn('Log in', 'primary')]),
conflict: () => Dlg({ title: 'Someone else saved this version', desc: 'Camille Roche saved French revision 6 at 14:32. Your changes aren\'t saved. They stay here until you discard them.', w: 520 }, [Alert('neutral', 'info', 'Save over theirs', 'Replaces the title, body and search fields of the French version only. Other languages aren\'t changed. If someone saves again first, you\'ll be asked again.')], [Btn('Keep my changes'), Btn('Download my version', 'secondary', { ic: 'download' }), Btn('Reload theirs…'), Btn('Save over theirs', 'danger')]),
move: () => Dlg({ title: 'Move the story to Notes?', desc: 'This moves every version together.' }, [Fd('Site', 'Notes', { sel: true }), Alert('warning', 'triangle-alert', 'Notes doesn\'t enable every language in this story', 'Enable the missing languages on Notes first. Disabled-language versions count too. Nothing is split.')], [Btn('Cancel'), Btn('Move story', 'primary', { dis: true })]),
moveSlug: () => Dlg({ title: 'Move the story to Archive?', desc: 'All versions move together.' }, [Fd('Site', 'Archive', { sel: true }), Alert('error', 'circle-x', 'A French address is already used on Archive', 'Change the French address before moving.', Btn('Edit French address', 'secondary', { sm: true }))], [Btn('Cancel'), Btn('Move story', 'primary', { dis: true })]),
movePerm: () => Dlg({ title: 'You can\'t move this story' }, [Alert('error', 'circle-x', 'Moving needs edit permission on every version', 'Ask an owner to move it. Nothing was changed.')], [Btn('Close')])
}[x]?.())
// ---- compose
const EDV = { v: 'ed' }
if (v === 'sl') { Object.assign(shell, setS, x === 'single' ? { crumbs: ['Settings', 'Sites', 'Archive', 'Languages'], mTitle: 'Archive · Languages' } : {}); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', slPage()); ov = [].concat(slOv() || []) }
else if (v === 'su') { Object.assign(shell, setS, { crumbs: ['Settings', 'Languages'] }); main = D('flex:1;min-height:0;display:flex;flex-direction:column', suBody()) }
else if (v === 'li') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); if (x === 'filters' && m) ov = [sheet()] }
else if (v === 'na') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); ov = [naOv()] }
else if (v === 'seo' || v === 'api') {
Object.assign(shell, edS)
let mainEl
if (v === 'seo') {
const unset = x === 'unset'
const rows = [['Address (slug)', 'comment-nous-chiffrons-une-renovation', 'Unique within Journal and French. Changing the title never changes it. Edit address changes it on purpose. Published: "Links to the old address will stop working. RenewCMS does not create redirects."'], ['Page title', FRT + ' | Journal', 'Empty uses the French title. Fallback: French title.'], ['Description', 'Empty', 'No fallback: the English site default isn\'t used for French, and the tag is left out.'], ['Image alternative text', 'Un plan de salle de bain avec les mesures', 'Written per language.']]
mainEl = Page('Search and sharing · French', 'Applies only to the French version. Page language: fr.', null, unset ? Alert('info', 'info', 'Public URL not configured', 'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.', Btn('Open site settings', 'secondary', { sm: true })) : Card(D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('Canonical', 'neutral', 'link'), S(mono + ';' + HL, 'https://journal.example.com/fr/comment-nous-chiffrons-une-renovation')), S(MU + ';font-size:12px', 'Built from the site\'s public URL pattern https://journal.example.com/{lang}/{slug}. Each language points to itself unless you set a canonical override.')), !unset && Card(S('font-weight:600;' + HL, 'Alternate languages'), S(TO, 'Published versions in enabled languages only.'), D('display:flex;gap:10px;align-items:center', S(mono, 'EN'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + TO, 'https://journal.example.com/en/how-we-price…'), Bd('Published', 'success', 'circle-check')), S(MU + ';font-size:12px', 'Drafts and the disabled Spanish language are left out.')), !unset && Card(S('font-weight:600;' + HL, 'Social metadata locale'), D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('og:locale', 'neutral', 'share-2'), x === 'noSocial' ? S(MU, 'Not set') : S(mono + ';' + HL, 'fr_FR')), S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', x === 'noSocial' ? 'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.' : 'Set by an owner for French in Journal languages. Without it the tag would be left out: a region is never guessed from the language.')), ...rows.map(r => Fd(r[0], r[1], { help: r[2], val: r[1] !== 'Empty', mono: r[0].startsWith('Address') })))
} else mainEl = Page('Integration · Languages', 'Proposed. Not available in the current API.', null, Alert('warning', 'triangle-alert', 'Proposed capability', 'These requests don\'t exist yet. Existing article-ID requests are unchanged.'),
Card(S('font-weight:600;' + HL, 'Exact lookup: site + language + slug'), S(mono + ';' + HL, 'GET /v1/public/site/journal/lang/fr/article/{slug}'), S(TO, 'Matches the published French version with that slug, or returns 404 article_not_found. It never falls back, because a slug belongs to one language.'), S(mono + ';' + MU, '{ "story_id": "st_01", "lang": "fr", "slug": "…", "alternates": [{ "lang": "en", "slug": "…" }] }')),
Card(S('font-weight:600;' + HL, 'Resolve a story: story_id + language'), S(mono + ';' + HL, 'GET /v1/public/site/journal/story/{story_id}?lang=fr&fallback=default'), S(TO, 'Use the story_id from any response or from alternates[]. French published: 200, "lang": "fr". French missing or unpublished and fallback=default: 200 with the published default, "lang": "en", "requested_lang": "fr", "fallback": true. Default also missing or unpublished: 404 language_not_available. Without fallback=default a missing French is 404 language_not_available. No other language is tried, and drafts are never returned.')),
Card(S('font-weight:600;' + HL, 'Existing, unchanged'), S(mono + ';' + HL, 'GET /v1/article/{id}'), S(TO, 'Article-ID endpoints and links keep working. Each id is one language version.')))
main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', mainEl)
} else {
Object.assign(shell, edS)
const rtl = x === 'rtl', en = x === 'navFail'
let L = rtl ? AR : en ? EN : FR
const o = { cap: CAPS.full, pub: en ? 'pub' : 'draft', seoNote: false }
let extra = null
const popLeft = m ? 0 : 292
const pop = (rows, q = {}) => ov.push(LangPop(rows, { left: popLeft, top: 188, ...q }))
if (v === 'ed') {
o.seoNote = !m && !['menu', 'many', 'two'].includes(x)
if (x === 'menu') { o.open = 1; pop(MENUS.all) } else if (x === 'two') { o.open = 1; pop(MENUS.two) } else if (x === 'many') { o.open = 1; pop(MENUS.many, { find: 1, max: 520 }) }
else if (x === 'ro') o.cap = CAPS.ro
else if (x === 'roMenu') { o.cap = CAPS.ro; o.open = 1; pop(MENUS.ro) }
else if (x === 'p2') o.cap = CAPS.p2
else if (x === 'p3') o.cap = CAPS.p3
else if (x === 'p4') { o.open = 1; pop(MENUS.p4) }
else if (x === 'p5') { o.open = 1; pop(MENUS.p5) }
else if (x === 'p6') { o.cap = CAPS.p6; ov.push(D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0' : 'right:24px;top:188px;width:300px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column', A('div', { role: 'menu', 'aria-label': 'More actions' }, 'display:flex;flex-direction:column', A('div', { role: 'menuitem' }, 'padding:10px 12px;min-height:' + (m ? 48 : 36) + 'px;display:flex;align-items:center;color:var(--ui-error)', 'Delete French version'), A('div', { role: 'menuitem', 'aria-disabled': 'true' }, 'padding:10px 12px;display:flex;flex-direction:column;gap:2px;opacity:.6', 'Delete story', S(MU + ';font-size:12px', 'You can\'t delete this story.')), A('div', { role: 'menuitem', 'aria-disabled': 'true' }, 'padding:10px 12px;display:flex;flex-direction:column;gap:2px;opacity:.6', 'Move story', S(MU + ';font-size:12px', 'Needs edit permission on every version.'))))) }
else if (x === 'unsaved') { o.dirty = 1; ov.push(Dlg({ title: 'Leave without saving?', desc: 'Your changes to the French version aren\'t saved.' }, [], [Btn('Keep editing'), Btn('Discard and open English', 'danger'), Btn('Save and open English', 'primary')])) }
else if (x === 'saving') { o.dirty = 1; o.saving = 1 }
else if (x === 'saved') ov.push(Toast('success', 'circle-check', 'French saved', 'English wasn\'t changed.'))
else if (x === 'dirtyPublish') o.dirty = 1
else if (x === 'saveFail') { o.dirty = 1; o.saveFail = 1 }
else if (x === 'onlineDirty') { o.dirty = 1; o.pub = 'pub' }
else if (['invalid', 'titleSaving', 'failed', 'focus', 'dirty'].includes(x)) { o.titleState = { titleSaving: 'saving' }[x] || x; o.dirty = x !== 'focus'; if (x === 'invalid') { o.titleVal = ''; o.saveBlock = 1 } if (x === 'titleSaving') o.saving = 1; if (x === 'failed') o.saveFail = 1 }
else if (x === 'loading') o.loading = 1
else if (x === 'gone') { main = Page('This version isn\'t available', 'It may have been deleted, or you may not have access.', null, Btn('Back to Articles', 'secondary')) }
else if (x === 'navFail') ov.push(Toast('error', 'circle-x', 'Couldn\'t open the French version', 'You\'re still editing English. Try again.'))
} else if (v === 'src') {
o.src = x !== 'closed' && x !== 'mobileClosed'; o.narrow = x === 'narrow'; o.long = x === 'long'; o.srcMob = x === 'mobileSrc' || x === 'unavail' && m; o.gone = x === 'unavail'
if (x === 'reopen') ov.push(Toast('info', 'info', 'Reference: English', 'Restored. Your place in both panes is kept.'))
if (x === 'changeRef') ov.push(D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0' : 'left:200px;top:210px;width:340px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column', A('div', { role: 'menu', 'aria-label': 'Reference version' }, 'display:flex;flex-direction:column', LRow({ n: 'English', c: 'EN', st: 'pub', cur: 1 }), LRow({ n: 'German', c: 'DE', st: 'pub' })), D('padding:10px 12px;border-top:1px solid var(--ui-border);' + MU + ';font-size:12px;line-height:18px', 'Only changes what you compare against. The tracked review source stays English. Use Change tracked source in the review banner to change that.')))
if (x === 'changeRef') { o.src = true }
} else if (v === 'fr') {
o.fresh = 'Needs review'
const buttons = [Btn('Show source reference', 'secondary', { sm: true }), CAPS.full.rv && Btn('Mark as reviewed', 'primary', { sm: true }), Btn('Change tracked source', 'ghost', { sm: true })].filter(Boolean)
const ban = (tone, ic, t, d, act) => Alert(tone, ic, t, d, act)
if (x === 'gone') { o.fresh = 'No source'; extra = ban('neutral', 'info', 'Source deleted', 'The tracked English version was deleted. Review history is kept. Choose a new source to track changes again.', Btn('Choose a new source', 'secondary', { sm: true })) }
else if (x === 'inaccessible') { o.fresh = 'Source unavailable'; extra = ban('warning', 'lock', 'The source isn\'t available to you right now', 'It may be restricted. Review history is kept. Mark as reviewed is off until you can read it again.', Btn('Choose a new source', 'ghost', { sm: true })) }
else { if (x === 'pub') o.pub = 'pub'; extra = ban('warning', 'triangle-alert', 'English changed after French was reviewed', 'French was reviewed against English revision 7 (12 Oct 2026). English is at revision 8 (14 Oct 2026). Changed fields: Title, Body.' + (x === 'pub' ? ' French is still published. Reviewing doesn\'t change publication.' : ''), buttons) }
ov = ov.concat(frOv() || [])
} else if (v === 'pd') { o.pub = x === 'unpublish' || x === 'done' ? 'pub' : 'draft'; if (x === 'saveAndPublish' || x === 'saveFailPublish') o.dirty = 1; if (x === 'saveFailPublish') o.saveFail = 1; ov = ov.concat(pdOv() || []) }
else if (v === 'at') { o.pub = 'pub'; o.cap = CAPS.full; L = LS(x === 'dup' || x === 'dupHidden' ? S1 : S5); ov = ov.concat(atOv()) }
else if (v === 'pm') { if (x === 'conflict') { o.dirty = 1 } ; ov = ov.concat(pmOv() || []) }
if (!main) main = o.loading ? D('flex:1;padding:24px;display:flex;flex-direction:column;gap:14px', Sk(6)) : Editor(L, o, extra)
}
return { srcPane: null, edBanner: null, shell, mob: m, main, ov: ov.length ? D('position:absolute;inset:0;pointer-events:none', ...ov.map(e => D('pointer-events:none', e))) : null }
}
}
</script>
</body>
</html>
@@ -0,0 +1,600 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="width:{{ fw }};height:{{ fh }};position:relative;display:flex;flex-direction:{{ dir }};background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;overflow:hidden">
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
<div style="width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px">
<div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div>
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="3">
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if>
</sc-for>
<sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ c.name }}</span></div></sc-for><sc-if value="{{ contentMore }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span><span>{{ contentMore }}</span></div></sc-if></div></sc-if>
<div style="flex:1"></div>
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
</div>
<sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if>
</sc-if>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
<div style="height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/panel-left-close.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/panel-left-close.svg') center / contain no-repeat"></span><span style="width:1px;height:16px;background:var(--ui-border);margin:0 6px"></span><sc-for list="{{ crumbs }}" as="c" hint-placeholder-count="1"><sc-if value="{{ c.sep }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></sc-if><span style="color:{{ c.fg }};font-weight:{{ c.fw }}">{{ c.t }}</span></sc-for></div>
</sc-if>
<sc-if value="{{ mob }}">
<div style="height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)"><span data-act="m-lead" style="position:relative;width:44px;height:44px;display:flex;align-items:center;justify-content:center"><sc-if value="{{ s.navBadge }}"><span aria-label="Update available" style="position:absolute;top:9px;right:9px;width:10px;height:10px;background:var(--ui-info);border:2px solid var(--ui-bg)"></span></sc-if><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ mLead }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ mLead }}.svg') center / contain no-repeat"></span></span><span style="flex:1;min-width:0;font-weight:600;font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.mTitle }}</span><sc-if value="{{ s.mAction }}"><span data-act="m-action" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.mAction }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.mAction }}.svg') center / contain no-repeat"></span></span></sc-if></div>
</sc-if>
<sc-if value="{{ s.offline }}"><div role="status" style="flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/wifi-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/wifi-off.svg') center / contain no-repeat"></span><span style="flex:1">{{ offText }}</span></div></sc-if>
<div style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column">
<sc-if value="{{ isSlot }}">{{ slotBody }}</sc-if>
<sc-if value="{{ isBoot }}">
<div aria-busy="true" style="padding:{{ pad }};display:flex;flex-direction:column;gap:24px">
<div style="display:flex;flex-direction:column;gap:8px"><span style="width:220px;height:22px;background:var(--ui-bg-accented)"></span><span style="width:320px;max-width:80%;height:12px;background:var(--ui-bg-accented)"></span></div>
<div style="display:grid;grid-template-columns:{{ cols3 }};gap:12px"><span style="height:88px;background:var(--ui-bg-accented)"></span><span style="height:88px;background:var(--ui-bg-accented)"></span><span style="height:88px;background:var(--ui-bg-accented)"></span></div>
<span style="height:220px;background:var(--ui-bg-accented)"></span>
<span style="align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending · GET /v1/auth/me</span>
</div>
</sc-if>
<sc-if value="{{ isHome }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:{{ gap }};max-width:1040px">
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"><div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:4px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Good morning, {{ who }}</span><span style="color:var(--ui-text-muted);font-size:14px">{{ s.home.sub }}</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
<sc-if value="{{ s.home.banner }}"><div role="region" aria-label="Update available" style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBannerAlign }};gap:12px;padding:12px 14px;background:{{ s.home.banner.bg }};border:1px solid var(--ui-border)"><div style="flex:1;display:flex;gap:10px;min-width:0"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:{{ s.home.banner.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.home.banner.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.home.banner.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ s.home.banner.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.home.banner.text }}</span></div></div><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.home.banner.btn }}</span><span role="button" aria-label="Dismiss" style="width:{{ btnH }};height:{{ btnH }};display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div></div></sc-if>
<sc-if value="{{ s.home.error }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't load your articles</span><span style="color:var(--ui-text-toned)">Check your connection and try again.</span></div><span style="align-self:flex-start;height:28px;padding:0 10px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>Try again</span></div></div></sc-if>
<sc-if value="{{ s.home.stats }}">
<div style="display:grid;grid-template-columns:{{ cols3 }};gap:12px">
<sc-for list="{{ s.home.stats }}" as="k" hint-placeholder-count="3"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:4px"><sc-if value="{{ k.skel }}"><span style="width:40px;height:28px;background:var(--ui-bg-accented)"></span><span style="width:70px;height:12px;background:var(--ui-bg-accented)"></span></sc-if><sc-if value="{{ k.n }}"><span style="font:600 24px/32px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ k.n }}</span><span style="color:var(--ui-text-muted)">{{ k.l }}</span></sc-if></div></sc-for>
</div>
</sc-if>
<sc-if value="{{ s.home.empty }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center"><span style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/file-text.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/file-text.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:18px;color:var(--ui-text-highlighted)">Write your first article</span><span style="color:var(--ui-text-muted);max-width:340px;font-size:14px;line-height:22px">It starts as a draft, so nothing appears on your site until you publish it.</span><span data-act="new" style="height:{{ btnH }};padding:0 16px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></div>
</sc-if>
<sc-if value="{{ s.home.recent }}">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;justify-content:space-between"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Recently updated</span><span data-act="nav-articles" style="color:var(--ui-text-toned);text-decoration:underline;text-underline-offset:3px">All articles</span></div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<sc-for list="{{ s.home.recent }}" as="a" hint-placeholder-count="5">
<div data-act="open-{{ a.id }}" style="display:flex;align-items:center;gap:12px;min-height:{{ rowH }};padding:8px 12px;border-top:1px solid var(--ui-border-muted)">
<sc-if value="{{ a.skel }}"><span style="flex:1;height:12px;background:var(--ui-bg-accented);max-width:60%"></span><span style="width:56px;height:20px;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ a.t }}"><span style="flex:1;min-width:0;font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.t }}</span><sc-if value="{{ a.on }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.draft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ desk }}"><span style="width:110px;text-align:right;color:var(--ui-text-muted)">{{ a.u }}</span></sc-if></sc-if>
</div>
</sc-for>
</div>
</div>
</sc-if>
<sc-if value="{{ act.show }}">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;justify-content:space-between"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ act.title }}</span><span data-act="nav-activity" style="color:var(--ui-text-toned);text-decoration:underline;text-underline-offset:3px">View all</span></div>
<sc-if value="{{ act.error }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't load recent activity</span><span style="color:var(--ui-text-toned)">Check your connection and try again.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;"></span>Try again</span></div></div></sc-if>
<sc-if value="{{ act.empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:32px 24px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center"><span style="color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/history.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/history.svg') center / contain no-repeat;"></span></span><span style="font-weight:600;color:var(--ui-text-highlighted)">No activity yet</span><span style="color:var(--ui-text-muted);max-width:260px">{{ act.emptyText }}</span></div></sc-if>
<sc-if value="{{ act.rows }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ act.rows }}" as="r" hint-placeholder-count="5"><div style="display:flex;align-items:center;gap:12px;min-height:{{ rowH }};padding:8px 12px;border-top:1px solid var(--ui-border-muted)"><sc-if value="{{ r.skel }}"><span style="width:24px;height:24px;flex-shrink:0;background:var(--ui-bg-accented)"></span><span style="flex:1;height:12px;background:var(--ui-bg-accented);max-width:60%"></span></sc-if><sc-if value="{{ r.s }}"><span style="width:24px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand);font:600 10px 'IBM Plex Sans',sans-serif">{{ r.ini }}</span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text)">{{ r.s }}<span style="font-weight:600;color:var(--ui-text-highlighted)">{{ r.em }}</span></span><span style="font-size:12px;color:var(--ui-text-muted)">{{ r.meta }}</span></div><sc-if value="{{ r.fail }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-error-soft);color:var(--ui-error);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span>Failed</span></sc-if></sc-if></div></sc-for></div></sc-if>
</div>
</sc-if>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;gap:14px;align-items:flex-start"><span style="width:36px;height:36px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plug.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plug.svg') center / contain no-repeat"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Connect your website</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your site reads published articles from RenewCMS. Share this guide with whoever builds it.</span><span data-act="nav-integration" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px;margin-top:4px">Open the guide</span></div></div>
</div>
</sc-if>
<sc-if value="{{ isList }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"><div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Articles</span><span style="color:var(--ui-text-muted)">Everything you've written, online and in draft.</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
<div style="display:flex;flex-direction:{{ tbDir }};gap:{{ tbGap }};border-bottom:{{ tbBorder }}">
<sc-if value="{{ mob }}"><span style="height:44px;padding:0 4px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }};font-size:16px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:36px;height:36px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
<sc-if value="{{ mob }}"><span role="switch" aria-checked="{{ naOn }}" style="display:flex;flex-direction:column"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid {{ naBd }};background:{{ naBg }};color:{{ naFg }};font-size:15px;font-weight:{{ naFw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>Needs attention<span style="font:500 11px 'IBM Plex Mono',monospace">{{ naN }}</span></span></span></sc-if><sc-if value="{{ multiMob }}"><span style="position:relative;display:flex;flex-direction:column;"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};font-size:16px;"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:48px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:44px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
<div style="display:flex;gap:20px;flex:1;border-bottom:{{ tabBorder }}"><sc-for list="{{ tabs }}" as="t" hint-placeholder-count="3"><span style="height:{{ tabH }};display:flex;align-items:center;gap:6px;border-bottom:2px solid {{ t.line }};margin-bottom:-1px;font-weight:{{ t.fw }};color:{{ t.fg }};font-size:{{ tabF }}">{{ t.t }}<span style="font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ t.n }}</span></span></sc-for></div>
<sc-if value="{{ desk }}"><span role="switch" aria-checked="{{ naOn }}" style="margin-bottom:6px;display:flex"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid {{ naBd }};background:{{ naBg }};color:{{ naFg }};font-size:13px;font-weight:{{ naFw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>Needs attention<span style="font:500 11px 'IBM Plex Mono',monospace">{{ naN }}</span></span></span></sc-if><sc-if value="{{ desk }}"><span style="width:260px;height:32px;margin-bottom:6px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
<sc-if value="{{ multiDesk }}"><span style="position:relative;display:flex;flex-direction:column;width:150px;margin-bottom:6px;"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:36px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
</div>
<sc-if value="{{ s.list.empty }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:56px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center"><span style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.list.empty.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.list.empty.icon }}.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ s.list.empty.title }}</span><span style="color:var(--ui-text-muted);max-width:280px">{{ s.list.empty.text }}</span><span data-act="empty" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;font-weight:500;background:{{ emptyBg }};color:{{ emptyFg }};border:1px solid {{ emptyBd }}">{{ s.list.empty.action }}</span></div>
</sc-if>
<sc-if value="{{ pgErr }}"><div role="alert" style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span><span style="flex:1;color:var(--ui-text-toned)">{{ pgErr }}</span><span data-act="pg-retry" style="height:{{ pgBtn }};padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>Retry</span></div></sc-if>
<sc-if value="{{ pgNote }}"><div role="status" style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span><span style="flex:1;color:var(--ui-text-toned)">{{ pgNote }}</span></div></sc-if>
<sc-if value="{{ showTable }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ gridCols }};height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Title</span><sc-if value="{{ authorsCol }}"><span>Authors</span></sc-if><sc-if value="{{ multi }}"><span>Site</span></sc-if><span>Status</span><span>Created</span><span style="display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)">Updated<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat"></span></span><span></span></div>
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
<div data-act="open-{{ a.id }}" style="display:grid;grid-template-columns:{{ gridCols }};height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ a.bg }};opacity:{{ a.op }};position:relative">
<sc-if value="{{ a.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><sc-if value="{{ authorsCol }}"><span style="width:60px;height:24px;background:var(--ui-bg-accented)"></span></sc-if><sc-if value="{{ multi }}"><span style="width:70px;height:12px;background:var(--ui-bg-accented)"></span></sc-if><span style="width:60px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span></span></sc-if>
<sc-if value="{{ a.t }}">
<span style="font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.t }}</span>
<sc-if value="{{ authorsCol }}"><span title="{{ a.auNames }}" style="display:flex;align-items:center;min-width:0"><sc-if value="{{ a.hasAu }}"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:24px;height:24px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 10px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for><sc-if value="{{ a.auMore }}"><span style="margin-left:6px;font-size:12px;color:var(--ui-text-muted)">{{ a.auMore }}</span></sc-if></sc-if><sc-if value="{{ a.noAu }}"><span aria-label="No authors" style="color:var(--ui-text-dimmed)">—</span></sc-if></span></sc-if><sc-if value="{{ multi }}"><span style="color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.site }}</span></sc-if>
<span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if></span>
<span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span>
<span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
<sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:12px;top:40px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
</sc-if>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ pagerD }}"><div style="display:flex;align-items:center;gap:16px;padding:12px 0 0"><div style="display:contents"><span aria-live="polite" style="flex:1;color:var(--ui-text-muted)">{{ pager.summary }}</span><div style="position:relative"><span data-act="pg-size" role="combobox" aria-label="Rows per page" aria-expanded="{{ pager.sizeOpen }}" style="height:{{ pgBtn }};padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);outline:{{ pager.sizeRing }};outline-offset:2px">{{ pager.size }} per page<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ pager.sizeOpen }}"><div role="listbox" style="position:absolute;left:0;bottom:calc(100% + 4px);z-index:40;width:160px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ pager.sizes }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:{{ pgBtn }};padding:0 8px;display:flex;align-items:center;gap:8px;background:{{ o.bg }};color:{{ o.fg }};font-weight:{{ o.fw }}">{{ o.t }}</div></sc-for></div></sc-if></div></div>
<sc-if value="{{ pager.ctl }}"><nav aria-label="Pagination" style="display:flex;align-items:center;gap:4px;justify-content:flex-end">
<span data-act="pg-first" aria-label="First page" style="width:{{ pgBtn }};height:{{ pgBtn }};display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:{{ pager.backOp }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-left.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-left.svg') center / contain no-repeat"></span></span>
<span data-act="pg-prev" aria-label="Previous page" style="width:{{ pgBtn }};height:{{ pgBtn }};display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:{{ pager.backOp }};outline:{{ pager.prevRing }};outline-offset:2px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-left.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-left.svg') center / contain no-repeat"></span></span>
<sc-for list="{{ pager.nums }}" as="n" hint-placeholder-count="5"><span data-act="pg-{{ n.t }}" aria-current="{{ n.cur }}" style="min-width:{{ pgBtn }};height:{{ pgBtn }};padding:0 6px;display:flex;align-items:center;justify-content:center;border:1px solid {{ n.bd }};background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }};outline:{{ n.ring }};outline-offset:2px">{{ n.t }}</span></sc-for>
<span data-act="pg-next" aria-label="Next page" style="width:{{ pgBtn }};height:{{ pgBtn }};display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:{{ pager.fwdOp }};outline:{{ pager.nextRing }};outline-offset:2px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></span>
<span data-act="pg-last" aria-label="Last page" style="width:{{ pgBtn }};height:{{ pgBtn }};display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:{{ pager.fwdOp }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-right.svg') center / contain no-repeat"></span></span>
</nav></sc-if></div></sc-if>
<sc-if value="{{ showCards }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
<div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}">
<sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center Line truncated
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ pagerM }}"><div style="display:flex;flex-direction:column;gap:12px;padding:12px 0 16px"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between"><span aria-live="polite" style="flex:0 1 auto;color:var(--ui-text-muted)">{{ pager.summary }}</span><div style="position:relative"><span data-act="pg-size" role="combobox" aria-label="Rows per page" aria-expanded="{{ pager.sizeOpen }}" style="height:{{ pgBtn }};padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);outline:{{ pager.sizeRing }};outline-offset:2px">{{ pager.size }} per page<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ pager.sizeOpen }}"><div role="listbox" style="position:absolute;right:0;bottom:calc(100% + 4px);z-index:40;width:160px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ pager.sizes }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:{{ pgBtn }};padding:0 8px;display:flex;align-items:center;gap:8px;background:{{ o.bg }};color:{{ o.fg }};font-weight:{{ o.fw }}">{{ o.t }}</div></sc-for></div></sc-if></div></div>
<sc-if value="{{ pager.ctl }}"><nav aria-label="Pagination" style="display:flex;align-items:center;gap:8px;">
<span data-act="pg-prev" aria-label="Previous page" style="flex:1;height:{{ pgBtn }};display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:{{ pager.backOp }};outline:{{ pager.prevRing }};outline-offset:2px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-left.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-left.svg') center / contain no-repeat"></span>Previous</span>
<span aria-current="page" style="flex:0 0 auto;padding:0 4px;color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap">{{ pager.of }}</span>
<span data-act="pg-next" aria-label="Next page" style="flex:1;height:{{ pgBtn }};display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:{{ pager.fwdOp }};outline:{{ pager.nextRing }};outline-offset:2px;font-weight:500">Next<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></span>
</nav></sc-if></div></sc-if>
</div>
<sc-if value="{{ mob }}"><sc-if value="{{ listFab }}"><div data-act="new" style="position:absolute;left:16px;right:16px;bottom:16px;height:48px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</div></sc-if></sc-if>
</sc-if>
<sc-if value="{{ isEditor }}">
<sc-if value="{{ s.ed.notfound }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:14px;max-width:560px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">404</span><span style="font:600 24px/32px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">This article doesn't exist</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px">It may have been deleted, or the link is wrong.</span><span data-act="nav-articles" style="align-self:flex-start;height:{{ btnH }};padding:0 12px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">All articles</span></div>
</sc-if>
<sc-if value="{{ edFound }}">
<div style="flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:{{ edHeadPad }};display:flex;flex-wrap:wrap;align-items:center;gap:{{ edHeadGap }}">
<sc-if value="{{ desk }}"><span data-act="back" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-left.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-left.svg') center / contain no-repeat"></span></span></sc-if>
<div style="flex:1 1 {{ edTitleBasis }};min-width:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<sc-if value="{{ s.ed.loading }}"><span style="width:320px;max-width:70%;height:20px;background:var(--ui-bg-accented)"></span><span style="width:60px;height:22px;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ edReady }}"></sc-if><span title="{{ edTitleTip }}" style="font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);{{ edTitleCss }}">{{ edTitle }}</span><sc-if value="{{ lg }}"><span role="button" aria-haspopup="listbox" aria-expanded="{{ lg.open }}" aria-label="{{ lg.label }}" style="height:{{ lg.h }};max-width:100%;width:max-content;padding:0 8px;display:inline-flex;align-items:center;gap:6px;border:1px solid {{ lg.bd }};background:var(--ui-bg);color:var(--ui-text-highlighted);font-size:13px;font-weight:500;white-space:nowrap;flex-shrink:0"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat"></span><span>{{ lg.name }}</span><span style="flex-shrink:0;font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ lg.code }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ lg.ro }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:13px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span>Read-only</span></sc-if><sc-if value="{{ lg.fresh }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-warning);background:var(--ui-bg);color:var(--ui-warning);font-size:13px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/refresh-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/refresh-cw.svg') center / contain no-repeat"></span>{{ lg.fresh }}</span></sc-if><sc-if value="{{ edPending }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:13px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ s.ed.pending }}</span></sc-if><sc-if value="{{ edOnline }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:13px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ edDraft }}"><span style="height:26px;padding:0 9px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:13px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if></sc-if>
</div>
<sc-if value="{{ edReady }}">
<span aria-live="polite" style="display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;color:{{ save.fg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ save.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ save.icon }}.svg') center / contain no-repeat"></span>{{ save.t }}<sc-if value="{{ save.retry }}"><span data-act="save" style="height:24px;padding:0 8px;display:flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>Retry</span></sc-if></span>
<sc-if value="{{ mob }}"><span style="flex:1"></span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ counter.fg }}">{{ counter.t }}</span></sc-if>
<sc-if value="{{ desk }}">
<span data-act="preview" style="display:flex;align-items:center;gap:8px;white-space:nowrap;flex-shrink:0;color:var(--ui-text-toned)"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ pvJust }};background:{{ pvBg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span>Preview</span>
<span data-act="save" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ saveOp }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
<span data-act="publish" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat"></span>{{ pub.t }}</span>
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>{{ delText }}</span>
</sc-if>
</sc-if>
</div>
{{ edBanner }}<sc-if value="{{ showToolbar }}">
<div role="toolbar" aria-label="Formatting" style="flex-shrink:0;padding:6px {{ tbPadX }};display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--ui-border);position:relative;overflow:{{ tbOverflow }};background:var(--ui-bg)">
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0;transform:translateX({{ tbShift }})"><sc-for list="{{ toolbar }}" as="t" hint-placeholder-count="20"><sc-if value="{{ t.sep }}"><span style="width:1px;height:20px;background:var(--ui-border-accented);margin:0 6px;flex-shrink:0"></span></sc-if><sc-if value="{{ t.btn }}"><span data-act="tb-{{ t.icon }}" style="height:{{ tbBtn }};min-width:{{ tbBtn }};padding:0 6px;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:4px;position:relative;color:{{ t.fg }};opacity:{{ t.op }};outline:{{ t.ring }};outline-offset:1px;background:{{ t.bg }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat"></span>{{ t.text }}<sc-if value="{{ t.tip }}"><span role="tooltip" style="position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:6px;z-index:30;white-space:nowrap;padding:4px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:400 12px/16px 'IBM Plex Sans',sans-serif">{{ t.tip }}</span></sc-if></span></sc-if></sc-for></div><sc-if value="{{ tbFadeL }}"><span style="position:absolute;left:0;top:0;bottom:0;width:24px;background:linear-gradient(to right,var(--ui-bg),transparent);pointer-events:none"></span></sc-if><sc-if value="{{ tbFadeR }}"><span style="position:absolute;right:0;top:0;bottom:0;width:32px;background:linear-gradient(to left,var(--ui-bg),transparent);pointer-events:none"></span></sc-if>
</div>
</sc-if>
<div style="flex:1;min-height:0;display:flex">
{{ srcPane }}<div style="flex:1;min-width:0;display:flex;flex-direction:column;background:{{ edBg }}">
<div data-act="type" style="flex:1;min-height:0;overflow:hidden;padding:{{ edPad }};font-family:'Source Serif 4',serif;color:var(--ui-text-highlighted)">
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:14px;max-width:672px"><span style="height:14px;background:var(--ui-bg-accented)"></span><span style="height:14px;background:var(--ui-bg-accented);width:92%"></span><span style="height:14px;background:var(--ui-bg-accented);width:80%"></span><span style="height:22px;background:var(--ui-bg-accented);width:45%;margin-top:18px"></span><span style="height:14px;background:var(--ui-bg-accented)"></span><span style="height:14px;background:var(--ui-bg-accented);width:70%"></span></div></sc-if>
<sc-if value="{{ edReady }}">
<div style="max-width:672px;margin:{{ edMargin }}">
<sc-if value="{{ s.ed.preview }}"><div style="font-size:{{ h1F }};line-height:1.25;font-weight:600;margin:0 0 20px">{{ edTitle }}</div></sc-if>
<sc-if value="{{ sampleBody }}"><p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p><sc-if value="{{ lk.on }}"><p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">We also check the <span style="position:relative"><span data-act="link" style="{{ lk.style }}">timber windows</span><sc-if value="{{ lk.pop }}"><span role="dialog" aria-label="Link" style="position:absolute;left:0;top:100%;margin-top:8px;z-index:20;width:288px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);font:400 13px/20px 'IBM Plex Sans',sans-serif;color:var(--ui-text);display:flex;flex-direction:column;text-align:left"><span style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--ui-border);font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></span><span style="display:flex;padding:4px;gap:2px"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);background:{{ lk.popEdit }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</span><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open</span><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:6px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove</span></span></span></sc-if></span> before any tiling starts.</p></sc-if>
<div style="font-size:{{ h2F }};line-height:1.3;font-weight:600;margin:32px 0 12px">What goes into the price</div>
<ul style="margin:0 0 20px;padding-left:28px;list-style:square;font-size:{{ pF }};line-height:1.66"><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>
<sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if>
<p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">We don't add a margin on materials you choose yourself.<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p></sc-if><sc-if value="{{ edBody }}"><p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">{{ edBody }}<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p><sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if></sc-if>
</div>
</sc-if>
</div>
<sc-if value="{{ desk }}"><sc-if value="{{ showToolbar }}"><div style="flex-shrink:0;padding:8px 24px;border-top:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;gap:12px"><span style="flex:1;color:{{ counter.fg }};font-size:12px">{{ counter.msg }}</span><span style="display:flex;align-items:center;gap:6px;font:400 12px 'IBM Plex Mono',monospace;color:{{ counter.fg }}"><sc-if value="{{ counter.icon }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ counter.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ counter.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ counter.t }}</span></div></sc-if></sc-if>
</div>
<sc-if value="{{ desk }}">
<div style="width:280px;flex-shrink:0;border-left:1px solid var(--ui-border);background:var(--ui-bg);padding:20px;display:flex;flex-direction:column;gap:16px">
<span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Details</span>
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate(-90deg)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><span style="margin-left:22px;width:40%;height:12px;background:var(--ui-bg-accented)"></span></div><div style="display:flex;flex-direction:column;gap:10px"><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ edReady }}">
<div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span><span style="color:var(--ui-text-muted)">Images</span><span>{{ imgCount }}</span></div>
<div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace"><span style="flex:1">/v1/article/12</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-muted);font-size:12px;line-height:18px">{{ apiNote }}</span></div>
<div style="display:flex;flex-direction:column;gap:4px;padding-top:12px;border-top:1px solid var(--ui-border)"><span style="color:var(--ui-text-muted)">Title</span><span style="font-size:12px;line-height:18px;color:var(--ui-text-toned)">{{ edTitleNote }}</span></div>
</sc-if>
</div>
</sc-if>
</div>
<sc-if value="{{ mob }}"><sc-if value="{{ edReady }}">
<div style="flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg);display:flex;flex-direction:column;gap:8px">
<sc-if value="{{ counter.msg }}"><span style="color:{{ counter.fg }};font-size:13px;line-height:19px">{{ counter.msg }}</span></sc-if>
<div style="display:flex;gap:8px"><span data-act="details" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/panel-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/panel-right.svg') center / contain no-repeat"></span></span><span data-act="save" style="flex:1;height:44px;padding:0 12px;white-space:nowrap;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;font-size:15px;opacity:{{ saveOp }}">Save</span><span data-act="publish" style="flex:1 0 auto;padding:0 16px;white-space:nowrap;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;font-weight:500;font-size:15px;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}">{{ pub.t }}</span></div>
</div>
</sc-if></sc-if>
</sc-if>
</sc-if>
<sc-if value="{{ isInteg }}"><div style="position:relative;flex:1;min-height:0;overflow:hidden"><dc-import name="IntegrationPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
<sc-if value="{{ isKeys }}"><div style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden"><dc-import name="ApiKeysPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
<sc-if value="{{ isUsers }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"><div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Users</span><span style="color:var(--ui-text-muted)">Everyone who can sign in and work on your articles.</span></div><sc-if value="{{ desk }}"><span data-act="invite" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/user-plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/user-plus.svg') center / contain no-repeat"></span>Invite user</span></sc-if></div>
<sc-if value="{{ usError }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't load users</span><span style="color:var(--ui-text-toned)">Check your connection and try again.</span></div><span style="align-self:flex-start;height:{{ btnH }};padding:0 10px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>Try again</span></div></div></sc-if>
<sc-if value="{{ usTable }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px;gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Email</span><span>Display name</span><span>Role</span><span>Invite status</span><span style="display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)">Created<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat"></span></span><span></span></div>
<sc-for list="{{ usRows }}" as="r" hint-placeholder-count="5">
<div style="display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px;gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:50%;background:var(--ui-bg-accented)"></span><span style="width:52px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:76px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ r.email }}"><span style="display:flex;align-items:center;gap:8px;min-width:0"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.email }}</span><sc-if value="{{ r.you }}"><span style="flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">you</span></sc-if></span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ r.dfg }}">{{ r.dn }}</span><span><span style="height:20px;padding:0 7px;display:inline-flex;align-items:center;background:{{ r.rl.bg }};color:{{ r.rl.fg }};border:1px solid {{ r.rl.bd }};font:500 11px 'IBM Plex Mono',monospace">{{ r.rl.t }}</span></span><span><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:{{ r.b.bg }};color:{{ r.b.fg }};border:1px solid {{ r.b.bd }};font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat"></span>{{ r.b.t }}</span></span><span style="color:var(--ui-text-muted)">{{ r.c }}</span><span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.act }}"><span data-act="{{ r.act.key }}" style="height:28px;padding:0 9px;white-space:nowrap;flex-shrink:0;display:inline-flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ r.act.fg }};font-weight:500;font-size:12px;opacity:{{ r.act.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ r.act.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ r.act.icon }}.svg') center / contain no-repeat"></span>{{ r.act.t }}</span></sc-if></span></sc-if>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ usCards }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
<sc-for list="{{ usRows }}" as="r" hint-placeholder-count="5">
<div style="display:flex;flex-direction:column;justify-content:center;gap:6px;padding:12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;background:{{ r.bg }}">
<sc-if value="{{ r.skel }}"><span style="width:55%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ r.email }}"><span style="display:flex;align-items:center;gap:8px;min-width:0"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px;line-height:21px;font-weight:500;color:{{ r.fg }}">{{ r.email }}</span><sc-if value="{{ r.you }}"><span style="flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">you</span></sc-if></span><sc-if value="{{ r.display2 }}"><span style="color:var(--ui-text-toned)">{{ r.dn }}</span></sc-if><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted)"><span style="height:20px;padding:0 7px;display:inline-flex;align-items:center;background:{{ r.rl.bg }};color:{{ r.rl.fg }};border:1px solid {{ r.rl.bd }};font:500 11px 'IBM Plex Mono',monospace">{{ r.rl.t }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:{{ r.b.bg }};color:{{ r.b.fg }};border:1px solid {{ r.b.bd }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat"></span>{{ r.b.t }}</span><span>Created {{ r.c }}</span></div><sc-if value="{{ r.act }}"><div style="display:flex;padding-top:4px"><span data-act="{{ r.act.key }}" style="height:44px;padding:0 14px;white-space:nowrap;flex-shrink:0;display:inline-flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ r.act.fg }};font-weight:500;font-size:13px;opacity:{{ r.act.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ r.act.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ r.act.icon }}.svg') center / contain no-repeat"></span>{{ r.act.t }}</span></div></sc-if></sc-if>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ usOnly }}"><span style="color:var(--ui-text-muted);text-wrap:pretty">Invite someone so they can sign in with their own account instead of sharing yours.</span></sc-if>
</div>
<sc-if value="{{ mob }}"><sc-if value="{{ listFab }}"><div data-act="invite" style="position:absolute;left:16px;right:16px;bottom:16px;height:48px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/user-plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/user-plus.svg') center / contain no-repeat"></span>Invite user</div></sc-if></sc-if>
</sc-if>
<sc-if value="{{ isSettings }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;flex-shrink:0;margin-top:{{ setScroll }}">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Settings</span><span style="color:var(--ui-text-muted)">Workspace information.</span></div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div>
<div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px">
<span id="updates-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);outline:{{ s.upd.focus }};outline-offset:4px">Updates</span>
<div aria-live="polite" style="display:flex;gap:12px;padding:14px;background:{{ s.upd.bg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:{{ s.upd.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat"></span></span><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.upd.title }}</span><sc-if value="{{ s.upd.pendingTag }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-bg);color:var(--ui-warning);border:1px solid var(--ui-warning);font:500 11px 'IBM Plex Mono',monospace">Pending</span></sc-if></div><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.upd.text }}</span><sc-if value="{{ s.upd.bar }}"><div style="display:flex;flex-direction:column;gap:6px;padding-top:8px"><div role="progressbar" aria-label="{{ s.upd.barLabel }}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ s.upd.pct }}" style="height:6px;background:var(--ui-bg-accented)"><div style="height:6px;width:{{ s.upd.barW }};margin-left:{{ s.upd.barL }};background:var(--ui-primary)"></div></div><span style="font:400 12px/16px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.upd.barText }}</span></div></sc-if><sc-if value="{{ s.upd.meta }}"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.meta }}</span></sc-if><sc-if value="{{ s.upd.inBtn }}"><div style="display:flex;padding-top:8px"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ s.upd.inBtn.bg }};color:{{ s.upd.inBtn.fg }};border:1px solid {{ s.upd.inBtn.bd }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.inBtn.t }}</span></div></sc-if></div></div>
<sc-if value="{{ s.upd.notes }}"><div style="display:flex;flex-direction:column;gap:10px"><span role="button" aria-expanded="{{ s.upd.notes.open }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;min-height:{{ btnH }};font-weight:500;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat"></span>What's new in {{ s.upd.notes.ver }}</span><sc-if value="{{ s.upd.notes.open }}"><div style="display:flex;flex-direction:column;gap:8px;padding:0 0 0 20px"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.notes.date }}</span><sc-for list="{{ s.upd.notes.items }}" as="it" hint-placeholder-count="3"><div style="display:flex;gap:8px"><span aria-hidden="true" style="flex-shrink:0;width:4px;height:4px;margin-top:8px;background:var(--ui-text-muted)"></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ it.t }}</span></div></sc-for><span style="align-self:flex-start;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">Read the full release notes</span></div></sc-if></div></sc-if>
<sc-if value="{{ s.upd.cmd }}"><div style="display:flex;flex-direction:column;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">For whoever manages the server</span><div style="display:flex;flex-direction:{{ upCmdDir }};align-items:{{ upBtnAlign }};gap:8px"><code style="flex:1;min-width:0;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:22px">{{ s.upd.cmd.line }}</code><span role="button" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ s.upd.cmd.fg }};font-weight:500"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.cmd.t }}</span></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.cmd.note }}</span></div></sc-if>
<sc-if value="{{ s.upd.btns }}"><div style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBtnAlign }};gap:8px"><sc-for list="{{ s.upd.btns }}" as="b" hint-placeholder-count="1"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ b.bg }};color:{{ b.fg }};border:1px solid {{ b.bd }};opacity:{{ b.op }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat"></span>{{ b.t }}</span></sc-for></div></sc-if>
<sc-if value="{{ s.upd.hint }}"><span style="font-size:12px;color:var(--ui-text-muted);text-wrap:pretty">{{ s.upd.hint }}</span></sc-if>
<sc-if value="{{ s.upd.link }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">{{ s.upd.link }}<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span></span></sc-if>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Image sizes, cache and cleanup for your media library.</span></div><div style="display:flex;flex-direction:column"><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Manage image variants</span><span style="color:var(--ui-text-muted)">Named sizes requested with ?v=</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/database.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/database.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Cache usage</span><span style="color:var(--ui-text-muted)">Generated images and disk space</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></div></div>
<sc-if value="{{ showSites }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Publishing</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Where your content is shown.</span></div><div style="display:flex;flex-direction:column"><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Manage sites</span><span style="color:var(--ui-text-muted)">Base URLs, default image and robots policy</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></div></div></sc-if>
<div role="region" aria-labelledby="cleanup-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><sc-if value="{{ cl.loading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><span style="display:block;width:140px;height:16px;background:var(--ui-bg-accented)"></span><span style="display:block;width:90%;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:55%;height:13px;background:var(--ui-bg-accented)"></span><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:120px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:180px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:36px;height:20px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:60%;height:13px;background:var(--ui-bg-accented)"></span><div style="height:1px;background:var(--ui-border)"></div><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:100px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:220px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:140px;height:32px;background:var(--ui-bg-accented)"></span></div></div></sc-if><sc-if value="{{ cl.ready }}"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="cleanup-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media cleanup</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ cl.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ cl.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ cl.ro }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;"></span></span><span style="color:var(--ui-text-toned)">Only owners and admins can change this.</span></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Automatic purge</span><span style="font-size:12px;color:var(--ui-text-muted)">Runs in the background. Off by default.</span></div><div role="switch" aria-checked="{{ cl.on }}" style="display:flex;align-items:center;justify-content:center;width:{{ cl.swW }};height:{{ cl.swH }};flex-shrink:0;opacity:{{ cl.swOp }}"><div style="position:relative;width:36px;height:20px;border:1px solid {{ cl.swBd }};background:{{ cl.swBg }};outline:{{ cl.swRing }};outline-offset:2px"><div style="position:absolute;top:1px;left:{{ cl.knobL }};width:16px;height:16px;background:var(--ui-bg);border:1px solid {{ cl.knobBd }};box-sizing:border-box"></div></div></div></div><sc-if value="{{ cl.on }}"><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Every</span><span style="font-size:12px;color:var(--ui-text-muted)">Between 1 and 365 days.</span></div><div style="display:flex;flex-direction:column;gaLine truncated
<sc-if value="{{ rt.show }}"><div role="region" aria-labelledby="retention-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="retention-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Retention</span><span style="color:var(--ui-text-muted);text-wrap:pretty">How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ rt.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ rt.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ rt.err }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't save the retention period</span><span style="color:var(--ui-text-toned)">Your previous setting is still in place.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Try again</span></div></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep activity for</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.help }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.val }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;color:var(--ui-text-muted)"></span></div></div><div style="display:flex;align-items:center;gap:12px;color:var(--ui-text-muted);font-size:12px;font-weight:500"><span style="flex:1;height:1px;background:var(--ui-border)"></span><span>or</span><span style="flex:1;height:1px;background:var(--ui-border)"></span></div><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep at most</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.sizeHelp }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.sizeVal }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;cLine truncated
</div>
</sc-if>
<sc-if value="{{ isProfile }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;flex-shrink:0;margin-top:{{ pfShift }}">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Profile</span><span style="color:var(--ui-text-muted)">Your account on this RenewCMS workspace.</span></div>
<sc-if value="{{ pfLoading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:120px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:90px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:100px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div></div></sc-if>
<sc-if value="{{ pfReady }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Display name</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.</span></div>
<sc-if value="{{ pf.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned)">{{ pf.notice }}</span></div></sc-if>
<div style="display:flex;flex-direction:column;gap:6px">
<label style="font-weight:500;color:var(--ui-text-highlighted)">Display name <span style="font-weight:400;color:var(--ui-text-muted)">(optional)</span></label>
<div style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;border:1px solid {{ dn.bd }};box-shadow:{{ dn.sh }};background:{{ dn.bg }};color:{{ dn.fg }};font-size:{{ inF }};overflow:hidden;white-space:nowrap"><span style="overflow:hidden;text-overflow:ellipsis">{{ dn.shown }}</span><sc-if value="{{ dn.caret }}"><span style="flex-shrink:0;display:inline-block;width:1px;height:18px;background:var(--ui-text-highlighted);margin-left:1px"></span></sc-if></div>
<div style="display:flex;gap:12px;align-items:flex-start"><span style="flex:1;display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px"><sc-if value="{{ dn.err }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ dn.err }}</sc-if></span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ dn.countFg }}">{{ dn.count }}</span></div>
</div>
</div>
<div style="padding:12px {{ cardPadX }};border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);display:flex;align-items:center;gap:12px"><span style="flex:1;min-width:0;color:{{ dn.footFg }};font-size:12px;line-height:18px">{{ dn.foot }}</span><span data-act="pf-save" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;flex-shrink:0;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ dnSave.op }}"><sc-if value="{{ dnSave.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ dnSave.t }}</span></div>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Account</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Set when your account was created. You log in with your username, even after adding a display name.</span></div>
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><dc-import name="AccountCards" part="email" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></div>
<sc-if value="{{ pfPanel }}"><dc-import name="AccountCards" part="panel" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></sc-if>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Author profile</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your public byline on articles. It's separate from your account.</span></div>
<sc-if value="{{ pAu.err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't create your author profile</span><span style="color:var(--ui-text-toned)">Try again in a moment.</span></div></div></sc-if>
<sc-if value="{{ pAu.none }}"><span style="color:var(--ui-text-toned);text-wrap:pretty">You don't have an author profile yet. Create one to be credited on your own articles.</span><div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ pAu.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat"></span>{{ pAu.btn }}</span><span style="color:var(--ui-text-muted);font-size:12px">{{ pAu.uses }}</span></div></sc-if>
<sc-if value="{{ pAu.has }}"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><span style="width:40px;height:40px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand);border:1px solid var(--ui-border);font:600 14px/1 'IBM Plex Sans',sans-serif">MA</span><div style="flex:1;min-width:140px;display:flex;flex-direction:column"><span style="font-weight:500;color:var(--ui-text-highlighted)">Marta Alves</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ pAu.slug }}</span></div><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit author</span></div>
<sc-if value="{{ pAu.slugNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Your slug is {{ pAu.slug }}</span><span style="color:var(--ui-text-toned)">marta-alves was already used by another author. You can change it in Edit author.</span></div></div></sc-if>
<div style="display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Linked to your account. New articles start with this author.</div></sc-if></div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Password</span><span style="color:var(--ui-text-muted);text-wrap:pretty">To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.</span></div>
<div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><div style="flex:1;min-width:0"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Password</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:0.12em">••••••••••••</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div><span data-act="pf-reset" style="height:{{ inH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;flex-shrink:0;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap;opacity:{{ rsBtn.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat"></span>{{ rsBtn.t }}</span></div>
<sc-if value="{{ rsNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ rsNote.bg }}"><span style="display:flex;padding-top:2px;color:{{ rsNote.fg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:{{ rsNote.fg }}">{{ rsNote.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ rsNote.text }}</span></div></div></sc-if>
</div>
<dc-import name="AccountCards" part="cards" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ isError }}">
<div style="padding:{{ errPad }};display:flex;flex-direction:column;gap:14px;max-width:560px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.err.code }}</span><span style="font:600 24px/32px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.err.title }}</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px;text-wrap:pretty">{{ s.err.text }}</span><div style="display:flex;gap:8px;flex-wrap:wrap"><span data-act="err-primary" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">{{ s.err.primary }}</span><sc-if value="{{ s.err.secondary }}"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">{{ s.err.secondary }}</span></sc-if></div></div>
</sc-if>
</div>
</div>
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if>
<sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if>
<sc-if value="{{ s.modal }}">
<div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:{{ modalAlign }};justify-content:center;padding:{{ modalPad }}">
<div role="dialog" aria-modal="true" style="width:{{ modalW }};background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column">
<sc-if value="{{ mob }}"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div></sc-if>
<div style="padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.modal.title }}</span><sc-if value="{{ s.modal.desc }}"><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ s.modal.desc }}</span></sc-if></div><sc-if value="{{ s.modal.close }}"><span data-act="modal-close" style="width:{{ closeSz }};height:{{ closeSz }};display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></div>
<div style="padding:20px 24px 0;display:flex;flex-direction:column;gap:16px">
<sc-if value="{{ s.modal.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned)">{{ s.modal.notice }}</span></div></sc-if>
<sc-if value="{{ s.modal.list }}"><div style="display:flex;flex-direction:column;gap:6px"><sc-for list="{{ mList }}" as="li" hint-placeholder-count="3"><div style="display:flex;gap:8px;align-items:flex-start;color:var(--ui-text-toned)"><span style="display:flex;padding-top:3px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">{{ li.t }}</span></div></sc-for></div></sc-if>
<sc-if value="{{ mWarn }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ mWarn.bg }};color:{{ mWarn.fg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">{{ mWarn.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ mWarn.text }}</span></div></div></sc-if>
<sc-for list="{{ mFields }}" as="f" hint-placeholder-count="0">
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</span><sc-if value="{{ f.count }}"><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ f.countFg }}">{{ f.count }}</span></sc-if></div><div style="min-height:{{ f.h }};padding:{{ f.p }};display:flex;align-items:{{ f.ai }};border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }}">{{ f.shown }}</div><sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if><sc-if value="{{ f.help }}"><span style="color:var(--ui-text-muted);font-size:12px">{{ f.help }}</span></sc-if></div>
</sc-for>
<sc-if value="{{ s.modal.link }}"><span data-act="modal-link" role="link" style="align-self:flex-start;min-height:{{ closeSz }};display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat"></span>{{ s.modal.link }}</span></sc-if>
<sc-if value="{{ s.modal.toggle }}"><div style="display:flex;align-items:center;gap:12px;min-height:{{ inH }}"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ s.modal.toggle.j }};background:{{ s.modal.toggle.bg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span><span style="color:var(--ui-text-highlighted);font-weight:500">Open in new tab</span></div></sc-if>
<sc-if value="{{ s.modal.removeLink }}"><div style="display:flex;align-items:center;gap:8px;min-height:{{ inH }};color:var(--ui-error);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</div></sc-if>
<sc-if value="{{ s.modal.upload }}">
<div data-act="upload-pick" style="height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;border:{{ up.bd }};background:{{ up.bg }};color:{{ up.fg }}"><span aria-hidden="true" style="display:inline-block;width:24px;height:24px;background-color:{{ up.ic }};-webkit-mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat"></span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ up.title }}</span><sc-if value="{{ up.progress }}"><div style="width:80%;height:4px;background:var(--ui-bg-accented)"><div style="width:{{ up.progress }};height:4px;background:var(--ui-primary)"></div></div></sc-if><span style="color:var(--ui-text-toned);font-size:12px;max-width:300px">{{ up.text }}</span></div>
</sc-if>
<sc-if value="{{ s.modal.crop }}">
<div style="display:flex;flex-direction:column;gap:16px">
<div style="height:{{ cr.stageH }};padding:16px;background:var(--ui-bg-elevated);display:flex;align-items:center;justify-content:center">
<div role="img" aria-label="north-wall.jpg, 4032 × 3024 px" style="position:relative;height:100%;max-width:100%;aspect-ratio:4/3;overflow:hidden;background:#d8d0c3">
<div style="position:absolute;left:0;right:0;bottom:0;height:32%;background:#8f8373"></div>
<div style="position:absolute;left:56%;top:14%;width:22%;height:42%;background:var(--rc-sand);border:6px solid var(--ui-border-accented)"></div>
<div tabindex="0" aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it." style="position:absolute;left:{{ cr.l }};top:{{ cr.t }};width:{{ cr.w }};height:{{ cr.h }};box-shadow:0 0 0 2000px var(--rc-overlay);outline:1px solid var(--ui-bg);cursor:move">
<div style="position:absolute;left:33.333%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;left:66.666%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;top:33.333%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;top:66.666%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;left:-6px;top:-6px;width:12px;height:12px;background:var(--ui-bg);border:1px solid var(--ui-primary)"></div>
<div style="position:absolute;right:-6px;top:-6px;width:12px;height:12px;background:var(--ui-bg);border:1px solid var(--ui-primary)"></div>
<div style="position:absolute;left:-6px;bottom:-6px;width:12px;height:12px;background:var(--ui-bg);border:1px solid var(--ui-primary)"></div>
<div style="position:absolute;right:-6px;bottom:-6px;width:12px;height:12px;background:var(--ui-bg);border:1px solid var(--ui-primary)"></div>
<span style="position:absolute;left:8px;bottom:8px;padding:1px 6px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:400 11px/18px 'IBM Plex Mono',monospace;white-space:nowrap">{{ cr.dims }}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:4px"><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">Aspect ratio</span><span data-act="crop-rotate" style="height:{{ btnH }};padding:0 8px;display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat"></span>Rotate</span><span data-act="crop-reset" style="height:{{ btnH }};padding:0 8px;display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat"></span>Reset</span></div>
<div role="radiogroup" aria-label="Aspect ratio" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
<sc-for list="{{ cr.ratios }}" as="p" hint-placeholder-count="5"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<span style="font-weight:500;color:var(--ui-text-highlighted)">Width</span>
<div role="radiogroup" aria-label="Width" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
<sc-for list="{{ cr.sizes }}" as="p" hint-placeholder-count="4"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
</div>
<span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ cr.out }}</span>
</div>
<sc-if value="{{ cr.note }}"><div role="status" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft)"><span style="display:flex;padding-top:2px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ cr.note }}</span></div></sc-if>
</div>
</sc-if>
</div>
<div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px">
<sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if>
<sc-if value="{{ s.modal.confirm }}"><span data-act="modal-confirm" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ mConfirmBg }};color:var(--ui-bg);font-weight:500;opacity:{{ mConfirmOp }};text-align:center"><sc-if value="{{ s.modal.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ s.modal.confirm }}</span></sc-if>
</div>
</div>
</div>
</sc-if>
{{ purgeDlg }}
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span stLine truncated
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
<sc-if value="{{ integSearch }}"><dc-import name="IntegrationSearch" s="{{ s.search }}" mobile="{{ mob }}" hint-size="100%,100%"></dc-import></sc-if>
{{ slotOverlays }}
<sc-if value="{{ hasToasts }}">
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
<sc-for list="{{ toasts }}" as="t" hint-placeholder-count="1">
<div style="display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;margin-top:2px;width:16px;height:16px;background-color:{{ t.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat"></span><div style="flex:1;display:flex;flex-direction:column;gap:6px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ t.title }}</span><span style="color:var(--ui-text-muted)">{{ t.text }}</span></div><sc-if value="{{ t.action }}"><span data-act="retry" style="align-self:flex-start;height:28px;padding:0 10px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>{{ t.action }}</span></sc-if></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></div>
</sc-for>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;slotBody&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;ReactNode&quot;},&quot;slotOverlays&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;ReactNode&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}
const m = !!this.props.mobile
const k = s.kind || 'home'
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; const q = cr0[cr0.length - 1] === 'Languages'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }, { t: 'Languages', ic: 'languages', bg: q ? 'var(--rc-sand)' : 'transparent', fg: q ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: q ? 500 : 400 }] })() : [], hasAccess: s.role !== 'user', subs3: s.role === 'user' ? [] : [(() => { const o = cr0[1] === 'Roles'; return { t: 'Roles', ic: 'shield', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } })()], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
const L = s.list || {}
const counts = L.counts || [6, 4, 2]
const tabs = ['All', 'Online', 'Drafts'].map((t, i) => { const on = (L.tab || 0) === i; return { t, n: counts[i], line: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
const AVT = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'var(--ui-bg-elevated)'], ini2 = n => n.replace(/^Dr\.\s*/, '').split(/\s+/).slice(0, 2).map(x => x[0]).join('').toUpperCase()
const auInfo = a => { if (a.skel) return {}; const au = a.au || []; return { hasAu: au.length > 0, noAu: au.length === 0, avs: au.slice(0, 3).map((n, i) => ({ i: ini2(n), bg: AVT[(n.length + i) % 3], ml: i ? '-6px' : '0' })), auMore: au.length > 3 ? '+' + (au.length - 3) : '', auNames: au.join(', '), auFirst: au.length ? au[0] + (au.length > 1 ? ' +' + (au.length - 1) : '') : 'No authors', site: a.site || '' } }
const rows = (L.loading ? Array.from({ length: 6 }, () => ({ skel: true })) : (L.rows || [])).map(a => ({ ...a, seoSug: a.seo > 0, seoN: a.seo, seoTitle: a.seo + (a.seo === 1 ? ' suggestion' : ' suggestions'), seoHid: a.seo === 'hidden', showOn: !a.busy && a.on, showDraft: !a.busy && a.t && !a.on, op: a.busy ? '0.55' : '1', bg: a.menu ? 'var(--ui-bg-elevated)' : 'transparent', menuBg: a.menu ? 'var(--ui-bg-accented)' : 'transparent', menuPub: a.on ? 'Unpublish' : 'Publish', menuIcon: a.on ? 'globe-lock' : 'globe', ...auInfo(a) }))
const e = s.ed || {}
const saveMap = { clean: ['check', 'Saved', 'var(--ui-text-muted)'], dirty: ['circle-dot', 'Unsaved changes', 'var(--ui-warning)'], saving: ['loader-circle', 'Saving…', 'var(--ui-text-muted)'], saved: ['check', 'Saved at 11:02', 'var(--ui-text-muted)'], error: ['circle-alert', 'Save failed', 'var(--ui-error)'], offline: ['wifi-off', 'Waiting for connection', 'var(--ui-text-muted)'] }
const sv = saveMap[e.save || 'saved']
const save = { icon: sv[0], t: sv[1], fg: sv[2], retry: e.save === 'error' }
const len = e.len ?? 4318
const fmt = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const counter = { t: fmt(Math.round(len / 6)) + ' words · ' + fmt(len) + ' characters', fg: 'var(--ui-text-muted)', icon: '', msg: e.note || '' }
const dirty = e.save === 'dirty' || e.save === 'error'
const online = e.status === 'online'
const blocked = !!s.offline
let pub
if (e.pending) pub = { t: e.pending, icon: 'loader-circle', bg: online ? 'var(--ui-bg)' : 'var(--ui-primary)', fg: online ? 'var(--ui-text-highlighted)' : 'var(--ui-bg)', bd: online ? 'var(--ui-border-accented)' : 'var(--ui-primary)', op: '1' }
else if (online) pub = { t: 'Unpublish', icon: 'globe-lock', bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: 'var(--ui-border-accented)', op: s.offline ? '0.4' : '1' }
else pub = { t: dirty ? 'Save and publish' : 'Publish', icon: 'globe', bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: 'var(--ui-primary)', op: blocked ? '0.4' : '1' }
const md = s.modal || {}
const mFields = (md.fields || []).map(f => { const has = f.value; return { ...f, shown: has || f.ph || '', fg: md.loading ? 'var(--ui-text-dimmed)' : has ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', bd: f.err ? 'var(--ui-error)' : f.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)', sh: f.err ? 'inset 0 0 0 1px var(--ui-error)' : f.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none', bg: md.loading ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', h: f.area ? (m ? '120px' : '96px') : (m ? '44px' : '40px'), p: f.area ? '8px 10px' : '0 10px', ai: f.area ? 'flex-start' : 'center', countFg: f.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)' } })
const U = md.upload || {}
const upMap = {
uploading: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'north-wall.jpg', text: 'Uploading 180 of 420 KB', progress: '43%', bd: '1px solid var(--ui-border)', bg: 'var(--ui-bg)' },
rejected: { icon: 'file-x', ic: 'var(--ui-error)', title: 'north-wall.tif is 14.2 MB', text: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' },
idle: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'Drop an image here, or choose a file', text: 'PNG, JPEG, WEBP, SVG · max 10 MB', bd: '1px dashed var(--ui-border-accented)', bg: 'var(--ui-bg)' },
error: { icon: 'circle-x', ic: 'var(--ui-error)', title: 'Upload failed', text: 'The server didn\'t accept the file. Your article hasn\'t changed.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' }
}
const up = upMap[U.state || 'idle'] || upMap.idle
let cr = {}
if (md.crop) {
const C = md.crop, IW = 4032, IH = 3024
const ri = C.ratio || 0, si = C.size ?? 1
const presets = [[0, 0, 100, 100], [6, 8, 88, 78], [0, 12.5, 100, 75], [0, 0, 100, 100], [12.5, 0, 75, 100]]
const r = C.rect || presets[ri]
const cw = Math.round(IW * r[2] / 100), ch = Math.round(IH * r[3] / 100)
const target = [cw, 1600, 1200, 800][si]
const ow = Math.min(target, cw), oh = Math.round(ch * ow / cw)
const kb = Math.round(ow * oh * 0.22 / 1000)
const pill = (pre, cur) => (t, i) => ({ t, on: i === cur, act: pre + i, bg: i === cur ? 'var(--ui-primary)' : 'transparent', fg: i === cur ? 'var(--ui-bg)' : 'var(--ui-text-toned)' })
cr = {
l: r[0] + '%', t: r[1] + '%', w: r[2] + '%', h: r[3] + '%', dims: fmt(cw) + ' × ' + fmt(ch),
ratios: ['Original', 'Free', '16:9', '4:3', '1:1'].map(pill('crop-r-', ri)),
sizes: ['Original', '1600 px', '1200 px', '800 px'].map(pill('crop-s-', si)),
out: 'Uploads as ' + fmt(ow) + ' × ' + fmt(oh) + ' px · about ' + (kb >= 1000 ? (kb / 1000).toFixed(1) + ' MB' : kb + ' KB'),
note: target > cw ? 'The crop is ' + cw + ' px wide, so it\'s uploaded at that size. Enlarging it would make it blurry.' : '',
stageH: m ? '240px' : '340px', pillFlex: m ? '1 1 0' : '0 0 auto', groupW: m ? '100%' : 'max-content'
}
}
const US = s.users || {}
const badges = { accepted: { t: 'Accepted', icon: 'circle-check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'var(--rc-success-soft)' }, pending: { t: 'Pending', icon: 'mail', bg: 'var(--rc-warning-soft)', fg: 'var(--ui-warning)', bd: 'var(--rc-warning-soft)' }, expired: { t: 'Expired', icon: 'clock-alert', bg: 'transparent', fg: 'var(--ui-text-muted)', bd: 'var(--ui-border-accented)' } }
const roles = { owner: { t: 'owner', bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: 'var(--ui-primary)' }, admin: { t: 'admin', bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)' } }
const acts = { pending: { key: 'cancel-invite', t: 'Cancel invite', icon: 'user-x', fg: 'var(--ui-error)', op: '1' }, expired: { key: 'renew-invite', t: 'Renew invite', icon: 'refresh-cw', fg: 'var(--ui-text-highlighted)', op: '1' } }
const usRows = (US.loading ? Array.from({ length: 5 }, () => ({ skel: true })) : (US.rows || [])).map(r => { if (r.skel) return r; const dn = (r.you && s.display) || r.display || ''; const a0 = acts[r.st]; return { ...r, dn: dn || '—', dfg: dn ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', display2: !!dn, fg: 'var(--ui-text-highlighted)', rl: roles[r.role] || roles.admin, b: badges[r.st] || badges.pending, bg: r.hl ? 'var(--ui-bg-elevated)' : 'transparent', act: a0 && (r.busy ? { ...a0, t: r.busy, icon: 'loader-circle', op: '0.5' } : a0) } })
const who = s.display || 'marta'
const ini = s.display ? s.display.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase() : 'MA'
const pf = s.pf || {}
const dnVal = pf.value ?? s.display ?? ''
const dnLen = [...dnVal].length
const dn = { shown: dnVal || (pf.focus ? '' : 'e.g. Marta Alves'), caret: !!pf.focus, fg: pf.saving ? 'var(--ui-text-dimmed)' : dnVal ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', bd: pf.err ? 'var(--ui-error)' : pf.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)', sh: pf.err ? 'inset 0 0 0 1px var(--ui-error)' : pf.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none', bg: pf.saving ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', count: dnLen + ' / 60', countFg: dnLen > 60 ? 'var(--ui-error)' : 'var(--ui-text-muted)', err: pf.err || '', foot: pf.dirty && !pf.saving ? 'Unsaved changes' : 'You appear as \u201c' + who + '\u201d' + (s.display ? '.' : ', your username.'), footFg: pf.dirty && !pf.saving ? 'var(--ui-warning)' : 'var(--ui-text-muted)' }
const dnSave = { t: pf.saving ? 'Saving…' : 'Save', loading: !!pf.saving, op: pf.saving || (pf.dirty && !pf.err) ? '1' : '0.4' }
const rs = pf.reset || 'idle'
const rsMap = { sent: { bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', icon: 'mail-check', title: 'Reset link sent', text: 'Check marta@renew.example. The link works once, within 1 hour.' }, limited: { bg: 'var(--rc-warning-soft)', fg: 'var(--ui-warning)', icon: 'clock-alert', title: 'Too many requests', text: 'You\'ve asked for several links recently. Wait a few minutes, then try again.' }, error: { bg: 'var(--rc-error-soft)', fg: 'var(--ui-error)', icon: 'circle-x', title: 'Couldn\'t send the link', text: 'Check your connection and try again.' } }
const rsNote = rsMap[rs] || null
const rsBtn = { t: rs === 'sending' ? 'Sending…' : rs === 'sent' ? 'Send again' : 'Send reset link', icon: rs === 'sending' ? 'loader-circle' : 'mail', op: rs === 'limited' ? '0.4' : '1' }
const E5 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Not signed in', 'Failed login attempt for ', 'alex@example.com', '1 h ago', 0], ['Jonas Weber', 'Revoked API key ', 'Mobile app', '2 h ago', 1], ['Scheduler', 'Ran the nightly backup', '', '7 h ago', 1]]
const E4 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Marta Alves', 'Deleted the image ', 'old-banner.png', 'Yesterday', 1], ['Marta Alves', 'Uploaded the image ', 'floorboards-copy.jpg', '3 Oct 2026', 1]]
const ho = s.home || {}, own = s.role === 'user'
const actRows = ho.act === 'loading' ? Array.from({ length: 5 }, () => ({ skel: true })) : ho.act === 'ok' ? (own ? E4 : E5).map(([a, t, em, rel, ok]) => ({ s: t, em, meta: own ? rel : a + ' · ' + rel, fail: !ok, ini: a === 'Scheduler' ? 'SY' : a === 'Not signed in' ? '?' : a.split(' ').map(w => w[0]).join('') })) : null
const act = { show: !!ho.act && k === 'home' && !s.boot, title: own ? 'Your recent activity' : 'Recent activity', rows: actRows, error: ho.act === 'error', empty: ho.act === 'empty', emptyText: own ? 'Your actions will appear here.' : 'Actions on your site will appear here.' }
const C = s.cleanup || {}, cOn = !!C.on, cDis = !!(C.ro || C.offline || C.loading), cErr = !!(C.invalid || C.saveErr)
const cl = { loading: !!C.loading, ready: !C.loading, ro: !!C.ro, notRo: !C.ro, on: cOn, saving: !!C.saving, saved: !!C.saved && !C.saving, swW: m ? '44px' : '36px', swH: m ? '44px' : '24px', swOp: cDis ? '0.4' : '1', swRing: C.focusSw ? '2px solid var(--ui-primary)' : '0', swBd: cOn ? 'var(--ui-primary)' : 'var(--ui-border-accented)', swBg: cOn ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', knobL: cOn ? '17px' : '1px', knobBd: cOn ? 'transparent' : 'var(--ui-border-accented)', daysW: m ? '100%' : '140px', daysAlign: m ? 'stretch' : 'flex-end', daysBd: cErr ? 'var(--ui-error)' : C.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)', daysSh: C.focusDays && !cErr ? '0 0 0 1px var(--ui-primary)' : 'none', daysOp: cDis ? '0.4' : '1', daysVal: C.days ?? '30', hasErr: cErr, errTxt: C.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.', errMax: m ? 'none' : '300px', errTA: m ? 'left' : 'right', lastLine: C.last ? 'Last purge: ' + C.last.d + ' · ' + C.last.c + ' copies merged · ' + C.last.a + ' articles updated' : 'Never run', nextLine: 'Next purge: ' + (C.next || '14 Jul 2026'), purgeOp: C.offline || C.loading ? '0.4' : '1', btnShrink: m ? '1' : '0' }
const R = s.ret || {}, rd = R.days || '90', fv = rd === '0', sz = R.size || '0', hs = sz !== '0'
const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? (hs ? 'No age limit. Only the size limit applies.' : 'Nothing is removed automatically. The log grows over time.') : (hs ? 'Events older than this are removed.' : 'Older events are removed automatically.'), last: fv && !hs ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv && !hs ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', sizeVal: hs ? sz + ' GB' : 'No size limit', sizeHelp: hs ? 'When logs pass ' + sz + ' GB, the oldest events are removed. Currently 3.2 GB.' : 'Optional. Logs can grow without a size cap.', selW: m ? '100%' : '160px' }
const purgeDlg = (() => {
if (!s.purge) return null
const h = React.createElement, bh = m ? 44 : 32
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label)
}
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
const mark = h('svg', { width: 20, height: 20, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
const Th = (im, t = '') => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55'))
const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } }
const Alert = (tone, icon, text, title) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text)))
const hl = 'color:var(--ui-text-highlighted)'
const mono = "font-family:'IBM Plex Mono',monospace"
const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)')
// ---------- dialog
const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }]
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.purge && s.purge.pv) }
const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated'
const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups'))
const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))
const dg = s.purge
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px',
D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
o.center ? D('padding:28px 24px 0;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc))
: D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)),
body && D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', ...body),
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm))))
const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.'
const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))]
const Dialog = () => {
const k = dg.k, pv = PV
const gp = 'Purge ' + pl(pv.groups, 'group')
const note = (tone, icon, t) => Alert(tone, icon, t)
if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel())
if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null)
const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()]
if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body)
if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body)
if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body])
if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body])
if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body])
}
return Dialog()
})()
const toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
const endpoints = [
{ path: '/v1/article/', desc: 'Lists every online article.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/')\nconst articles = await res.json()" },
{ path: '/v1/article/{id}', desc: 'Returns one online article. Drafts and unknown ids both return 404.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()" }
]
const toolbarDef = [['undo-2'], ['redo-2'], '|', ['pilcrow', 'Paragraph ▾'], '|', ['bold', '', true], ['italic'], ['underline'], ['strikethrough'], '|', ['align-left', '', true], ['align-center'], ['align-right'], ['align-justify'], '|', ['list'], ['list-ordered'], ['indent-decrease'], ['indent-increase'], '|', ['link'], ['remove-formatting'], ['image-plus', 'Image']]
const LK = e.lk || {}
const lbs = LK.btn || 'default'
const toolbar = toolbarDef.map(x => { if (x === '|') return { sep: true, btn: false }; const base = { sep: false, btn: true, icon: x[0], text: x[1] || '', bg: x[2] ? 'var(--ui-bg-accented)' : 'transparent', fg: 'var(--ui-text-toned)', op: '1', ring: 'none', tip: '' }; if (x[0] !== 'link') return base; return { ...base, bg: lbs === 'hover' ? 'var(--ui-bg-elevated)' : lbs === 'active' ? 'var(--rc-sand)' : 'transparent', fg: lbs === 'hover' ? 'var(--ui-text-highlighted)' : lbs === 'active' ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', op: lbs === 'disabled' ? '0.4' : '1', ring: lbs === 'focus' ? '2px solid var(--ui-primary)' : 'none', tip: lbs === 'hover' && !m ? 'Insert link (Ctrl+K)' : '' } })
const lkT = LK.text || 'default'
const lk = { on: !!LK.on, pop: !!LK.pop && !m, sheet: !!LK.pop && m, popEdit: 'transparent', style: 'color:' + (lkT === 'selected' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';text-decoration:underline;text-decoration-thickness:' + (lkT === 'hover' ? '2px' : '1px') + ';text-underline-offset:3px;' + (lkT === 'selected' ? 'background:var(--rc-sand);' : '') + (lkT === 'focus' ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : '') }
const LG = e.lang || null
if (LG && !e.pending && pub) pub = { ...pub, t: pub.t.replace('Save and publish', 'Save and publish ' + LG.name).replace(/^Publish$/, 'Publish ' + LG.name).replace(/^Unpublish$/, 'Unpublish ' + LG.name) }
const lg = LG ? { name: LG.name, code: LG.code, open: !!LG.open, ro: !!LG.ro, fresh: LG.fresh || '', bd: LG.open ? 'var(--ui-primary)' : 'var(--ui-border-accented)', h: m ? '44px' : '26px', label: 'Language: ' + LG.name + '. Switch version' } : null
const edReady = k === 'editor' && !e.loading && !e.notfound
const edTitle = e.title || 'How we price a bathroom renovation'
return {
lg, srcPane: this.props.srcPane || null, edBanner: this.props.edBanner || null, edTitleCss: LG ? 'flex:0 1 100%;min-width:0' : '', edTitleNote: LG ? 'Each language has its own title. Changing it doesn\'t change the URL; edit the slug in Search and sharing.' : "Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.", edTitleTip: LG ? 'Titles can be changed. The URL only changes if you edit the slug.' : "The title can't be changed after creation.", delText: LG ? 'Delete ' + LG.name + ' version' : 'Delete',
lk, edSeo: this.props.edSeo || null, seoOpen: !!this.props.edSeo, seoClosed: !this.props.edSeo, seoRot: this.props.edSeo ? '0deg' : '-90deg', seoSummary: e.seoSummary || 'Ready', seoFg: /suggest/.test(e.seoSummary || '') ? 'var(--ui-warning)' : 'var(--ui-text-muted)', naOn: !!L.needs, naBg: L.needs ? 'var(--rc-sand)' : 'var(--ui-bg)', naFg: L.needs ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', naBd: L.needs ? 'var(--rc-sand)' : 'var(--ui-border-accented)', naFw: L.needs ? 500 : 400, naN: L.naState === 'pending' ? '…' : L.naState === 'fail' ? '!' : L.naState === 'absent' ? '' : String(L.needsN ?? 3), edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1, uses: q.user ? 'Uses your username, marta. You can change it afterwards.' : 'Uses your display name, Marta Alves. You can change it afterwards.' } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 150px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: '16px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu',
navDrawer: m && !!s.drawer, integSearch: k === 'integration' && !!s.search, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
pad: m ? '16px 16px 88px' : '24px', gap: '16px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
tbDir: m ? 'column' : 'row', tbGap: m ? '12px' : '16px', tbBorder: m ? '0' : '1px solid var(--ui-border)', tabBorder: m ? '1px solid var(--ui-border)' : '0', tabH: m ? '44px' : '36px', tabF: m ? '14px' : '13px',
qText: L.q ? L.q : 'Search by title', qFg: L.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)',
pgBtn: m ? '44px' : '32px', ...(() => {
const g = L.pg
if (!g) return { pager: null, pagerD: false, pagerM: false, pgErr: '', pgNote: '' }
const mk = (t, cur) => ({ t: String(t), cur: cur ? 'page' : 'false', bg: cur ? 'var(--rc-sand)' : 'var(--ui-bg)', fg: cur ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)', bd: cur ? 'var(--ui-primary)' : 'var(--ui-border-accented)', fw: cur ? 600 : 400, ring: g.focus === 'p' + t ? '2px solid var(--ui-primary)' : 'none' })
const set = new Set([1, g.pages, g.page - 1, g.page, g.page + 1].filter(n => n >= 1 && n <= g.pages))
const arr = g.pages <= 7 ? Array.from({ length: g.pages }, (_, i) => i + 1) : [...set].sort((x, y) => x - y)
const nums = []
arr.forEach((n, i) => { if (i && n - arr[i - 1] > 1) nums.push({ t: '…', cur: 'false', bg: 'transparent', fg: 'var(--ui-text-muted)', bd: 'transparent', fw: 400, ring: 'none' }); nums.push(mk(n, n === g.page)) })
const from = g.total ? (g.page - 1) * g.per + 1 : 0, to = Math.min(g.page * g.per, g.total)
const busy = !!g.loading
const fmtN = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const sizes = [5, 25, 50, 100].map(n => ({ t: n + ' per page', on: n === g.per, bg: n === g.per ? 'var(--rc-sand)' : 'transparent', fg: n === g.per ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)', fw: n === g.per ? 600 : 400 }))
const pager = { summary: busy ? 'Loading page ' + g.loading + '…' : 'Showing ' + from + '–' + to + ' of ' + fmtN(g.total), size: g.per, sizes, sizeOpen: !!g.sizeOpen, sizeRing: g.focus === 'size' ? '2px solid var(--ui-primary)' : 'none', ctl: g.pages > 1, nums, of: 'Page ' + g.page + ' of ' + g.pages, backOp: busy || g.page === 1 ? '0.4' : '1', fwdOp: busy || g.page === g.pages ? '0.4' : '1', prevRing: g.focus === 'prev' ? '2px solid var(--ui-primary)' : 'none', nextRing: g.focus === 'next' ? '2px solid var(--ui-primary)' : 'none' }
return { pager, pagerD: !m, pagerM: m, pgErr: g.err || '', pgNote: g.note || '' }
})(),
showTable: !m && !L.empty, showCards: m && !L.empty, listFab: !s.modal,
emptyBg: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-bg)', emptyFg: L.empty && L.empty.primary ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', emptyBd: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-border-accented)',
edFound: k === 'editor' && !e.notfound, edReady, edPending: edReady && !!e.pending, edOnline: edReady && !e.pending && online, edDraft: edReady && !e.pending && !online,
edHeadPad: m ? '12px 16px' : '14px 24px', edHeadGap: m ? '8px 12px' : '12px', edTitleBasis: m ? '100%' : '200px',
showToolbar: k === 'editor' && !e.notfound && !e.preview,
tbPadX: m ? '8px' : '24px', tbBtn: m ? '40px' : '28px',
edBg: e.preview ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', edPad: m ? '24px 16px' : '40px 48px', edMargin: e.preview ? '0 auto' : '0',
h1F: m ? '28px' : '32px', h2F: m ? '23px' : '26px', pF: m ? '17px' : '18px', caret: !e.preview,
pvJust: e.preview ? 'flex-end' : 'flex-start', pvBg: e.preview ? 'var(--ui-primary)' : 'var(--ui-bg-accented)',
saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.',
copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)',
errPad: m ? '40px 20px' : '64px 48px',
closeSz: m ? '44px' : '28px', modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '0' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row', mConfirmOp: md.disabled ? '0.4' : '1', mList: (md.list || []).map(t => ({ t })), mWarn: md.warn ? { ...md.warn, bg: 'var(--rc-' + md.warn.tone + '-soft)', fg: 'var(--ui-' + md.warn.tone + ')' } : null, pfShift: pf.shift ? '-' + (m ? pf.shift[1] : pf.shift[0]) + 'px' : '0px', pfAc: pf.ac || {}, pfC: s.copy || null, pfPanel: !!(pf.ac && (pf.ac.form || pf.ac.pending)),
mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)',
hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px'
}
}
}
</script>
</body>
</html>
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="./locale-fixtures.js"></script>
<script src="./locale-spec.js"></script>
<meta name="design_doc_mode" content="canvas">
<link rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Back-office screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-on-sand:#111111;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: article localization</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">One story, several language versions. Proposed feature: every API change is labelled as proposed. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode. Imported localization reference. Product and API corrections are in docs/design/localization.md. Rendered parity and prototype flows remain unverified.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
</sc-for>
</div>
</div>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="7">
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
</div>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:{{ st.dw }};height:{{ st.dh }};overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:{{ st.fw }};height:{{ st.fh }};zoom:{{ st.z }}"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
</div>
</div>
</sc-for>
</div>
</section>
</sc-for>
<sc-for list="{{ tables }}" as="t" hint-placeholder-count="3"><section style="display:flex;flex-direction:column;gap:10px;max-width:1400px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ t.title }}</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:{{ t.w }};gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div><sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:{{ t.w }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r }}" as="c" hint-placeholder-count="3"><span style="color:var(--ui-text-highlighted);text-wrap:pretty;min-width:0">{{ c }}</span></sc-for></div></sc-for></div></section></sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light' }
rootRef = React.createRef()
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
resolved() {
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
return this.state.mode
}
apply() {
const el = this.rootRef.current
if (!el) return
const vars = this.resolved() === 'dark' ? this.dark : this.light
for (const k in vars) el.style.setProperty(k, vars[k])
document.body.style.background = vars['--rc-canvas']
}
componentDidMount() { this.apply() }
componentDidUpdate(p) {
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
this.apply()
}
groups() { return window.LOC.groups.map(g => ({ ...g, label: g.id + ' ' + g.title, states: g.states.map(a => ({ name: a[0], trigger: a[1], dw: a[4]==='narrow'?'717px':'720px', dh: a[4]==='narrow'?'538px':'450px', fw: a[4]==='narrow'?'1024px':'1440px', fh: a[4]==='narrow'?'768px':'900px', z: a[4]==='narrow'?0.7:0.5, s: { v: a[2], x: a[3] }, nar: a[4] === 'narrow' })) })) }
renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
const on = this.state.mode === k
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
})
return { rootRef: this.rootRef, modes, groups: this.groups(), tables: window.LOC.tables }
}
}
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+4
View File
@@ -187,3 +187,7 @@ States and copy in screens/account.md. Board: RenewCMS Screens Account.dc.html (
| 500, network | existing 500 copy, or "Couldn't delete your account. Nothing was deleted. Try again in a moment." |
| 409 last_admin | "You can't delete your account yet" (non-dismissable dialog) |
| 409 email_unavailable | Landing only: "We couldn't change your email address" (same for in use and rejected) |
## Localization target state inventory
The twelve `screens/localization-*.md` files enumerate 109 target states with stable `loc.*` IDs and board selectors. See [localization.md](localization.md) for precedence, lifecycle and verification limitations. A source inventory is not a passing visual result. Each SPA slice must add mocks using its actual board fixture, then pass the existing visual gate. Narrow desktop support is additional to the standard desktop/mobile captures.