The Project Editor (`src/app/features/project-editor/`) edits the tenant's `BootstrapConfig` (`docs/BACKEND_API.md#4-bootstrap`) directly — no parallel model. It is out of scope for products, categories, orders, or analytics management (those live under `features/admin/*`/`features/backoffice/*`, see `docs/ADMIN.md`).
Route: `/edit/:section` or `/{lang}/edit/:section`. `/backoffice/static-pages` (the Admin dashboard) redirects here (`/edit/static-pages`) rather than hosting a second CRUD surface over the same `bootstrap.staticPages` data (Sprint X+2 — see `docs/StaticPages.md`).
`ProjectEditorFacade` exposes: `loadBootstrap()`, `updateBootstrap(updater)`, `exportBootstrap()`, `importBootstrap()`, `preview()`, `save()`, `publish()`, `undo()`, `redo()`, plus signals `bootstrap`, `status` (`draft|published`), `dirty`, `canUndo`, `canRedo`, `lastSavedAt`, `lastPublishedAt`, `validationIssues`, `blockingIssues`, `hasBlockingIssues`, `issuesByField`, `issuesBySection`, `modifiedFields`, `modifiedSections`, `changeSummary`, `homepageWidgets`, `homepagePage`, plus the `fieldError(key)` method. Components in `sections/*` inject this facade directly (an accepted exception to the presentational-component rule, per ADR-006 — these are container/section components, not shared UI). See "Configuration schema, form engine, and validation architecture" below for the schema/validator/undo internals.
| Languages | `languages-section` | add/remove supported locale, set default locale; syncs translation keys across static pages and nav labels via `LocaleSyncService` |
| Preview | `preview-section` | export/import JSON, in-memory runtime preview without full reload |
## Save / publish / draft / reset model
- **Save**: `save()` snapshots the current in-memory bootstrap as "last saved" (`lastSavedAt`). `ProjectEditorDraftStorageService` persists the full draft to `localStorage` (`projectEditor.draftBootstrap.v1`, scoped by `tenant.id`) on every `updateBootstrap()`, `save()`, and `publish()` call.
- **Publish**: runs `ProjectValidator`; if clean, calls `PlatformRuntimeService.reloadFromBootstrap()`, sets `status = 'published'`, sets `lastPublishedAt`, and becomes the new `originalBootstrap` baseline used by reset.
- **Draft restore**: on `loadBootstrap()`, if a stored draft exists for the same tenant it loads instead of the fresh fetch, and `draftRestored` is set (shown as a dismissible banner in the save bar).
- **Reset section**: reverts one section's bootstrap keys (per `EDITOR_SECTION_BOOTSTRAP_KEYS` in `models/project-editor.model.ts`) to `originalBootstrap`. Confirmation required.
- **Reset draft**: reverts the entire bootstrap to `originalBootstrap` and clears the persisted local draft. Confirmation required.
- **Per-field reset is not implemented** — no per-field default registry exists; only section- and project-level reset.
- **No backend persistence exists for any of this today** — see `docs/BACKEND_API.md#67-builder--bootstrap-draftpublishvalidate-planned-highest-priority` for the endpoints needed.
**Approach: metadata-augmented, not fully schema-driven.** Section templates stay hand-authored (`sections/*.component.html`); a field-schema registry sits alongside them as the single source of truth for field identity, labels, and validator wiring. This was chosen over a schema-driven renderer to preserve every existing template/UX pixel-for-pixel while still centralizing metadata and validation — the highest-value, lowest-regression-risk option given 11 mature section templates already built on the `shared/ui` primitives (see the primitives table above).
### Field-schema registry (`schema/`)
-`field-schema.model.ts` — `FieldSchema`: `{ key, section, type, labelKey, hintKey?, default?, required?, validators? }`. `key` is a dot path into `BootstrapConfig` (e.g. `theme.palette.primary`), unique per section. `validators` references reusable validator names (`hexColor`, `url`, `email`, `json`, `css`, `localeCompleteness`, `duplicateRoutes`, `widgetConfig`) rather than embedding logic.
-`editor-schema.ts` — `SECTION_FIELD_SCHEMAS`: every editable field, one entry per section, sourced from what each template already renders. `ALL_FIELD_SCHEMAS` flattens it.
-`editor-schema.service.ts` (`EditorSchemaService`, `providedIn: 'root'`) — `getFields(section)`, `getField(key)`, `all()`, `getByPath(source, key)` (safe dot-path resolver, never throws on a missing segment).
The schema is currently consumed by the facade (validation issue → field mapping, modified-field diffing, change-summary labels), not by the templates directly — templates keep calling `facade.updateBootstrap()` the same way they always did.
### Centralized validators (`schema/validators/`)
`primitives.ts` holds pure, framework-free functions — one per concern, reused everywhere that concern appears: `isValidHexColor`, `isValidHttpUrl`, `isValidEmail`, `validateJson`, `validateCss` (brace-balance check, comments stripped), `extractStyleBlocks` (pulls `<style>` bodies out of static-page HTML), `normalizeRoute` (trim/strip-slashes/lowercase for duplicate comparison).
`ProjectValidator` (`services/project-validator.service.ts`) composes these primitives into checks and tags every `ProjectValidationIssue` with `section`, `fieldKey`, and `severity` (`'error'` blocks Publish, `'warning'` is advisory). Checks: missing `branding.logoUrl`, no supported locales, **default locale not itself in the supported-locales list** (error — catches General's free-text default-language field pointing at an unsupported code), invalid `tenant.websiteBaseUrl`, duplicate static-page slugs, **duplicate routes** across `pages`/`staticPages` (warning), empty homepage, a homepage widget with no `type`, **malformed widget config** — missing `id`/`type`/`version`/`props` (error), duplicate header nav links, invalid theme colors, **invalid CSS** inside static-page `<style>` blocks (warning), missing translations for a supported locale, layout/section-layout values outside the known enums, **invalid company contact email** (error), **invalid footer social-link URL** (warning), and **incomplete footer payment icon** — only one of `src`/`alt` set (warning).
`ProjectEditorFacade` exposes, on top of `validationIssues`: `blockingIssues` / `hasBlockingIssues` (severity-filtered), `issuesByField: Map<string, ProjectValidationIssue[]>`, `issuesBySection: Map<ProjectEditorSectionId, number>`, and `fieldError(key)` (first message for a field, or `null`). `publish()` gates on `hasBlockingIssues()`, not "any issue" — a duplicate-route or invalid-CSS warning no longer blocks publishing. Sections bind `[error]` on `app-form-field` (or a standalone `<p class="editor-error">` where the target isn't a single form-field, e.g. a whole list) for every field that has a matching `ProjectValidator``fieldKey` today: theme palette, general name/domain/default-locale, branding logo, languages (`localization.supportedLocales`), homepage/widgets (`pages`), navigation (`navigation.header`), static-pages (`staticPages`), and footer (contact email, social links, payment icons). Header/features have no matching validator checks (every field there is a bool/enum, always valid by construction), so nothing is wired there — not an oversight. `project-editor-nav` shows a red badge with the blocking-issue count per section.
A pure, framework-free reducer (`emptyHistory`, `commit`, `undo`, `redo`) over immutable `BootstrapConfig` snapshots, capped at 50 entries. The facade wraps it with **debounced commits** (~300ms): `updateBootstrap()` captures the pre-burst snapshot on the first call in a burst and only pushes it to history once edits settle, so a run of rapid typing collapses into one undo step instead of one per keystroke. `undo()`/`redo()` route through the same draft-save path as every other mutation, so the `localStorage` autosave never desyncs from the in-memory undo stack. History is cleared on `loadBootstrap()`, `publish()`, and `resetDraft()` (a fresh baseline invalidates old snapshots). UI: save-bar Undo/Redo buttons (`canUndo`/`canRedo`), `Ctrl/Cmd+Z` / `Ctrl/Cmd+Shift+Z` / `Ctrl/Cmd+Y` page-level shortcuts (skipped while a text field has focus, so native per-field text undo still works).
### Modified-field tracking
`modifiedFields` (facade, `computed<Set<string>>`) diffs every schema field's current value against `originalBootstrap`. `modifiedSections` rolls that up per section for an amber dot in the nav (shown only when a section has no blocking-issue badge). `changeSummary` builds the before/after rows (schema label + stringified value, truncated for objects) consumed by the Preview section below.
### Pre-publish preview
The Preview tab (`preview-section`) now opens with a "changes since last publish" card: the full validation issue list (warning/error styled) plus a before/after table from `changeSummary`, ahead of the existing export/import/live-preview card. Reuses the existing `ProjectEditorPreviewService.preview()` — no new preview mechanism, just more visibility before triggering it.
### What stayed the same
No changes to `ProjectEditorIoService` (export/import), `ProjectEditorDraftStorageService` (draft `localStorage` format), or the publish/draft/reset flow described above — draft/publish/import/export compatibility is fully preserved. Section templates are unchanged except for `[error]` bindings on already-existing `app-form-field` usages.
Admin login shares the exact same Telegram QR/session backend and `TelegramLoginComponent` as customer login (`mode: 'admin'` vs `'customer'`) — only the cookie name/`SameSite` policy, token storage keys, and guard differ. **Backend gap:** because both flows hit the same session endpoint, the backend cannot distinguish an admin scan from a customer scan today — real admin authorization must be enforced server-side. Full detail: `docs/BACKEND_API.md#25-the-admin-authorization-gap-critical--security-relevant-unresolved`.
## Design system primitives (post-Sprint 30 redesign)
All 11 section components (`sections/*.component.html`) share these 6 `shared/ui/*` primitives instead of copy-pasted markup. They mirror the existing `InputComponent` CVA idiom (standalone, `OnPush`, `NG_VALUE_ACCESSOR` where they're form controls):
| Primitive | Selector | Replaces | Used in |
|---|---|---|---|
| `ToggleComponent` | `app-toggle` | raw `<input type="checkbox">` + `$any($event.target).checked` | header, homepage, widgets, navigation, features |
`section.shared.scss`'s `.editor-grid.*` classes are unchanged and still used inside `SectionCard` bodies; only the outer `.editor-section-card` shell and per-section `<h2>` were replaced (that rule has been removed from the shared stylesheet since it has no remaining consumers).
Interaction feedback + motion applied consistently, all gated behind `prefers-reduced-motion`:
-`section.shared.scss``button`/`button.secondary` gained hover/active/`focus-visible`/`disabled` states (previously flat, no feedback across all 11 sections).
-`project-editor-page.component.scss`: the active section fades/slides in (220ms) when the `@switch` swaps components; the "reset section" button got matching hover/focus states.
-`project-editor-save-bar` buttons now use the shared `app-button` primitive (danger / secondary / primary variants) instead of unstyled native `<button>`s.
## HTML editor (Static Pages)
`MarketplaceHtmlEditorComponent` (`components/html-editor/marketplace-html-editor.component.ts`) is a `contenteditable` WYSIWYG used inside the Static Pages editor (`features/content-management/.../static-pages-editor.component.html`), one instance per locale, bound `[html]` / `(htmlChange)`.
**Status: working.** Verified live 2026-07-16 — typing captured, toolbar commands functional (bold toggles, `insertUnorderedList` wraps `<ul><li>`, H2/H3/link/image/table), `htmlChange` emits on every edit, and a "Код" toggle swaps to raw-HTML editing.
**Toolbar (Sprint X+2 additions):** horizontal rule, code block (`<pre>`), embed (prompt for a URL, inserts a sandboxed `<iframe sandbox="allow-scripts allow-same-origin">`) — alongside the original bold/italic/underline/H2/H3/lists/link/image/table set.
**HTML-mode validation (Sprint X+2):** switching from raw-HTML back to the visual surface now runs `schema/validators/primitives.validateHtml` (stack-based tag-balance check) first; a malformed edit (unclosed/mismatched tag) stays in code mode with an inline error instead of silently corrupting the visual editor.
**Caveat:** implemented on the deprecated `document.execCommand` API. It works in all current browsers today but is a legacy web API with no modern drop-in replacement; if a future browser drops it, this component needs a rewrite (e.g. a maintained rich-text library). By design it emits **raw, unsanitized** HTML — sanitization is a storefront-render concern, not an authoring one (see `docs/BACKEND_API.md#68-builder--content-pages--cms-planned` on server-side content moderation on publish; `StaticPageComponent` and `StaticPagePreviewComponent` both run content through `DomSanitizer` before render).
Every field across the 10 editor section templates now carries a one-line, i18n'd description under its label explaining what it does in plain language (all new copy routed through `TranslateService`/`TranslatePipe`, added to `Translations` + `en.ts`/`ru.ts`/`hy.ts` following the existing `builder.*` key pattern — see `src/app/i18n/translations.ts`).
**Converted from free-text `<input>` to `<select>`** (backed by a closed TypeScript union), each option carrying a human label and a short description (via `title` attribute) instead of the raw enum value:
Each of these components defines a local `readonly` options array of `{ value, labelKey, descriptionKey }` (per ADR-006, these are section/container components so this is allowed without a new shared UI library).
**Still plain text/checkbox, with a description added, and why:** marketplace name, domain, description, logo/favicon/small-logo URLs, palette colors (already `<input type="color">`, which is the correct native widget), company/address/phone/email, copyright, payment icons/social links (JSON-ish textarea), homepage section `columns` (a number, not an enum), widget-specific props (`hero`/`categories`/`product-collection` typed fields like layout/height/overlay/autoplay/cardsPerRow — these are widget `props` strings/booleans, not modeled as TypeScript unions anywhere, so they stay free text/checkbox with a description rather than a fabricated enum), navigation link label/URL, and the widget JSON fallback textarea for any widget type without a dedicated editor. These are genuinely open-ended or already have the correct native input type; converting them to `<select>` would either be wrong (URLs/colors/free text) or invent an enum that doesn't exist in the schema.
A section-by-section correctness audit (not a feature pass) — for each section, checked whether its controls actually do what they claim at runtime, not just whether they render. 9 real, verified defects found and fixed (each confirmed live via `window.ng.getComponent()` reproducing the exact bug, then re-verified fixed):
- **Footer**: `createSocialLinkRow`'s id was derived from array length (`social-${length+1}`) — add/remove/add reliably collides with a surviving row's id, corrupting `footer.component.html`'s `@for (... track item.id)` DOM identity on the public storefront. Payment-icon `@for` tracked by `icon.src`, which collides whenever two rows share a src (most commonly two blank ones). Both switched to safe keys.
- **Features**: wishlist/compare visibility is gated by *two* flags at runtime (`featureFlags.<key>` AND `userExperience.<key>.enabled` — see `feature-config.service.ts`), but the editor only exposed a toggle for the first. Both default `true` so it was silent, but a config with the second explicitly `false` left the toggle looking "on" with no way to fix it from this screen. Now one toggle drives both.
- **Widgets**: the JSON-fallback textarea's `updateJson()` caught parse errors and did nothing, but the textarea was bound to `propsJson(committed props)` — so an in-progress invalid edit got silently overwritten on the next change-detection pass. Now keeps the user's draft on screen with an inline error until it's valid.
- **Languages**: `addLocale()` cleared the input regardless of whether `LocaleSyncService` actually accepted the code — adding an already-supported locale silently no-opped. Now shows an inline error and leaves the input untouched.
- **Preview**: `importBootstrap()` replaced `state.bootstrap` directly instead of routing through `updateBootstrap()` — so an import never got a `draftStorage.save()` (lost on refresh before an explicit Save) and was never an undo-able history step. Now routed through the same pipeline as every other edit.
- **Static Pages**: `createPage()`'s slug (`custom-page-${length+1}`) and `duplicatePage()`'s slug/route (fixed `-copy` suffix) both reproducibly collide the same way as the footer bug above (create/delete/create; duplicate the same page twice). Added a shared `uniqueValue()` helper (appends `-2`, `-3`, ... until free).
- **General**: "Supported Languages" is a free-text comma list that bypassed `LocaleSyncService` entirely, so adding a locale here never seeded the empty translation entries Languages' add-button produces — the two UI paths silently diverged. Now diffs and routes through `facade.addLocale()`/`removeLocale()`. Also added the "default locale not itself supported" validator check described above, since this field had (and still has, by design — it's free text) no format guard.
- **Branding → SEO**: `branding.socialImageUrl` (added earlier this same pass) wasn't actually read by `SeoService.resetToDefaults()` — the OG/Twitter image fallback stayed on `appIconUrl || logoUrl`. Fixed to check it first.
- **Media picker**: `MediaLibraryFacade` is a root-provided singleton shared by *every*`app-media-picker` instance on a page (branding alone renders 4). `ngOnInit` loaded unconditionally on mount regardless of dialog state, and `search`/`folder`/`page` filters leaked between independently-opened picker dialogs. Replaced with an `effect()` that resets those filters and loads only when that instance's own `open` input actually becomes `true`.
### Known gaps found but not fixed (real, out of scope for this pass)
- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix.
- **`layout.type` (Site Layout) and the homepage section's `type` field both feed a rendering pipeline that was never wired up.** `src/app/dynamic-renderer/` has services/models for page/section/widget rendering but zero components or templates (every directory has only a `.gitkeep`) — the storefront homepage renders through a separate, older path that ignores both fields. `homepage-section.component.ts`'s `updateSection(id, 'type', ...)` has no UI calling it because of this; not built, since building UI for a field nothing reads would be inventing dead controls.
- **`HeaderConfig.showProfile`** is a real toggle in `header-section` with no corresponding profile/account menu anywhere in `header.component.html` — the toggle currently does nothing. Building the actual menu is a feature (needs an auth-system check first), not an editor-wiring fix.