diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..e88f362 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,39 @@ +# RenewCMS design + +The visual spec of record for the back-office SPA and the transactional emails, exported from Claude Design. `features/` stays the behavioural spec; if the two disagree, stop and ask. Agents read this folder and never edit it; a design change is a new export. + +## Files + +| File | What it holds | +|---|---| +| `routes.md` | Every route: layout, auth requirement, page component, API calls | +| `components.md` | The component inventory with its Nuxt UI mapping, props, emits and slots, then the full component and primitive specs | +| `states.md` | Every state of every screen with its trigger, and the HTTP-status-to-copy table | +| `screens/.md` | One file per screen: route, API, and per state the exact copy, fields, alerts, dialogs and toasts | +| `foundations.md` | Colors, contrast, type, spacing, motion and icons, and where the token files are | +| `emails.md` | The two email templates and their variables | +| `source/` | The raw Claude Design export. Open the `.dc.html` boards in a browser for the drawings (they load `support.js` locally and fonts from Google). | + +## Screens + +| Screen | Route | States | Board | +|---|---|---|---| +| [Log in](screens/log-in.md) | `/login` | 9 | `source/RenewCMS Screens Auth.dc.html` | +| [Register](screens/register.md) | `/register` | 6 | `source/RenewCMS Screens Auth.dc.html` | +| [Pending](screens/pending.md) | `/register/pending` | 6 | `source/RenewCMS Screens Auth.dc.html` | +| [Verify](screens/verify.md) | `/register/validate?c=CODE` | 6 | `source/RenewCMS Screens Auth.dc.html` | +| [Forgot password](screens/forgot-password.md) | `/register/reset/request` | 6 | `source/RenewCMS Screens Auth.dc.html` | +| [Reset password](screens/reset-password.md) | `/register/reset/validate?c=CODE&email=EMAIL` | 7 | `source/RenewCMS Screens Auth.dc.html` | +| [Boot](screens/boot.md) | `any route, first load` | 1 | `source/RenewCMS Screens Back-office.dc.html` | +| [Home](screens/home.md) | `/home` | 4 | `source/RenewCMS Screens Back-office.dc.html` | +| [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` | +| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` | +| [Editor](screens/editor.md) | `/article/:id/edit` | 19 | `source/RenewCMS Screens Back-office.dc.html` | +| [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` | +| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` | + +## Other boards + +- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit). +- `source/RenewCMS Handoff.dc.html`: the handoff page the markdown here was generated from. +- `source/RenewCMS Directions.dc.html`: the three brand directions (history only). diff --git a/docs/design/components.md b/docs/design/components.md new file mode 100644 index 0000000..ba2e6cd --- /dev/null +++ b/docs/design/components.md @@ -0,0 +1,669 @@ +# Components + +## Inventory + +| Component | Nuxt UI base | Props | Emits | Slots | Used on | +|---|---|---|---|---|---| +| AuthShell | UPageCard, UColorModeSelect | title, description? | none | default, notice, footer | All public routes | +| DashboardShell | UDashboardGroup, UDashboardSidebar, UNavigationMenu, UDashboardPanel, UDashboardNavbar, UBreadcrumb | none (reads route.meta and the session store) | none | default, navbar-right | All verified routes | +| UserMenu | UDropdownMenu, UButton, UAvatar | none | logout | none | DashboardShell | +| PageHeader | UPageHeader | title, description? | none | actions, meta | Home, Articles, Integration | +| ArticleStatusBadge | UBadge | online: boolean, size?: 'sm'\|'md', pending?: 'publish'\|'unpublish' | none | none | Home, Articles, Editor | +| ArticleTable | UTabs, UInput, UTable, UDropdownMenu, UPagination | articles: Article[], loading, busyIds: Set | publish, unpublish, delete, copy-url | empty, toolbar-right | Articles (md and up) | +| ArticleCardList | ULink, UDropdownMenu (custom list) | same as ArticleTable | same as ArticleTable | empty | Articles (below md) | +| EmptyState | UEmpty | variant: 'no-articles'\|'no-results'\|'no-drafts'\|'no-online', query? | action | none | Home, Articles | +| ConfirmDialog | UModal via useOverlay | title, description, confirmLabel, pendingLabel, tone: 'danger'\|'neutral', action: () => Promise | close(confirmed) | none | Articles, Editor, Pending | +| NewArticleModal | UModal, UForm, UFormField, UInput, UTextarea | none | created(article) | none | Home, Articles, /article/new | +| ArticleEditor | UEditor, UEditorToolbar, UDropdownMenu (+ custom Indent extension) | modelValue: string, articleId: number, disabled? | update:modelValue, request-image | none | Editor | +| ImageUploader | UModal, UFileUpload, UProgress | articleId: number | uploaded(location), close | none | Editor | +| SaveStatus | custom (span + UButton) | state: 'clean'\|'dirty'\|'saving'\|'saved'\|'error'\|'offline', savedAt?: Date | retry | none | Editor | +| PublishControl | UButton | online: boolean, dirty: boolean, pending?: string, disabled? | publish, unpublish | none | Editor | +| ArticleDetails | UCard (desktop) / USlideover (mobile) | article: Article | copy-url | none | Editor | +| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register | +| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset | +| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Register (403) | +| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration | +| EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration | +| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary | +| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell | +| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route | + +Composables behind these components: `useSession`, `useApi`, `useArticles` (the list cache plus actions shared by the table, cards and editor), `useArticleDraft` (dirty state, save, and the sessionStorage mirror), `useImageUpload` (XHR with progress) and `useRetryAfter` (429 countdown). + +## Product component specs + +Extracted from `source/RenewCMS Components Product.dc.html`; open it for the drawings. + +## Components: product + +These are the RenewCMS components, built from the primitives on the first page . Each one lists the Nuxt UI parts it's made from, its props, emits and slots, every state, and keyboard and responsive behavior. Components marked custom have no Nuxt UI equivalent. + +### AuthShell + +One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task. + +Log in Welcome back. Log in to manage your articles. + +default slot + +Forgot password? System + +Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose. + +Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits. + +A11y The title is the page's h1 , and document.title becomes "Log in · RenewCMS". On route change, focus moves to the h1 . The logo is alt="RenewCMS" and isn't a link, since there's nothing public to go back to. + +Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , the card loses its border and padding drops to 20 px, filling the screen on --ui-bg , and fields and buttons use xl . + +### DashboardShell + +The sidebar has three destinations, and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover. + +RenewCMS + +Home + +Articles + +Integration + +marta Owner + +System + +Light + +Dark + +Log out + +MA marta + +Articles How we price a bathroom renovation + +page body + +MA + +Articles + +Mobile top bar. The menu button opens the sidebar as a left slideover. + +Built from UDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default ( UNavigationMenu orientation="vertical" :collapsed ) and #footer (UserMenu) slots. Each page is a UDashboardPanel , with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb . On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over. + +UserMenu UDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them. + +Props, slots No props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb . Slots navbar-right (page actions on mobile) and default . It shows the offline banner when navigator.onLine is false. + +A11y The first Tab stop is a "Skip to content" link. The sidebar is a nav labeled "Main", and the current page gets aria-current="page" . In the collapsed rail, items show tooltips and keep their aria-label . The collapse button carries aria-expanded . The mobile slideover traps focus and closes after navigating. + +Bend The active nav item uses a sand fill with ink text instead of Nuxt UI's primary-tinted background and left indicator. The shell is set up for this in ui.config.ts . + +### PageHeader + +Articles Everything you've written, online and in draft. + +Connect your website New article + +Articles Everything you've written, online and in draft. + +New article + +Built from UPageHeader :title :description , with actions in the #links slot. The ui override is { root: 'py-0 border-0', title: 'text-xl', description: 'text-sm text-muted mt-0.5' } , which brings Nuxt UI's marketing-size header down to dashboard density. + +Props, slots title , description? , slot actions (the primary action goes last, so it sits on the right), slot meta (for example, a status badge next to the title in the editor). + +Responsive Below md , the actions wrap under the text. The primary action goes full width at 44 px, and secondary actions move into the navbar's overflow menu. + +### ArticleStatusBadge + +Online + +Draft + +Online + +Draft + +Publishing… + +Unpublishing… + +Built from UBadge . Online: color="neutral" variant="solid" icon="i-lucide-globe" . Draft: color="neutral" variant="outline" icon="i-lucide-pencil-line" . Status is shown by the fill, the icon and the word, never by color alone. + +Props online: boolean , size?: 'sm' | 'md' (sm in lists, md in the editor header), pending?: 'publish' | 'unpublish' (shows the -ing label with a spinner while the request runs). No emits, because it isn't interactive. + +### ArticleTable + +Search, filter tabs, a sortable table and row actions. Everything happens in the browser on the list it has already loaded. Below md , the table becomes a card list with the same data and actions. + +All 6 Online 4 Drafts 2 + +Search by title + +Title Status Created Updated + +Online Draft + +Built from UTabs + UInput type="search" + UTable (desktop), or a custom ArticleCardList of ULink rows (mobile), switched with useBreakpoints at md . Each row has a UDropdownMenu , and the row actions go through useArticleActions() , which shares one set of calls between the table, the list and the editor. + +Props, emits Props articles: Article[] , loading: boolean , busyIds: Set . Emits publish(id) , unpublish(id) , delete(article) , copy-url(id) . Search, tab and sort state lives in the URL query ( ?q=&status=&sort= ). Slots #empty , #toolbar-right . + +Behavior Search is case-insensitive and ignores accents, and it matches anywhere in the title. It updates on input, debounced 120 ms. Tab counts always reflect the full list. Dates within 7 days are relative ("2 hours ago"); older ones are absolute ("18 Aug 2026"), with the full timestamp in a title attribute. Clicking a row opens the editor, except clicks on the menu. + +States Loading (5 skeleton rows), empty, no results, a filter tab with nothing in it, a row busy, an action failed (toast with Retry, row reverts), and a row vanished (a 404 on any action removes the row and shows an info toast). Row actions are never optimistic: the row updates when the 200 arrives. + +A11y The table has a visually hidden caption, "Articles, sorted by last updated, newest first". Each row's menu button is named "Actions for How we price a bathroom renovation". After a search, a polite live region announces "3 articles". + +### EmptyState + +No articles yet Write your first one. It stays a draft until you publish it. New article + +No articles match "tiles" Check the spelling, or search for a shorter part of the title. Clear search + +No drafts Everything you've written is online. New article + +Nothing is online yet Publish a draft and it will appear on your website. Show drafts + +Built from UEmpty :icon :title :description :actions variant="naked" , placed in the UTable #empty slot or on the page. The icon tile is overridden to rounded-none bg-elevated . + +Props variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' , query?: string . Emits action . Each variant has fixed copy, and the search term is shown in quotes, cut to 40 characters. + +A11y The title is an h2 . "No results" is announced through the table's live region. Clear search returns focus to the search field. + +### ConfirmDialog + +Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone. + +Cancel Delete article + +Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone. + +Couldn't delete the article, so it's still there. Try again in a moment. + +Cancel Deleting… + +Left: default. Right: after a failed attempt (the error appears inline), with the retry running (loading). + +Built from UModal opened through useOverlay().create(ConfirmDialog) . open() returns a promise that resolves once the action succeeds or the dialog is cancelled. The action runs inside the dialog, so errors show there instead of in a toast behind the overlay. + +Props, emits title , description , confirmLabel , pendingLabel , tone: 'danger' | 'neutral' , action: () => Promise . Emits close(confirmed: boolean) . The delete description comes from the title plus images.length , and reads "and its image" for 1 and just the title for 0. + +Other uses Cancel registration: "Cancel your registration?" / "Your account will be deleted. You can register again with the same or a different email." / "Cancel registration" / "Keep my account". Leaving with unsaved changes uses the neutral tone. + +A11y role="alertdialog" . Focus starts on Cancel, and Enter doesn't confirm by default. While the action runs, the dialog can't be dismissed, both buttons are disabled, and aria-busy is set. On success, focus goes to the next row, or the page heading if there isn't one. + +### ArticleEditor, SaveStatus, PublishControl, CharacterCounter + +The editor is made of four components that work together. The header holds the read-only title, status, SaveStatus, Save and PublishControl. A sticky toolbar sits above the writing surface, which uses the content type scale. The counter sits at the foot. + +How we price a bathroom renovation Draft + +Unsaved changes + +Save + +Save and publish + +Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays . + +What goes into the price + +Stripping out and making good + +Plumbing moves, if any + +Details + +Created 12 Mar 2026, 09:14 Updated Today, 11:02 ID 12 Images 3 + +Public API URL /v1/article/12 Returns 404 until the article is online. + +4 318 / 10 000 + +Unsaved changes dirty, Save enabled + +Saving… saving, Save disabled + +Saved at 11:02 saved, Save disabled + +Save failed Retry error, changes kept + +Publish draft, saved + +Unpublish online + +9 240 / 10 000 body ≥ 90% + +10 214 / 10 000 214 characters over. Shorten the text or remove an image. Formatting counts too. over limit, Save disabled + +ArticleEditor UEditor v-model content-type="html" :extensions + UEditorToolbar :editor :items layout="fixed" . TipTap v3 StarterKit already includes Underline and Link. TextAlign handles alignment and Image handles images. Formatting is a UDropdownMenu with Paragraph and Headings 1–4 ( h1 is kept because the public site controls how the title appears). Indent and outdent call sinkListItem / liftListItem in lists. For paragraphs, a small custom Indent extension writes data-indent="1–4" , because TipTap has no paragraph indent. Clear formatting runs unsetAllMarks().clearNodes() . The editor surface gets class="article-content" . + +Props, emits modelValue: string (HTML), articleId: number , disabled? . Emits update:modelValue , request-image . Exposes insertImage(src, alt) and focus() . The toolbar reflects the active state (bold is on at the cursor) using aria-pressed . + +SaveStatus A custom inline span aria-live="polite" , driven by useArticleDraft() : 'clean' | 'dirty' | 'saving' | 'saved' | 'error' . Saved shows the time and switches to "Saved 5 min ago" after an hour. The error state's Retry is a UButton size="xs" variant="outline" . Ctrl/⌘ S saves. Leaving with unsaved changes triggers onBeforeRouteLeave (ConfirmDialog, neutral tone) and beforeunload . There is no autosave , so the owner controls exactly what is sent. The draft is mirrored to sessionStorage so it survives a session expiry. + +PublishControl A single UButton . Draft → primary "Publish"; draft with unsaved changes → "Save and publish" (PATCH body, then PATCH publish; if the save fails, it doesn't publish); online → outline "Unpublish". Unpublishing doesn't save pending changes, so the draft edits stay pending. Toasts: "Article published", with the description "Title" is now online. ; "Article unpublished", It's a draft again and hidden from your website. + +CharacterCounter A custom component in mono type. Props value: number , max: number , min?: number , warnAt?: number (default 0.9). The title uses [...str].length , so accents and emoji count as one, matching Go's validator, which counts runes. The body uses the HTML string length, which is what the API receives. The warning and error states add an icon and text, not just color. A visually hidden live region announces the change only when a threshold is crossed, not on every keystroke. + +Responsive Below lg , the details panel becomes a right slideover, opened with i-lucide-panel-right . Below md , the header wraps: back, title and badge on the first row, then SaveStatus and the counter. Save and Publish move to a sticky bottom bar (44 px). The toolbar scrolls horizontally with fade edges and keeps undo, redo and the image button pinned at the start. + +### ImageUploader + +Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. After a successful upload the image goes in at the cursor and the modal closes. + +Drop an image here Choose file PNG, JPEG, WEBP, SVG · max 10 MB + +idle + +Drop to upload + +dragging over + +north-wall.jpg 62% + +Uploading 2.4 of 3.9 MB + +uploading + +Image added + +success, closes after 600 ms + +"plan.pdf" can't be used Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. Choose another file + +rejected: type or size + +Upload failed The server didn't accept the file. Your article hasn't changed. Try again + +server error + +Built from UModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs" . Uploads go through XMLHttpRequest , because fetch has no upload progress, as FormData with the field name file . The 201 location is inserted with insertImage(location, '') . + +Validation Before uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024 . The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection. + +Props, emits articleId: number . Emits uploaded(location) , close . A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles. + +A11y, touch The drop zone is a labeled button, so Enter opens the file picker. Progress is exposed as role="progressbar" with aria-valuenow , and the result is announced. On touch screens the drop text is hidden and "Choose file" becomes the main action. Dropping a file directly onto the editor surface also opens the uploader with that file. + +### CodeBlock and EndpointCard + +GET /v1/article/{id} + +Returns one article. Drafts return 404 to anonymous callers, the same as an id that doesn't exist. + +fetch curl Response + +JavaScript Copied + +const res = await fetch( 'https://cms.renew.example/v1/article/12' ) + +if (res.status === 404 ) return notFound() + +const article = await res.json() + +CodeBlock This is a custom component, because Nuxt UI's prose code blocks rely on Nuxt Content and don't work in a plain Vue app. Highlighting is done ahead of time with shiki (a dual github-light / github-dark theme remapped to the semantic colors: keywords info, strings success, numbers warning, comments muted). The header shows the language label and a ghost copy UButton , which switches to "Copied" for 2 s through useClipboard . Props code , lang , label? . Long lines scroll horizontally and never wrap. + +EndpointCard UCard › a method UBadge (GET is solid ink) + the path in mono + a description, with UTabs (fetch, curl, Response) showing a CodeBlock each. Props method , path , description , examples: { fetch, curl, response } . Snippets use the current location.origin . + +A11y The copy button is named "Copy JavaScript example", and its result is announced. Code is a focusable pre ( tabindex="0" ) so keyboard users can scroll it. Syntax colors pass 4.5:1 on --ui-bg-elevated in both themes. + +### PasswordStrengthHint + +At least 8 characters not met yet + +At least 8 characters met + +At least 8 characters not met on submit + +Passwords match reset screen only + +Built from A custom ul in the password UFormField 's #help slot. Props rules: { label: string, met: boolean }[] , showErrors: boolean . There is no meter or score, because the API only requires 8 characters. + +A11y The list is the field's aria-describedby . Each item has hidden text reading "met" or "not met". A change is announced once, when the rule becomes met. + +### ResultPanel + +Check your email If an account exists for that email, a password reset link has been sent. The link expires in 1 hour. + +Back to log in + +This link has expired Reset links work once, for 1 hour. Request a new one and use it within the hour. + +Request a new link + +Too many attempts For your security, wait a moment before trying again. You can try again in 0:42 . + +Try again in 0:42 API pending + +Built from A custom component that fills AuthShell's slot: an icon tile, h1 title, description and UButton block size="lg" actions. Props tone: 'success' | 'error' | 'info' , icon , title , description , actions: ButtonProps[] . Slot default for extras such as the inline login on verify. + +A11y When the panel replaces a form, focus moves to its title and the panel is role="status" , or role="alert" for errors. The countdown updates every second visually but is announced only at the start and when it reaches zero. + +### Error pages and offline banner + +You're offline. You can keep writing, but saving and publishing will wait until you're back online. + +404 Page not found The link may be mistyped, or the page may have moved. Go to Home All articles + +Error Something went wrong This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server. Try again Go to Home + +Built from UError isn't used because it's built for Nuxt's error.vue . Instead there's a custom ErrorView (props kind: 'not-found' | 'error' ), rendered inside UDashboardPanel when the user is logged in and inside AuthShell otherwise. The offline banner is a UBanner color="neutral" at --z-banner , shown and hidden with useOnline() . Once the connection returns it switches to "You're back online" for 3 s. + +A11y The error title is the page h1 and gets focus on render. The banner is role="status" , and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip. + +## Themed primitives + +Extracted from `source/RenewCMS Components Primitives.dc.html`. + +## Components: themed primitives + +These are Nuxt UI components with the Ledger config applied. Every specimen reads the real --ui-* variables, so the theme switch on the right changes the whole page. The product components are on a second page . + +### Button + +There are five roles. A screen gets one primary button. Danger is used only for irreversible actions, and always inside a confirmation step. + +New article primary + +Cancel secondary + +Preview ghost + +Delete article danger + +icon-only + +Save sm 28 + +Save md 32 + +Save lg 40 + +Save xl 44, touch + +Publish default + +Publish hover + +Publish focus-visible + +Publish active + +Publish disabled + +Publishing… loading + +Delete article danger hover + +Cancel secondary hover + +Preview ghost hover + +Nuxt UI UButton . Primary color="primary" variant="solid" · secondary color="neutral" variant="outline" · ghost color="neutral" variant="ghost" · danger color="error" variant="solid" · icon-only square icon="i-lucide-ellipsis" :aria-label wrapped in UTooltip . + +Loading :loading swaps the leading icon for i-lucide-loader-circle and disables the button. The label changes to the -ing form ("Publishing…") and the button width stays fixed, so the layout doesn't jump. loading-auto is used on async @click handlers. + +Keyboard, ARIA Native + + + + +
+

Button

+

There are five roles. A screen gets one primary button. Danger is used only for irreversible actions, and always inside a confirmation step.

+
+
+
New articleprimary
+
Cancelsecondary
+
Previewghost
+
Delete articledanger
+
icon-only
+
+
+
Savesm 28
+
Savemd 32
+
Savelg 40
+
Savexl 44, touch
+
+
+
Publishdefault
+
Publishhover
+
Publishfocus-visible
+
Publishactive
+
Publishdisabled
+
Publishing…loading
+
Delete articledanger hover
+
Cancelsecondary hover
+
Previewghost hover
+
+
+
+Nuxt UIUButton. Primary color="primary" variant="solid" · secondary color="neutral" variant="outline" · ghost color="neutral" variant="ghost" · danger color="error" variant="solid" · icon-only square icon="i-lucide-ellipsis" :aria-label wrapped in UTooltip. +Loading:loading swaps the leading icon for i-lucide-loader-circle and disables the button. The label changes to the -ing form ("Publishing…") and the button width stays fixed, so the layout doesn't jump. loading-auto is used on async @click handlers. +Keyboard, ARIANative <button>, activated with Enter or Space. Icon-only buttons need an aria-label. Loading sets aria-busy="true". Disabled buttons stay focusable only when a tooltip explains why they're disabled. +Responsivemd on desktop, xl (44 px) below md. Primary page actions go full width in sticky footers on 390 px screens. +BendDisabled opacity is 40% instead of Nuxt UI's 75%, because a 75% black button still looks clickable. Hover darkens the button one step instead of using Nuxt UI's /75 alpha, so the label keeps its contrast. +
+
+ +
+

Input, password input, textarea

+

One outline style for every field. Focus adds a 2 px ink ring inside the edge. Errors turn the edge brick red and always come with a message, never color alone.

+
+
martadefault, placeholder
+
martahover
+
marfocus
+
maerror
+
owner@renew.exampledisabled / read-only
+
plasterleading icon, clear
+
••••••••••password, hidden
+
limewash2026password, shown
+
A short first paragraph. You can keep writing in the editor.textarea, autoresize 3–8 rows
+
+
+Nuxt UIUInput variant="outline" color="primary", :highlight bound to the field error so the edge turns red. The search field is icon="i-lucide-search" with a #trailing clear UButton. UTextarea autoresize :rows="3" :maxrows="8". +PasswordUInput :type="shown ? 'text' : 'password'" with a #trailing ghost UButton. aria-label reads "Show password" or "Hide password", with aria-pressed and aria-controls. autocomplete is current-password on log in and new-password on register and reset. Showing the password never clears or moves focus. +StatesDefault, hover (edge goes to muted), focus (2 px ink), filled, error, disabled, read-only (same look as disabled but selectable and focusable, used for the email on the reset screen). Fields are 16 px text below md. +PlaceholderPlaceholders use --ui-text-muted (5.7:1) and only show an example. The label always carries the meaning. +
+
+ +
+

Form field

+

A label on top, then the control, then the help text or the error in the same spot. The right-hand hint slot holds the character counter. Every field in the product is required, so none are marked.

+
+
Username5 / 20
marta3 to 20 characters. You'll use it to log in.
+
Username2 / 20
maUse at least 3 characters.
+
Emailmarta@renewEnter a full email address, like name@example.com.
+
+
+Nuxt UIUForm :schema :state @submit + UFormField name label help, with the counter in the #hint slot. Validation runs on blur and on submit, then on every input once a field has an error, so the error clears as soon as it's fixed. The schema mirrors the API rules exactly, so a validator 400 shouldn't reach the UI. +ARIANuxt UI links the label and aria-describedby (help or error) and sets aria-invalid. On a failed submit, focus moves to the first invalid field. Error text sits in the help slot, so there is no layout shift. +
+
+ +
+

Checkbox, switch, select

+

All three are square. The Preview toggle in the editor is the only switch. The select sets sort order in the mobile article list. No screen needs a checkbox yet, but it's themed so it matches if one gets added.

+
+
+Off +On +Focus +Disabled +
+
+Preview off +Preview on +Focus +Disabled +
+
+Newest updated first +
+
Newest updated first
+
Oldest updated first
+
+
+
+
+Nuxt UIUCheckbox, USwitch (slots base and thumb set to rounded-none), USelect :items value-key="value", whose content uses --shadow-popover. +KeyboardSpace toggles a checkbox or switch. The select opens with Enter, Space or ↓, moves with the arrow keys and type-ahead, and closes with Esc, returning focus to the trigger. The switch uses role="switch" and aria-checked, and its visible label is its name. +
+
+ +
+

Badge

+

Badges show status or a count. They never replace a button. Solid ink is for the one state that matters most in a list. Soft semantic badges are for system messages.

+
+Online +Draft +12 +success +info +warning +error +GET +Online · md +
+
+Nuxt UIUBadge. Solid ink color="neutral" variant="solid" · outline color="neutral" variant="outline" · count color="neutral" variant="soft" size="sm" · semantic variant="soft". Sizes are sm (22 px, in tables) and md (26 px, in the editor header). +ARIABadges are text, not live regions. When a status changes, the announcement comes from the toast. +
+
+ +
+

Alert

+

Alerts are for messages that belong to a page and stay until the situation changes. Anything that confirms an action someone just took is a toast instead.

+
+
Your session expiredLog in again to pick up where you left off.
+
Password changedLog in with your new password.
+
Almost at the length limit9 240 of 10 000 characters used. Images and formatting count too.
+
Couldn't load your articlesCheck your connection and try again.
Try again
+
+
+Nuxt UIUAlert variant="soft" :color :icon :title :description :actions. close only on alerts that are safe to dismiss (password changed, session expired). The error alert has no close button because its retry is the way out. +ARIAInfo, success and warning use role="status" and error uses role="alert". Alerts that are already there when the page loads aren't announced again. The description text uses --ui-text-toned rather than the semantic color, so long text stays readable. +
+
+ +
+

Toast

+

Toasts confirm that an action finished or failed. Each one has a short title and, at most, one action.

+
+
+
Article published"Winter checklist for timber windows" is now online.
+
That article no longer existsIt was deleted somewhere else, so we removed it from the list.
+
Couldn't unpublishThe server didn't respond.
Retry
+
+
+PlacementBottom right on desktop, 24 px from the edges. Bottom center at full width minus 16 px on mobile, above the sticky action bar. +DurationSuccess and info close after 5 s. Errors with retry stay until dismissed or retried. The timer pauses on hover and on focus. +StackAt most 3 visible, newest at the bottom. A fourth pushes out the oldest non-error toast. Repeating the same action replaces its toast instead of adding a new one. +Nuxt UIUApp :toaster="{ position: 'bottom-right', duration: 5000, max: 3 }". Toasts are raised with useToast().add({ id, title, description, color, icon, actions, duration }), where id is the action plus the article id so a repeat replaces the old toast. Errors use duration: 0. The progress bar is off. +ARIAThe Reka toast region announces politely, and errors assertively. F8 jumps to the region. Esc closes the focused toast. +
+
+
+ +
+

Modal and slideover

+

Modals ask a question or hold a short form. Slideovers carry navigation and side panels on small screens. Both sit over a 40% ink overlay.

+
+
+
Leave without saving?You have changes to "How we price a bathroom renovation" that haven't been saved.
+
Discard changesKeep editing
+
+
+
Details
+
Created12 Mar 2026, 09:14UpdatedToday, 11:02ID12Images3
+
+
+
+Nuxt UIUModal :title :description with the #footer slot for actions, width sm:max-w-md. Opened programmatically with useOverlay().create(ConfirmDialog). USlideover side="right" for details, side="left" for mobile navigation (built into UDashboardSidebar). +Button orderThe safe action is primary and sits on the right, where the eye lands. In destructive confirmations, the danger button is on the right and Cancel on the left (see ConfirmDialog). On mobile the buttons stack full width, with the primary on top. +Keyboard, ARIAFocus is trapped and starts on the least destructive button. Esc and the overlay close it unless it's :dismissible="false", which is used while an action is running. Focus returns to the trigger. The title is the aria-labelledby and the description is the aria-describedby. +ResponsiveBelow sm, modals sit 16 px from the screen edges and slideovers take the full width. Motion follows the foundations: 240 ms, and instant with reduced motion. +
+
+ +
+

Dropdown menu and tooltip

+

The row-action menu is the main dropdown. Delete always comes last, below a separator, in brick red. Tooltips name icon-only buttons and show their keyboard shortcut.

+
+
+ +
+
Edit
+
Unpublish
+
Copy API URL
+
+
Delete…
+
+
+
+
BoldCtrlB
+ +
+
+
+Nuxt UIUDropdownMenu :items :content="{ align: 'end' }", with items grouped in nested arrays so separators appear automatically. The Delete item is color: 'error', and its label ends in "…" because it opens a confirmation. UTooltip :text :kbds="['ctrl', 'b']" :delay-duration="400". +KeyboardThe menu opens with Enter, Space or ↓. Arrow keys and type-ahead move through it, Home and End jump to the ends, and Esc closes it and returns focus. Tooltips appear on focus as well as hover and never hold information that isn't also in the aria-label. +TouchTooltips don't show on touch, so every icon-only control on mobile also appears in a labeled menu or has a visible label. +
+
+ +
+

Tabs

+

Tabs filter the article list. Each count comes from the full list, so it doesn't change while you search.

+
+
+All6 +Online4 +Drafts2 +Focused +
+From left: selected, default, hover, focus-visible. +
+
+Nuxt UIUTabs variant="link" color="neutral" :content="false" :items v-model. The count goes in the #trailing slot as the mono sm count badge. The value syncs to ?status=online|draft so the filtered view can be linked. +KeyboardRoving tabindex: Tab enters the tab list on the selected tab, and ← and → move and activate. Each tab's accessible name includes its count, for example "Drafts, 2 articles". +ResponsiveTabs stay in one row at 360 px, which is why the labels are short. Each tab is 44 px tall on touch. +
+
+ +
+

Table and pagination

+

Rows are 44 px with hairline separators, and only the Updated column sorts. Pagination appears only when a filter shows more than 25 articles.

+
+
TitleStatusUpdated
+
How we price a bathroom renovationdefault2 hours ago
+
Choosing lime plaster for old stone wallshoverYesterday
+
Before and after: the Rue Verte kitchenfocus-visible3 days ago
+
Winter checklist for timber windowsbusy5 days ago
+
26–50 of 142
123…6
+
+
+Nuxt UIUTable :data :columns v-model:sorting v-model:global-filter v-model:pagination :loading with TanStack getPaginationRowModel, and @select for row click. UPagination :total :items-per-page="25" :sibling-count="1" show-edges, active item color="primary" variant="solid". +StatesDefault, hover (bg-elevated), focus-visible (2 px inset ring), busy (a row action is running: 50% opacity, a spinner in the status cell, aria-busy, actions disabled), loading (skeleton rows), empty (EmptyState in the #empty slot). +Keyboard, ARIAEach row's title is a real link to the editor, so Tab reaches it and Enter opens it. The rest of the row is a click target for the mouse only. The sort header is a button with aria-sort. Pagination is a nav labeled "Pages", and the current page has aria-current. +
+
+ +
+

Skeleton, avatar, kbd, separator

+
+ +
+
MAmarta
+
JVjan_vries
+
BObob
+
+
+Ctrl +S +Save +
+
+
+
+SkeletonUSkeleton blocks shaped like the real layout: the same row heights, a badge-sized block and a date-sized block. The pulse runs at 1.6 s and stops with reduced motion. The container is aria-busy="true" and a visually hidden live region says "Loading articles". Skeletons appear only after 150 ms, so fast responses never flash. +AvatarUAvatar :alt="username" size="xs|sm". The initials come from the username: split on anything that isn't a letter or digit, take the first letter of the first two parts, or the first two letters if there is only one part, then uppercase. Sand #d4c5b0 with ink text (11.2:1) in both themes. It's decorative (aria-hidden) because the username is always shown next to it. +KbdUKbd variant="outline", only inside tooltips and the editor's shortcut hints. The editor shortcuts are Ctrl/⌘ S to save, plus TipTap's defaults: B, I, U, Z and Shift Z. +SeparatorUSeparator uses --ui-border by default and --ui-border-accented between groups in dense toolbars. It's decorative, with role="none", except between menu groups. +
+
+ +
+

Color mode

+

Three choices: System (the default), Light and Dark. The toggle at the top of this page is the component itself, so you can try it.

+
+Nuxt UIIn the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference. On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint. +ARIAMenu items use menuitemcheckbox, with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme". +
+
+ + + + + + + diff --git a/docs/design/source/RenewCMS Components Product.dc.html b/docs/design/source/RenewCMS Components Product.dc.html new file mode 100644 index 0000000..93c034f --- /dev/null +++ b/docs/design/source/RenewCMS Components Product.dc.html @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + +
+
+ +
+
+

Components: product

+

These are the RenewCMS components, built from the primitives on the first page. Each one lists the Nuxt UI parts it's made from, its props, emits and slots, every state, and keyboard and responsive behavior. Components marked custom have no Nuxt UI equivalent.

+
+
+ + + +
+
+ +
+

AuthShell

+

One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task.

+
+
+
+ +
Log inWelcome back. Log in to manage your articles.
+
default slot
+
+
Forgot password?System
+
+
+
+Built fromCustom layout component AuthShell.vue: a UPageCard variant="outline" inside a centered main, with AppLogo, and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm, because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose. +Props, slotsProps title: string, description?: string. Slots default, notice (an alert above the title, for session expired or password changed), footer (links). No emits. +A11yThe title is the page's h1, and document.title becomes "Log in · RenewCMS". On route change, focus moves to the h1. The logo is alt="RenewCMS" and isn't a link, since there's nothing public to go back to. +Responsive400 px card from sm up, with its top 12 vh from the top. Below sm, the card loses its border and padding drops to 20 px, filling the screen on --ui-bg, and fields and buttons use xl. +
+
+ +
+

DashboardShell

+

The sidebar has three destinations, and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg, the sidebar becomes a slideover.

+
+
+
+
RenewCMS
+
Home
+
Articles
+
Integration
+
+
+
+
martaOwner
+
+
System
+
Light
+
Dark
+
+
Log out
+
+
MAmarta
+
+
+
+
ArticlesHow we price a bathroom renovation
+
page body
+
+
+
+
+ + + + + +MA +
+
+
Articles
+
Mobile top bar. The menu button opens the sidebar as a left slideover.
+
+
+
+
+Built fromUDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default (UNavigationMenu orientation="vertical" :collapsed) and #footer (UserMenu) slots. Each page is a UDashboardPanel, with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb. On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over. +UserMenuUDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them. +Props, slotsNo props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb. Slots navbar-right (page actions on mobile) and default. It shows the offline banner when navigator.onLine is false. +A11yThe first Tab stop is a "Skip to content" link. The sidebar is a nav labeled "Main", and the current page gets aria-current="page". In the collapsed rail, items show tooltips and keep their aria-label. The collapse button carries aria-expanded. The mobile slideover traps focus and closes after navigating. +BendThe active nav item uses a sand fill with ink text instead of Nuxt UI's primary-tinted background and left indicator. The shell is set up for this in ui.config.ts. +
+
+ +
+

PageHeader

+
+
ArticlesEverything you've written, online and in draft.
Connect your websiteNew article
+
ArticlesEverything you've written, online and in draft.
New article
+
+
+Built fromUPageHeader :title :description, with actions in the #links slot. The ui override is { root: 'py-0 border-0', title: 'text-xl', description: 'text-sm text-muted mt-0.5' }, which brings Nuxt UI's marketing-size header down to dashboard density. +Props, slotstitle, description?, slot actions (the primary action goes last, so it sits on the right), slot meta (for example, a status badge next to the title in the editor). +ResponsiveBelow md, the actions wrap under the text. The primary action goes full width at 44 px, and secondary actions move into the navbar's overflow menu. +
+
+ +
+

ArticleStatusBadge

+
+Online +Draft +Online +Draft +Publishing… +Unpublishing… +
+
+Built fromUBadge. Online: color="neutral" variant="solid" icon="i-lucide-globe". Draft: color="neutral" variant="outline" icon="i-lucide-pencil-line". Status is shown by the fill, the icon and the word, never by color alone. +Propsonline: boolean, size?: 'sm' | 'md' (sm in lists, md in the editor header), pending?: 'publish' | 'unpublish' (shows the -ing label with a spinner while the request runs). No emits, because it isn't interactive. +
+
+ +
+

ArticleTable

+

Search, filter tabs, a sortable table and row actions. Everything happens in the browser on the list it has already loaded. Below md, the table becomes a card list with the same data and actions.

+
+
+
+
All6Online4Drafts2
+Search by title +
+
+
TitleStatusCreatedUpdated
+ +
+{{ a.t }} +OnlineDraft +{{ a.c }}{{ a.u }} + +
+
+
+
+
+ +
+
{{ a.t }}
OnlineDraftUpdated {{ a.u }}
+ +
+
+
+
+
+Built fromUTabs + UInput type="search" + UTable (desktop), or a custom ArticleCardList of ULink rows (mobile), switched with useBreakpoints at md. Each row has a UDropdownMenu, and the row actions go through useArticleActions(), which shares one set of calls between the table, the list and the editor. +Props, emitsProps articles: Article[], loading: boolean, busyIds: Set<number>. Emits publish(id), unpublish(id), delete(article), copy-url(id). Search, tab and sort state lives in the URL query (?q=&status=&sort=). Slots #empty, #toolbar-right. +BehaviorSearch is case-insensitive and ignores accents, and it matches anywhere in the title. It updates on input, debounced 120 ms. Tab counts always reflect the full list. Dates within 7 days are relative ("2 hours ago"); older ones are absolute ("18 Aug 2026"), with the full timestamp in a title attribute. Clicking a row opens the editor, except clicks on the menu. +StatesLoading (5 skeleton rows), empty, no results, a filter tab with nothing in it, a row busy, an action failed (toast with Retry, row reverts), and a row vanished (a 404 on any action removes the row and shows an info toast). Row actions are never optimistic: the row updates when the 200 arrives. +A11yThe table has a visually hidden caption, "Articles, sorted by last updated, newest first". Each row's menu button is named "Actions for How we price a bathroom renovation". After a search, a polite live region announces "3 articles". +
+
+ +
+

EmptyState

+
+
No articles yetWrite your first one. It stays a draft until you publish it.New article
+
No articles match "tiles"Check the spelling, or search for a shorter part of the title.Clear search
+
No draftsEverything you've written is online.New article
+
Nothing is online yetPublish a draft and it will appear on your website.Show drafts
+
+
+Built fromUEmpty :icon :title :description :actions variant="naked", placed in the UTable #empty slot or on the page. The icon tile is overridden to rounded-none bg-elevated. +Propsvariant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online', query?: string. Emits action. Each variant has fixed copy, and the search term is shown in quotes, cut to 40 characters. +A11yThe title is an h2. "No results" is announced through the table's live region. Clear search returns focus to the search field. +
+
+ +
+

ConfirmDialog

+
+
+
Delete this article?"Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.
+
CancelDelete article
+
+
+
Delete this article?"Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone. +
Couldn't delete the article, so it's still there. Try again in a moment.
+
CancelDeleting…
+
+
+

Left: default. Right: after a failed attempt (the error appears inline), with the retry running (loading).

+
+Built fromUModal opened through useOverlay().create(ConfirmDialog). open() returns a promise that resolves once the action succeeds or the dialog is cancelled. The action runs inside the dialog, so errors show there instead of in a toast behind the overlay. +Props, emitstitle, description, confirmLabel, pendingLabel, tone: 'danger' | 'neutral', action: () => Promise<void>. Emits close(confirmed: boolean). The delete description comes from the title plus images.length, and reads "and its image" for 1 and just the title for 0. +Other usesCancel registration: "Cancel your registration?" / "Your account will be deleted. You can register again with the same or a different email." / "Cancel registration" / "Keep my account". Leaving with unsaved changes uses the neutral tone. +A11yrole="alertdialog". Focus starts on Cancel, and Enter doesn't confirm by default. While the action runs, the dialog can't be dismissed, both buttons are disabled, and aria-busy is set. On success, focus goes to the next row, or the page heading if there isn't one. +
+
+ +
+

ArticleEditor, SaveStatus, PublishControl, CharacterCounter

+

The editor is made of four components that work together. The header holds the read-only title, status, SaveStatus, Save and PublishControl. A sticky toolbar sits above the writing surface, which uses the content type scale. The counter sits at the foot.

+
+
+ +
How we price a bathroom renovationDraft
+Unsaved changes +Save +Save and publish + +
+
+ + +{{ t.text }} + +
+
+
+

Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays.

+
What goes into the price
+
  • Stripping out and making good
  • Plumbing moves, if any
+
+
+Details +
Created12 Mar 2026, 09:14UpdatedToday, 11:02ID12Images3
+
Public API URL/v1/article/12Returns 404 until the article is online.
+
+
+
4 318 / 10 000
+
+ +
+
Unsaved changesdirty, Save enabled
+
Saving…saving, Save disabled
+
Saved at 11:02saved, Save disabled
+
Save failedRetryerror, changes kept
+
Publishdraft, saved
+
Unpublishonline
+
9 240 / 10 000body ≥ 90%
+
10 214 / 10 000214 characters over. Shorten the text or remove an image. Formatting counts too.over limit, Save disabled
+
+ +
+ArticleEditorUEditor v-model content-type="html" :extensions + UEditorToolbar :editor :items layout="fixed". TipTap v3 StarterKit already includes Underline and Link. TextAlign handles alignment and Image handles images. Formatting is a UDropdownMenu with Paragraph and Headings 1–4 (h1 is kept because the public site controls how the title appears). Indent and outdent call sinkListItem/liftListItem in lists. For paragraphs, a small custom Indent extension writes data-indent="1–4", because TipTap has no paragraph indent. Clear formatting runs unsetAllMarks().clearNodes(). The editor surface gets class="article-content". +Props, emitsmodelValue: string (HTML), articleId: number, disabled?. Emits update:modelValue, request-image. Exposes insertImage(src, alt) and focus(). The toolbar reflects the active state (bold is on at the cursor) using aria-pressed. +SaveStatusA custom inline span aria-live="polite", driven by useArticleDraft(): 'clean' | 'dirty' | 'saving' | 'saved' | 'error'. Saved shows the time and switches to "Saved 5 min ago" after an hour. The error state's Retry is a UButton size="xs" variant="outline". Ctrl/⌘ S saves. Leaving with unsaved changes triggers onBeforeRouteLeave (ConfirmDialog, neutral tone) and beforeunload. There is no autosave, so the owner controls exactly what is sent. The draft is mirrored to sessionStorage so it survives a session expiry. +PublishControlA single UButton. Draft → primary "Publish"; draft with unsaved changes → "Save and publish" (PATCH body, then PATCH publish; if the save fails, it doesn't publish); online → outline "Unpublish". Unpublishing doesn't save pending changes, so the draft edits stay pending. Toasts: "Article published", with the description "Title" is now online.; "Article unpublished", It's a draft again and hidden from your website. +CharacterCounterA custom component in mono type. Props value: number, max: number, min?: number, warnAt?: number (default 0.9). The title uses [...str].length, so accents and emoji count as one, matching Go's validator, which counts runes. The body uses the HTML string length, which is what the API receives. The warning and error states add an icon and text, not just color. A visually hidden live region announces the change only when a threshold is crossed, not on every keystroke. +ResponsiveBelow lg, the details panel becomes a right slideover, opened with i-lucide-panel-right. Below md, the header wraps: back, title and badge on the first row, then SaveStatus and the counter. Save and Publish move to a sticky bottom bar (44 px). The toolbar scrolls horizontally with fade edges and keeps undo, redo and the image button pinned at the start. +
+
+ +
+

ImageUploader

+

Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. After a successful upload the image goes in at the cursor and the modal closes.

+
+
Drop an image hereChoose filePNG, JPEG, WEBP, SVG · max 10 MB
idle
+
Drop to upload
dragging over
+
north-wall.jpg62%
Uploading 2.4 of 3.9 MB
uploading
+
Image added
success, closes after 600 ms
+
"plan.pdf" can't be usedUse a PNG, JPEG, WEBP or SVG image, up to 10 MB.Choose another file
rejected: type or size
+
Upload failedThe server didn't accept the file. Your article hasn't changed.Try again
server error
+
+
+Built fromUModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs". Uploads go through XMLHttpRequest, because fetch has no upload progress, as FormData with the field name file. The 201 location is inserted with insertImage(location, ''). +ValidationBefore uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024. The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection. +Props, emitsarticleId: number. Emits uploaded(location), close. A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles. +A11y, touchThe drop zone is a labeled button, so Enter opens the file picker. Progress is exposed as role="progressbar" with aria-valuenow, and the result is announced. On touch screens the drop text is hidden and "Choose file" becomes the main action. Dropping a file directly onto the editor surface also opens the uploader with that file. +
+
+ +
+

CodeBlock and EndpointCard

+
+
+
GET/v1/article/{id}
+Returns one article. Drafts return 404 to anonymous callers, the same as an id that doesn't exist. +
+
fetchcurlResponse
+
+
JavaScriptCopied
+
const res = await fetch('https://cms.renew.example/v1/article/12')
+if (res.status === 404) return notFound()
+const article = await res.json()
+
+
+
+CodeBlockThis is a custom component, because Nuxt UI's prose code blocks rely on Nuxt Content and don't work in a plain Vue app. Highlighting is done ahead of time with shiki (a dual github-light/github-dark theme remapped to the semantic colors: keywords info, strings success, numbers warning, comments muted). The header shows the language label and a ghost copy UButton, which switches to "Copied" for 2 s through useClipboard. Props code, lang, label?. Long lines scroll horizontally and never wrap. +EndpointCardUCard › a method UBadge (GET is solid ink) + the path in mono + a description, with UTabs (fetch, curl, Response) showing a CodeBlock each. Props method, path, description, examples: { fetch, curl, response }. Snippets use the current location.origin. +A11yThe copy button is named "Copy JavaScript example", and its result is announced. Code is a focusable pre (tabindex="0") so keyboard users can scroll it. Syntax colors pass 4.5:1 on --ui-bg-elevated in both themes. +
+
+ +
+

PasswordStrengthHint

+
+
At least 8 charactersnot met yet
+
At least 8 charactersmet
+
At least 8 charactersnot met on submit
+
Passwords matchreset screen only
+
+
+Built fromA custom ul in the password UFormField's #help slot. Props rules: { label: string, met: boolean }[], showErrors: boolean. There is no meter or score, because the API only requires 8 characters. +A11yThe list is the field's aria-describedby. Each item has hidden text reading "met" or "not met". A change is announced once, when the rule becomes met. +
+
+ +
+

ResultPanel

+
+
Check your emailIf an account exists for that email, a password reset link has been sent. The link expires in 1 hour.
Back to log in
+
This link has expiredReset links work once, for 1 hour. Request a new one and use it within the hour.
Request a new link
+
Too many attemptsFor your security, wait a moment before trying again. You can try again in 0:42.
Try again in 0:42API pending
+
+
+Built fromA custom component that fills AuthShell's slot: an icon tile, h1 title, description and UButton block size="lg" actions. Props tone: 'success' | 'error' | 'info', icon, title, description, actions: ButtonProps[]. Slot default for extras such as the inline login on verify. +A11yWhen the panel replaces a form, focus moves to its title and the panel is role="status", or role="alert" for errors. The countdown updates every second visually but is announced only at the start and when it reaches zero. +
+
+ +
+

Error pages and offline banner

+
+
You're offline. You can keep writing, but saving and publishing will wait until you're back online.
+
+
404Page not foundThe link may be mistyped, or the page may have moved.
Go to HomeAll articles
+
ErrorSomething went wrongThis page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.
Try againGo to Home
+
+
+
+Built fromUError isn't used because it's built for Nuxt's error.vue. Instead there's a custom ErrorView (props kind: 'not-found' | 'error'), rendered inside UDashboardPanel when the user is logged in and inside AuthShell otherwise. The offline banner is a UBanner color="neutral" at --z-banner, shown and hidden with useOnline(). Once the connection returns it switches to "You're back online" for 3 s. +A11yThe error title is the page h1 and gets focus on render. The banner is role="status", and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip. +
+
+ +
+
+
+ + + diff --git a/docs/design/source/RenewCMS Directions.dc.html b/docs/design/source/RenewCMS Directions.dc.html new file mode 100644 index 0000000..b0c6400 --- /dev/null +++ b/docs/design/source/RenewCMS Directions.dc.html @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + +
+
+

RenewCMS, three directions

+

All three share your palette, the white-on-black R and square corners (--ui-radius: 0). They differ in type, density and how much the sand accent carries. Each shows the article list at desktop in light and at 390 px in dark. Pick one and I'll build the full foundations.

+
+
+ +
+
1a

Ledger

+

Quiet and precise, like a well-kept account book. Monochrome, hairline borders, compact rows so a few hundred articles scan quickly. Sand appears only where you are: the active nav item and selection. Article content switches to a sturdy serif so writing feels distinct from the tool.

+
+
+
+
primary#111111
+
neutral-600#444444
+
text-muted#666666
+
border#e6e4e0
+
secondary#e8dfd1
+
+Primary is ink, secondary is sand, neutral is the brand grays with a slight warm tint on borders. +
+
+Aa IBM Plex Sans +Aa Source Serif 4 +UI at 13/20, content at 18/30. Plex Mono for ids, paths and code. +
+
+Radius 0 · 1 px borders · compact +Rows 44 px, controls 32 px, 24 px page gutters. No shadows except menus and modals. +
+
+ +
+
+
R
RenewCMS
+
Home
+
Articles
+
Integration
+
+
MA
marta
+
+
+
HomeArticles
+
+
ArticlesEverything you've written, online and in draft.
New article
+
+
All 6Online 4Drafts 2
+
Search by title
+
+
+
TitleStatusCreatedUpdated
+ +
+{{ a.t }} +OnlineDraft +{{ a.c }}{{ a.u }} + +
+
+
+
+
+
+ +
+
+
R
Articles
+
+
Search by title
+
All 6Online 4Drafts 2
+
+ +
+
{{ a.t }}
OnlineDraftUpdated {{ a.u }}
+ +
+
+
+
+Choosing lime plaster for old stone walls +

Cement traps moisture in solid walls. Lime lets the stone breathe, which is why we strip it back on almost every farmhouse we take on.

+
It sets slowly, and that is the point.
+

Contrast: #111 on #fafafa 18.1:1 · #666 on #fafafa 5.5:1 · #999 on #111 6.6:1. #999 on light fails AA, so it is kept for dark mode and disabled states only.

+
+
+
+ +
+
1b

Atelier

+

Warm and unhurried, like a workshop with good light. The sand accent becomes the frame: a sand sidebar around a pale working surface. Titles are set in a literary serif everywhere, including the table, so the list reads like a table of contents. Generous rows, larger controls, friendly on a phone.

+
+
+
+
primary#222222
+
secondary#d4c5b0
+
bg-accented#e8dfd1
+
bg#fafafa
+
text-muted#666666
+
+Primary is near-black for a softer button. Sand is structural here, not just a highlight. +
+
+Aa Instrument Sans +Aa Newsreader +UI at 15/22, titles and content in Newsreader at 19/32. +
+
+Radius 0 · tonal surfaces · comfortable +Rows 60 px, controls 40 px, 32 px gutters. Separation by surface color more than by lines. +
+
+ +
+
+
R
RenewCMS
+
Home
+
Articles
+
Integration
+
+
MA
marta
+
+
+
HomeArticles
+
+
ArticlesEverything you've written, online and in draft.
New article
+
+
All 6Online 4Drafts 2
+ +
Search by title
+
+
+
TitleStatusCreatedUpdated
+ +
+{{ a.t }} +OnlineDraft +{{ a.c }}{{ a.u }} + +
+
+
+
+
+
+ +
+
+
Articles
New
+
+
Search by title
+
All 6Online 4Drafts 2
+
+
+ +
+
{{ a.t }}
OnlineDraft{{ a.u }}
+ +
+
+
+
+
+Choosing lime plaster for old stone walls +

Cement traps moisture in solid walls. Lime lets the stone breathe, which is why we strip it back on almost every farmhouse we take on.

+
It sets slowly, and that is the point.
+

Contrast: #222 on #e8dfd1 12.4:1 · #444 on #e8dfd1 7.3:1 · #111 on #d4c5b0 11.2:1 · #e8dfd1 on #444 7.3:1.

+
+
+
+ +
+
1c

Plank

+

Solid and confident, like freshly cut timber and a builder's pencil. A black sidebar anchors the page, headings are heavy, dates and ids sit in monospace. Status is shape-coded: online is a filled sand tag, draft is a dashed outline. Most graphic of the three, still restrained.

+
+
+
+
primary#111111
+
secondary#d4c5b0
+
neutral-800#222222
+
bg#ffffff
+
text-muted#666666
+
+Ink and sand at full strength. White page, black frame, sand for the one thing that is live. +
+
+Aa Archivo +Aa Literata +2026-09-24 · /v1/article/12 +
+
+Radius 0 · 2 px frame lines · medium +Rows 52 px, controls 40 px. Heavy rules around the table, hairlines inside it. +
+
+ +
+
+
R
RenewCMS
+
Home
+
Articles
+
Integration
+
+
MA
marta
+
+
+
Home/Articles
+
+
ArticlesEverything you've written, online and in draft.
New article
+
+
All 6Online 4Drafts 2
+
Search by title
+
+
+
TitleStatusCreatedUpdated
+ +
+{{ a.t }} +OnlineDraft +{{ a.ci }}{{ a.ui }} + +
+
+
+
+
+
+ +
+
+
R
+
+Articles +
All 6Online 4Drafts 2
+
+ +
+
{{ a.t }}
OnlineDraft{{ a.ui }}
+ +
+
+
New article
+
+
+Choosing lime plaster for old stone walls +

Cement traps moisture in solid walls. Lime lets the stone breathe, which is why we strip it back on almost every farmhouse we take on.

+
It sets slowly, and that is the point.
+

Contrast: #111 on #d4c5b0 11.2:1 · #cfccc7 on #111 12.5:1 · #444 dashed border on #fff 9.7:1. Dashed vs filled keeps status legible without color.

+
+
+
+ +
+
+
+ + + diff --git a/docs/design/source/RenewCMS Emails.dc.html b/docs/design/source/RenewCMS Emails.dc.html new file mode 100644 index 0000000..2aeb676 --- /dev/null +++ b/docs/design/source/RenewCMS Emails.dc.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + +
+
+

Transactional emails

+

Two Go html/template files: emails/verification.html and emails/password-reset.html. They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.

+

Suggested subjects are "Verify your RenewCMS account" and "Reset your RenewCMS password". html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.

+
+ +
+

{{ r.title }}

{{ r.vars }}
+
+ +
{{ f.label }}
+
+
+
+
+
+
+ + + diff --git a/docs/design/source/RenewCMS Foundations.dc.html b/docs/design/source/RenewCMS Foundations.dc.html new file mode 100644 index 0000000..13eb0b5 --- /dev/null +++ b/docs/design/source/RenewCMS Foundations.dc.html @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + +
+ +
+
+ +

Ledger foundations

+
+

Everything below is generated from one set of files: tokens/renewcms.css (Tailwind @theme, :root, .dark, article content styles), tokens/ui.config.ts (Nuxt UI colors, icons and component overrides) and tokens/vite.config.ts. The tables on this page read those files, so they can't drift.

+
+ +
+

Logo

+

The mark is a white R on a black square, drawn on a 16-pixel grid so it stays sharp as a favicon. It keeps the same geometry at every size. In dark mode the black square gets a 1 px #444 edge so it doesn't disappear into the background. The wordmark pairs the mark with RenewCMS in Plex Sans Semibold, with the text set at 0.6× the mark's height.

+
+
+
RenewCMS
+
+
16
+
32
+
64
+
128, app icon source for 180 / 512
+
+
Articles · RenewCMS
+
+
+
RenewCMS
+
+
16
+
32
+
64
+
128
+
+
Articles · RenewCMS
+
+
+

Favicon file: brand/favicon.svg. The 1 px edge switches on with prefers-color-scheme: dark. Keep clear space of at least half the mark's height around it. The R is never recolored or placed on sand.

+
+ +
+

Color scales

+

Seven Tailwind color scales, each mapped to a Nuxt UI alias. The ink, chalk and sand scales are built on your brand values. The four semantic scales are new and deliberately muted: evenly spaced oklch lightness steps with low chroma, so they sit comfortably next to ink and sand.

+
+ +
+
{{ sc.alias }}{{ sc.name }}{{ sc.note }}
+
+ +
{{ st.k }}{{ st.hex }}
+
+
+
+
+
+
+ +
+

Theme variables

+

Every --ui-* variable Nuxt UI reads, for both themes. Surfaces are white. The #fafafa brand background is the page canvas behind them. The theme follows the system setting until the owner picks Light or Dark from the user menu, and that choice is stored by Nuxt UI's color mode.

+
+
+
VariableUsed forLightDark
+ +
+{{ v.name }}{{ v.use }} +{{ v.lr }} {{ v.lh }} +{{ v.dr }} {{ v.dh }} +
+
+
+
+
+ +
+

Contrast checks

+

Each ratio is computed from the variables above against WCAG 2.2 AA: 4.5:1 for text and 3:1 for control edges and focus rings. Soft alerts are measured against a 10% tint of the color over the surface, which is how Nuxt UI renders them. {{ passSummary }}

+
+
+
PairNeedsLightDark
+ +
+{{ c.label }}{{ c.need }} +Aa{{ c.light.ratio }} +Aa{{ c.dark.ratio }} +
+
+
+
+
+ +
+

Typography

+

The interface uses IBM Plex Sans in three weights: 400, 500 and 600. IDs, paths, counters and code are set in IBM Plex Mono. Sizes replace Tailwind's text-* defaults. On screens narrower than md, form fields use 16 px so iOS doesn't zoom in when a field gets focus.

+
+ +
+Before and after: the Rue Verte kitchen +
{{ t.name }} · {{ t.size }} / {{ t.lh }} · {{ t.w }}{{ t.use }}
+
+
+
+GET /v1/article/12 · 8 412 / 10 000 +
font-mono · 13px / 20px · 400Code, paths, IDs, counters
+
+
+ +

Article content uses a separate scale in Source Serif 4, applied through the .article-content class. The editor surface and the preview both use it, so what the owner writes looks like what a public site would roughly show. The measure is capped at 42 rem, about 70 characters per line.

+
+
+
Choosing lime plaster for old stone walls
+

Cement traps moisture in solid walls. Lime lets the stone breathe, which is why we strip it back on almost every farmhouse we take on.

+
What we use
+
  • Hot-mixed lime for the base coat
  • Hair or fibre for the scratch coat
  • A thin lime putty finish
+
Drying time
+
It sets slowly, and that is the point.
+
Before you start
+
  1. Strip loose material back to sound stone.
  2. Wet the wall the evening before.
+
Article image
The north wall after the second coat.
+
+
+
Elementmd and upBelow md, spacing
+ +
+{{ t.el }}{{ t.desk }}{{ t.rest }} +
+
+
+
+
+ +
+

Spacing, radius and density

+

Spacing uses Tailwind's 4 px base unit unchanged. Radius is 0 everywhere: setting --ui-radius: 0rem flattens every component that derives its corners from it, and rounded-none overrides cover the few that hard-code rounded-full. The density is compact on desktop. On touch screens, every tap target is at least 44 px.

+
+
+ +
{{ s.k }}{{ s.px }} · {{ s.use }}
+
+
+
+
ControlHeight and use
+
size="sm"28 px · table row actions, toolbar buttons in the editor
+
size="md"32 px · default on desktop: buttons, inputs, selects, tabs
+
size="lg"40 px · auth forms, primary actions in dialogs
+
size="xl"44 px · everything below md, via a responsive size prop
+
Table row44 px on desktop, 64 px cards on mobile
+
Gutters16 px on mobile, 24 px on desktop; sidebar 224 px, collapses to 56 px
+
+
+
+ +
+

Borders and elevation

+

Most surfaces are flat and separated by hairlines. Shadows appear only on things that float above the page.

+
+
Hairline1 px --ui-border. Cards, table edges, sidebar and top bar dividers. Decorative, so no contrast requirement.
+
Search by title
Control edge1 px --ui-border-accented. Inputs, selects, checkboxes, outline buttons. 3.6:1 on white.
+
New article
Focus ring2 px --ui-primary outline with a 2 px offset, shown only on :focus-visible. Nuxt UI's default, kept.
+
Edit
Unpublish
Popover--shadow-popover. Dropdown menus, selects, toasts. In dark mode it becomes a darker shadow plus the hairline.
+
Modal--shadow-modal over a 40% chalk-950 overlay. Modals and slideovers.
+
+
+
+
LayerzContents
+ +
{{ z.n }}{{ z.v }}{{ z.u }}
+
+
+
+
BreakpointMinWhat changes
+ +
{{ b.n }}{{ b.v }}{{ b.u }}
+
+
+
+
+ +
+

Motion

+

Motion is there to show where something came from or that something is working. It is short and never decorative.

+
+
120 ms--duration-fast · ease-standardHover and pressed colors, focus ring, the counter turning to warning, the switch thumb.
+
180 ms--duration-base · ease-enter / ease-exitDropdowns and tooltips (fade plus a 4 px shift), the tab indicator, toasts sliding in, SaveStatus crossfades.
+
240 ms--duration-slow · ease-enter / ease-exitModals (fade plus scale from 0.98), slideovers (slide from the edge), sidebar collapse.
+
+
+
Never animatesRoute changes, table rows sorting or filtering, numbers counting up, the logo, editor text, and anything that runs on page load.
+
Reduced motionWith prefers-reduced-motion: reduce, every transition and animation completes instantly, skeletons stop pulsing and become flat blocks, and toasts appear without sliding. The one exception is the spinner on the automatic verification screen. It keeps turning, slowed to 1.6 s per rotation, because it is the only sign that work is happening.
+
+
+ +
+

Icons

+

Lucide through Iconify, using Nuxt UI's i-lucide-* classes. Icons are 16 px next to 13–14 px text and 20 px on mobile top bars. They are always paired with a visible label or an aria-label. Each icon maps to one meaning: Eye is only ever show password, and Preview uses Book Open.

+
+ +
{{ i.a }}{{ i.n }}
+
+
+
+ +
+

Where Nuxt UI defaults bend

+
+ +
{{ b.w }}{{ b.r }}
+
+
+
+ +
+

Files

+

Ready to paste into the repository. They follow its code style: tabs, no semicolons, single quotes and no comments. The CSS goes in src/assets/main.css and the config in src/theme/ui.config.ts. Add class="isolate" to the app root, as Nuxt UI requires.

+
+
+ + + + + +
+
{{ code }}
+
+
+ +
+
+ + + diff --git a/docs/design/source/RenewCMS Handoff.dc.html b/docs/design/source/RenewCMS Handoff.dc.html new file mode 100644 index 0000000..c556735 --- /dev/null +++ b/docs/design/source/RenewCMS Handoff.dc.html @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + +
+ +
+

RenewCMS handoff

+

Everything needed to build the back-office in Vue 3, vue-router, Pinia and Nuxt UI v4. Tokens have one source of truth, tokens/renewcms.css plus tokens/ui.config.ts. Components use PascalCase names and routes are kebab-case, matching the code.

+ +
+ +
+

Route map

+

A global router.beforeEach guard reads meta.auth. On the first navigation it resolves the session with GET /v1/auth/me (API pending) and caches the result in the session store. A fetch wrapper, useApi, turns any 401 into /login?redirect=&reason=expired and any 403 from an unverified account into /register/pending.

+
+
PathLayoutAuthScreenAPI calls
+
{{ r.p }}{{ r.l }}{{ r.a }}{{ r.s }}{{ r.api }}
+
+
+ +
+

Component inventory

+
+
ComponentNuxt UI basePropsEmitsSlotsUsed on
+
{{ c.n }}{{ c.b }}{{ c.p }}{{ c.e }}{{ c.s }}{{ c.u }}
+
+

The composables behind these components are useSession, useApi, useArticles (the list cache plus actions shared by the table, cards and editor), useArticleDraft (dirty state, save, and the sessionStorage mirror), useImageUpload (XHR with progress) and useRetryAfter (429 countdown).

+
+ +
+

State matrix

+

Every state is drawn at desktop and mobile on the auth and back-office boards, with final copy.

+
+ +
+
{{ m.s }}{{ m.r }}
+
{{ x.n }}{{ x.t }}
+
+
+
+
+ +
+

HTTP status to copy

+

Response bodies are plain text and are never shown. useApi throws an ApiError with the status and a context key, and each screen maps them to the copy below.

+
+
StatusWhereCopySurface
+
{{ e.c }}{{ e.w }}{{ e.t }}{{ e.s }}
+
+
+ +
+

API pending

+
+
{{ p.a }}{{ p.d }}
+
+
+ +
+
+ + + diff --git a/docs/design/source/RenewCMS Prototype.dc.html b/docs/design/source/RenewCMS Prototype.dc.html new file mode 100644 index 0000000..de2570a --- /dev/null +++ b/docs/design/source/RenewCMS Prototype.dc.html @@ -0,0 +1,407 @@ + + + + + + + + + + + + + + +
+ + + +
+
+
+
+ +
{{ emailSubject }}From RenewCMS · to marta@renew.example
+ +
+
+
RenewCMS
+
{{ n.t }}
+
+
+
+ +
+ +
{{ t.title }}{{ t.text }}
+
+
+
+
+
+
+
+
+ + + diff --git a/docs/design/source/RenewCMS Screens Auth.dc.html b/docs/design/source/RenewCMS Screens Auth.dc.html new file mode 100644 index 0000000..c141465 --- /dev/null +++ b/docs/design/source/RenewCMS Screens Auth.dc.html @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + +
+ +
+
+

Screens: public and auth

+

Every state of the six public routes, at 1440 px (shown at 50%) and 390 px (shown at 75%). Use the switch on the right to see all of them in dark mode. Final copy is written into each state. The back-office screens and global states are on the back-office board, and the emails are on the email board.

+
+
+ + + +
+
+ + +
+
+

{{ g.title }}

+
{{ g.route }}{{ g.api }}
+

{{ g.note }}

+
+
+ +
+
{{ st.name }}{{ st.trigger }}
+
+
+
+ + +
+
+
{{ st.dialog.title }}{{ st.dialog.text }}
+
{{ st.dialog.cancel }}{{ st.dialog.confirm }}
+
+
+
+ +
{{ st.toast.title }}{{ st.toast.text }}
+
+
+
+
+
+ + +
+
+
{{ st.dialog.title }}{{ st.dialog.text }}
+
{{ st.dialog.confirm }}{{ st.dialog.cancel }}
+
+
+
+ +
{{ st.toast.title }}{{ st.toast.text }}
+
+
+
+
+
+
+
+
+
+ +
+
+ + + diff --git a/docs/design/source/RenewCMS Screens Back-office.dc.html b/docs/design/source/RenewCMS Screens Back-office.dc.html new file mode 100644 index 0000000..c5f7d75 --- /dev/null +++ b/docs/design/source/RenewCMS Screens Back-office.dc.html @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + +
+ +
+
+

Screens: back-office and global

+

Every state of the verified-only routes, plus the global states, at 1440 px (shown at 50%) and 390 px (shown at 75%). The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from the auth board.

+
+
+ + + +
+
+ + +
+
+

{{ g.title }}

+
{{ g.route }}{{ g.api }}
+

{{ g.note }}

+
+
+ +
+
{{ st.name }}{{ st.trigger }}
+
+
+
+
+
+
+
+
+
+ +
+
+ + + diff --git a/docs/design/source/brand/favicon.svg b/docs/design/source/brand/favicon.svg new file mode 100644 index 0000000..3d58554 --- /dev/null +++ b/docs/design/source/brand/favicon.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplOTQ3NWYzYi1iMzk1LTQwYTItYTc0NC02OTBiNWNhN2ExYmEAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaBZb8xjia8MHL36GYqgFh9IAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDphMzk4M2EzMy03MTA2LTQwODctYjI1Yy05ZDBjMDgyNTBhNWNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoZLnQVKFQmfShOH339JG7cQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggm30c/9ae4hGpd7ZqF9l6g7kujDirGaXyTi536jB+bcekZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaLY9Kv67+CqoKTGwY2CZrToAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCB5yyMJKOtdrWszMUivonxA1KFo3L7r2RCA4Mx1H0/3uWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiYZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmU5NDc1ZjNiLWIzOTUtNDBhMi1hNzQ0LTY5MGI1Y2E3YTFiYS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjViZTQwMjZjLWM3ZjctNDUzNC1hYmFiLTY3OTc0OWZjNGFkY3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCbfRz/1p7iEal3tmoX2XqDuS6MOKsZpfJOLnfqMH5tx6JjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggM1R3FsRboXyA4rm7pAVOcC/HIi2cgqmrpa/mZrSD7n6iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggoD1kSgBM7/QspOsFvy5FPeDatbpPPR1Z+79+Ox5QvZZ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQMdbLZka2O1plsVf/+H0kOP9+QiAZOV//VvAdYZfFfsODuveysuHU8QoXGHnje8650LZsP/IoHEZTigTZ9MJqtg= \ No newline at end of file diff --git a/docs/design/source/emails/password-reset.html b/docs/design/source/emails/password-reset.html new file mode 100644 index 0000000..638ac97 --- /dev/null +++ b/docs/design/source/emails/password-reset.html @@ -0,0 +1,72 @@ + + + + + + + + Reset your password + + + +
Choose a new password for RenewCMS. The link expires in 1 hour.
+ + + + + + + diff --git a/docs/design/source/emails/verification.html b/docs/design/source/emails/verification.html new file mode 100644 index 0000000..a7768f1 --- /dev/null +++ b/docs/design/source/emails/verification.html @@ -0,0 +1,72 @@ + + + + + + + + Verify your account + + + +
Confirm your email to finish setting up RenewCMS. The link expires in 2 hours.
+ + + + + + + diff --git a/docs/design/source/support.js b/docs/design/source/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/docs/design/source/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: