Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -0,0 +1,249 @@
|
||||
# Site Builder Hardening + Asset Picker Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix the full 2026-07-12 Fable audit — Critical stored-XSS + High data-loss bugs, the Medium/Low correctness/dead-code/a11y backlog — and ship the unified image/asset picker, across the Craft.js site builder.
|
||||
|
||||
**Architecture:** Work proceeds in dependency-ordered phases. Phase A builds a shared escaping/URL util that many later tasks consume, so it lands first. Phases that edit overlapping component files are serialized; genuinely disjoint tasks within a phase may run in parallel. Every task is TDD where a behavior is testable, and ends in a commit.
|
||||
|
||||
**Tech Stack:** Vite 6 + React 18 + TypeScript 5 (strict) + @craftjs/core 0.2.x + Vitest. DOMPurify already a dependency.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Active source is `craft/` only. Never touch the legacy top-level GrapesJS builder.
|
||||
- Verify with `cd craft && npx vitest run` and `cd craft && npm run build` (runs `tsc` — strict). Both must be green before a task is "done".
|
||||
- No native `alert/confirm/prompt` in any UI intended for the WHP panel (WHP UI ban). Use existing WHP/in-app patterns; do NOT add `window.confirm`.
|
||||
- `toHtml` output must stay deterministic (no `Math.random()`/`Date.now()` in export paths) so published HTML diffs/caches cleanly.
|
||||
- Inline styles only for user content; editor chrome uses `editor.css`. Follow existing component pattern (render + `.craft` + static `toHtml`).
|
||||
- Frequent commits: one per task minimum. Conventional-commit messages. End commit messages with the Co-Authored-By trailer used in this repo.
|
||||
- Path alias `@/` → `craft/src/`.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Escaping/URL foundation (Critical XSS). Blocks C, and precedes D–G edits on shared files.
|
||||
|
||||
### Task A1: Shared `escape.ts` util
|
||||
|
||||
**Files:**
|
||||
- Create: `craft/src/utils/escape.ts`
|
||||
- Test: `craft/src/utils/escape.test.ts`
|
||||
- Modify: `craft/src/utils/html-export.ts` (delete local `escapeHtml` at ~:201, import from `escape.ts`)
|
||||
|
||||
**Produces (consumed by nearly every later task):**
|
||||
```ts
|
||||
export function escapeHtml(s: string): string; // & < > " — text/content + double-quoted attrs
|
||||
export function escapeAttr(s: string): string; // escapeHtml + ' + ` — any attribute context
|
||||
export function safeUrl(s: string): string; // '' for javascript:/vbscript:/data:text/html schemes (case/space/entity-insensitive), else trimmed input
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write failing tests** covering: `escapeHtml('a & b <c> "d"')` → `a & b <c> "d"`; `escapeAttr("' \` ")` escapes single-quote and backtick; `safeUrl('javascript:alert(1)')` → `''`; `safeUrl(' JavaScript:alert(1)')` → `''`; `safeUrl('data:text/html;base64,x')` → `''`; `safeUrl('https://x.com/a?b=1&c=2')` unchanged; `safeUrl('/relative')` unchanged; `safeUrl('mailto:x@y.com')` unchanged; `safeUrl('#anchor')` unchanged; non-string / null-ish inputs coerce safely.
|
||||
- [ ] **Step 2: Run** `npx vitest run src/utils/escape.test.ts` → FAIL.
|
||||
- [ ] **Step 3: Implement** `escape.ts`. `safeUrl` normalizes by lowercasing, stripping whitespace and HTML entities/control chars, then rejecting a `^(javascript|vbscript|data:text/html)` scheme; allow all other/relative URLs.
|
||||
- [ ] **Step 4: Update `html-export.ts`** to import `escapeHtml` from `escape.ts`; delete its private copy.
|
||||
- [ ] **Step 5: Run** `npx vitest run src/utils/escape.test.ts` + `npm run build` → PASS.
|
||||
- [ ] **Step 6: Commit** `feat(builder): add shared escape/safeUrl util`.
|
||||
|
||||
### Task A2: Replace the 27 local `esc`/`escapeHtml` copies
|
||||
|
||||
**Files (modify — import `escapeHtml`/`escapeAttr` from `@/utils/escape`, delete the local def):** every file matching `grep -rlE "const (esc|escapeHtml)\s*=|function (esc|escapeHtml)" craft/src`. Includes (non-exhaustive) `components/basic/{ButtonLink,Icon,SocialLinks,Logo,Menu,Navbar,Footer,StarRating}.tsx`, `components/sections/{HeroSimple,CTASection,CallToAction,FeaturesGrid,PricingTable,Gallery,ContentSlider,Testimonials,Countdown,Tabs,Accordion,SubscribeForm,MapEmbed,...}.tsx`, `components/layout/{Section,BackgroundSection,Container,ColumnLayout}.tsx`, `components/forms/*.tsx`, `components/basic/_cta-helpers.tsx`.
|
||||
|
||||
**Interfaces — Consumes:** A1's `escapeHtml`/`escapeAttr`.
|
||||
|
||||
- [ ] **Step 1:** For each file, replace the local escaper with the import. Use `escapeAttr` for values emitted inside attributes (`title=`, `alt=`, single/double-quoted attrs); `escapeHtml` for text-node content. This fixes the "omits `&`" and "omits quotes" variants at once.
|
||||
- [ ] **Step 2:** `grep -rE "const (esc|escapeHtml)\s*=|function (esc|escapeHtml)" craft/src` → only `escape.ts` remains (0 others).
|
||||
- [ ] **Step 3:** `npm run build` → PASS (tsc catches missed imports).
|
||||
- [ ] **Step 4:** Run existing toHtml tests `npx vitest run` → PASS.
|
||||
- [ ] **Step 5: Commit** `refactor(builder): use shared escaper everywhere, drop 27 copies`.
|
||||
|
||||
*(May be split into 2-3 commits by directory for review sanity; behavior-preserving.)*
|
||||
|
||||
### Task A3: `safeUrl` on all exported URLs
|
||||
|
||||
**Files (modify `toHtml` href/src/action/`url(...)` emission):** `basic/ButtonLink.tsx:230`, `basic/Icon.tsx:303,320`, `basic/SocialLinks.tsx:440`, `basic/Logo.tsx:417`, `basic/Menu.tsx:497`, `basic/Navbar.tsx:856-918`, `basic/_cta-helpers.tsx:86`, `sections/ContentSlider.tsx:484`, `sections/FeaturesGrid.tsx:292`, `sections/PricingTable.tsx:447`, `media/ImageBlock.tsx:479`, `media/VideoBlock.tsx:725,792`, `sections/Gallery.tsx` img `src`, background-image emitters `layout/BackgroundSection.tsx:191`, `sections/HeroSimple.tsx:421,441`, `sections/CallToAction.tsx:379`, `layout/Section.tsx` bg.
|
||||
|
||||
**Consumes:** A1 `safeUrl`, `escapeAttr`.
|
||||
|
||||
- [ ] **Step 1:** Wrap each exported URL: `href="${escapeAttr(safeUrl(props.href||''))}"`; for CSS `url(...)` use `url('${escapeAttr(safeUrl(x))}')`.
|
||||
- [ ] **Step 2: Write regression tests** (in each component's `.toHtml.test.ts`, or a new `toHtml-escaping.test.ts`): a `javascript:` href renders empty; a `"`-containing href stays inside its attribute; bg `url()` with `")` cannot break out.
|
||||
- [ ] **Step 3:** `npx vitest run` + `npm run build` → PASS.
|
||||
- [ ] **Step 4: Commit** `fix(builder): neutralize javascript:/breakout URLs in HTML export`.
|
||||
|
||||
### Task A4: JS-string / raw-HTML context fixes
|
||||
|
||||
**Files:** `basic/HtmlBlock.tsx:144`, `sections/Countdown.tsx:298`, `sections/Gallery.tsx:296`.
|
||||
|
||||
- [ ] **Step 1: Tests** — HtmlBlock `toHtml({code:'<script>x</script>'})` output is DOMPurify-sanitized (no raw `<script>`); Countdown `toHtml` with `targetDate:'2026-01-01");alert(1)//'` produces no injected JS (date rejected/blanked or JSON-encoded); Gallery lightbox uses `data-src` (no `onclick="..._open('...')"` interpolation).
|
||||
- [ ] **Step 2:** Run → FAIL.
|
||||
- [ ] **Step 3: Implement:** HtmlBlock `return { html: purifyHtml(props.code || '') }`. Countdown: validate `/^\d{4}-\d{2}-\d{2}([T ].*)?$/`; emit `var target=new Date(${JSON.stringify(validDate)}).getTime()`. Gallery: emit `data-src="${escapeAttr(img.src)}"` + `class="..._thumb"`, attach one delegated click listener in the gallery's inline script that reads `getAttribute('data-src')`.
|
||||
- [ ] **Step 4:** Run → PASS; `npm run build`.
|
||||
- [ ] **Step 5: Commit** `fix(builder): sanitize HtmlBlock export, JS-context in Countdown/Gallery`.
|
||||
|
||||
### Task A5: Escape values flowing into `cssPropsToString`
|
||||
|
||||
**Files:** `craft/src/utils/style-helpers.ts` (`cssPropsToString`).
|
||||
|
||||
- [ ] **Step 1: Test** a style value containing `";color:red;background:url(x)"` cannot terminate the declaration / inject extra props; `url()` contents run through `safeUrl`.
|
||||
- [ ] **Step 2:** Implement value sanitization (strip/encode `"`, `;` where they'd break out; `safeUrl` inside `url(...)`). Keep valid multi-value styles intact.
|
||||
- [ ] **Step 3:** `npx vitest run` (all toHtml tests still pass) + `npm run build`.
|
||||
- [ ] **Step 4: Commit** `fix(builder): sanitize style-string emission`.
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Data-loss / state corruption (High). Independent of A; disjoint files, may run parallel to A.
|
||||
|
||||
### Task B1: Node-id regeneration for copy/paste/duplicate
|
||||
|
||||
**Files:**
|
||||
- Create: `craft/src/utils/craft-tree.ts` — `regenerateTreeIds(query, tree): NodeTree`
|
||||
- Modify: `panels/context-menu/ContextMenu.tsx` (`duplicate`, `pasteNode`), `hooks/useKeyboardShortcuts.ts` (duplicate)
|
||||
- Test: `craft/src/utils/craft-tree.test.ts`
|
||||
|
||||
**Produces:** `regenerateTreeIds(query, tree)` → a `NodeTree` with every `rootNodeId`, `nodes` key, `.id`, `.data.parent`, and `linkedNodes` value remapped to fresh ids (via Craft's id generator), preserving structure.
|
||||
|
||||
- [ ] **Step 1: Test** — given a serialized 3-node subtree, `regenerateTreeIds` returns a tree whose id set is disjoint from the input's and internally consistent (parents/linkedNodes point to new ids).
|
||||
- [ ] **Step 2:** Run → FAIL.
|
||||
- [ ] **Step 3: Implement** helper. Update `duplicate`/`pasteNode`/keyboard-duplicate to `addNodeTree(regenerateTreeIds(query, tree), parentId)`. Fix `pasteNode` parent: insert as sibling of the target (target's `data.parent`), fallback `ROOT`. Remove the dead `clonedTree` computation in `duplicate`.
|
||||
- [ ] **Step 4:** Run + `npm run build` → PASS.
|
||||
- [ ] **Step 5: Commit** `fix(builder): regenerate node ids on duplicate/paste to prevent state corruption`.
|
||||
|
||||
### Task B2: Header/Footer save routing
|
||||
|
||||
**Files:** `hooks/useWhpApi.ts` (`save`), `state/PageContext.tsx` (confirm the commit-current-state fn name + `isEditingHeader/Footer` flags), test `craft/src/hooks/useWhpApi.save.test.ts`.
|
||||
|
||||
- [ ] **Step 1: Test** — with `activePageId==='__header__'` and a live canvas, `save()`'s payload puts the live serialization in `header_craft_state`/`header_html` and does NOT overwrite a normal page's slot nor the top-level `craft_state` with header content. (Mock `fetch`, `usePages`, `query.serialize`.)
|
||||
- [ ] **Step 2:** Run → FAIL.
|
||||
- [ ] **Step 3: Implement:** before serializing, commit the live canvas to the active zone (call PageContext's existing save-current-state). Branch on editing-header/footer: assign live serialization to header/footer slots; otherwise current behavior. Ensure header/footer HTML is exported from the freshly-committed state, not stale `headerPage.craftState`.
|
||||
- [ ] **Step 4:** Run + `npm run build` → PASS.
|
||||
- [ ] **Step 5: Commit** `fix(builder): route live header/footer edits correctly on save`.
|
||||
|
||||
### Task B3: AI-boundary validation
|
||||
|
||||
**Files:** `craft/src/utils/apply-ai-response.ts`, test `apply-ai-response.test.ts` (extend).
|
||||
|
||||
- [ ] **Step 1: Tests** — a node with unknown `resolvedName` is skipped (no throw); `update_props` cannot overwrite `node_id`/`style`; AI `node_id==='ROOT'` or duplicate is rejected/regenerated.
|
||||
- [ ] **Step 2:** Run → FAIL.
|
||||
- [ ] **Step 3: Implement:** validate `resolvedName` against `componentResolver` keys; allowlist `update_props` keys per node (exclude `node_id`; merge `style` shallowly, never replace wholesale via blind assign); dedupe/validate ids; fail soft + `console.warn`. Reuse `PageContext.treeToState` guard logic.
|
||||
- [ ] **Step 4:** Run + `npm run build` → PASS.
|
||||
- [ ] **Step 5: Commit** `fix(builder): validate AI response before deserializing into craft state`.
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Unified asset picker. AFTER Phase A (shares component files: ImageBlock/Logo/Navbar/Gallery/Features/etc.).
|
||||
|
||||
### Task C1: `utils/assets.ts`
|
||||
|
||||
**Files:** Create `craft/src/utils/assets.ts`, test `assets.test.ts`. Modify `panels/right/styles/shared.tsx` to re-export `uploadToWhp` as a thin wrapper over `uploadAsset`.
|
||||
|
||||
**Produces:**
|
||||
```ts
|
||||
export interface Asset { name: string; url: string; type: string; }
|
||||
export async function uploadAsset(file: File): Promise<string | null>; // POST upload_asset; blob: fallback if no WHP_CONFIG
|
||||
export async function listAssets(mediaType?: 'image'|'video'|'any'): Promise<Asset[]>; // [] if no WHP_CONFIG; filter by type prefix
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests** — `uploadAsset` w/o `WHP_CONFIG` returns a `blob:` URL; with config, POSTs to `upload_asset` and returns `data.url`; `listAssets('image')` returns only `type` startsWith `image`; `listAssets()` w/o config returns `[]`. (Mock `fetch`, `window.WHP_CONFIG`, `URL.createObjectURL`.)
|
||||
- [ ] **Step 2:** Run → FAIL.
|
||||
- [ ] **Step 3: Implement** (bodies lifted from existing `shared.tsx` `uploadToWhp` + the `list_assets` fetch pattern). Update `shared.tsx` `uploadToWhp` → `export const uploadToWhp = uploadAsset`.
|
||||
- [ ] **Step 4:** Run + `npm run build` → PASS.
|
||||
- [ ] **Step 5: Commit** `feat(builder): shared asset upload/list util`.
|
||||
|
||||
### Task C2: `ui/AssetPicker.tsx`
|
||||
|
||||
**Files:** Create `craft/src/ui/AssetPicker.tsx`, test `AssetPicker.test.tsx`.
|
||||
|
||||
**Consumes:** C1 `uploadAsset`/`listAssets`.
|
||||
**Produces:**
|
||||
```ts
|
||||
interface AssetPickerProps {
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
mediaType?: 'image'|'video'|'any'; // default 'image'
|
||||
variant?: 'full'|'compact'; // default 'full'
|
||||
placeholder?: string;
|
||||
}
|
||||
export const AssetPicker: React.FC<AssetPickerProps>;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests** — renders `value`; grid select calls `onChange(asset.url)`; URL "Apply" calls `onChange(input)`; `listAssets` called with `mediaType`; Browse hidden when no `WHP_CONFIG`; `variant="compact"` renders the compact row.
|
||||
- [ ] **Step 2:** Run → FAIL.
|
||||
- [ ] **Step 3: Implement** both variants sharing all logic (upload/browse/url state); `full` mirrors current `ImageStylePanel` layout, `compact` the tight row. File-input `accept` and grid filter from `mediaType`.
|
||||
- [ ] **Step 4:** Run + `npm run build` → PASS.
|
||||
- [ ] **Step 5: Commit** `feat(builder): reusable AssetPicker (full/compact, media-typed)`.
|
||||
|
||||
### Task C3: Wire AssetPicker into all image fields
|
||||
|
||||
**Files (replace ad-hoc image input with `<AssetPicker>`):**
|
||||
- Full: `panels/right/styles/ImageStylePanel.tsx`, `MediaStylePanel.tsx`, `BackgroundSectionStylePanel.tsx`, `HeroStylePanel.tsx` (image), `components/layout/Container.tsx` (bg), `components/basic/Logo.tsx`, `components/basic/Navbar.tsx` (logo), `components/media/ImageBlock.tsx`.
|
||||
- Compact: `panels/right/styles/FeaturesEditor.tsx`, `components/sections/FeaturesGrid.tsx`, `components/sections/Gallery.tsx`, `components/sections/ContentSlider.tsx`.
|
||||
|
||||
- [ ] **Step 1:** For each, render `<AssetPicker value={x} onChange={v=>setProp(...x=v)} variant={full|compact}/>`, writing to that consumer's existing prop. Remove now-dead local `showBrowser/browserAssets/handleBrowse/handleUpload` state.
|
||||
- [ ] **Step 2:** `npm run build` → PASS; `npx vitest run` → PASS (toHtml unchanged).
|
||||
- [ ] **Step 3: Manual smoke** via `npm run dev`: each field shows Upload+Browse+URL and selecting an asset updates the canvas.
|
||||
- [ ] **Step 4: Commit** (split by group) `feat(builder): unify image fields on AssetPicker`.
|
||||
|
||||
### Task C4: Remove duplicated upload/browse code
|
||||
|
||||
- [ ] **Step 1:** Delete leftover local `uploadToWhp` copies (FeaturesEditor etc.) + HeroStylePanel `AssetBrowser` now replaced. `grep` confirms no orphaned copies.
|
||||
- [ ] **Step 2:** `npm run build` + `npx vitest run` → PASS.
|
||||
- [ ] **Step 3: Commit** `refactor(builder): drop duplicated asset browse/upload code`.
|
||||
|
||||
---
|
||||
|
||||
## Phase D — Correctness / parity (Medium). After A (shared files).
|
||||
|
||||
- **D1 — Emit collected props.** Files: `forms/ContactForm.tsx` (render+export `successMessage`), `layout/Container.tsx` (apply `cssId`/`cssClass` in render + `toHtml`), `sections/FeaturesGrid.tsx` (make `mediaType` toggle actually select icon-vs-image instead of `feat.image` truthiness). Test each renders/exports. Commit `fix(builder): emit ContactForm/Container/FeaturesGrid props`.
|
||||
- **D2 — Export parity.** `sections/Testimonials.tsx` single-layout export matches editor (carousel markup or documented static + aria); `layout/ColumnLayout.tsx` width slider re-derives child widths in `toHtml`. Tests. Commit.
|
||||
- **D3 — Slug dedupe.** `state/PageContext.tsx` (`addPage`/`renamePage`/`replaceAllPages`) + `PagesPanel.tsx`: `uniqueSlug(base, existing)` appends `-2`, `-3`. Test collision. Commit.
|
||||
- **D4 — VideoBlock URL parsing.** `media/VideoBlock.tsx:32-38`: handle `youtu.be`, `/shorts/ID`, `/live/ID`, `watch?...&v=ID`, Vimeo `vimeo.com/ID/HASH`. Tests. Commit.
|
||||
- **D5 — TemplateModal race.** `panels/topbar/TemplateModal.tsx`: await the load sequence (promisified) then `onClose()`; show Loading state. Commit.
|
||||
- **D6 — Pure state updaters.** `state/PageContext.tsx:248-262,318-323`: move `loadState`/`setActivePageId`/ref mutations out of `setPages`/`setHeaderPage` updater callbacks into effects/post-set logic. Verify no double-deserialize under StrictMode. Commit.
|
||||
- **D7 — Footer edit guard.** `basic/Footer.tsx`: adopt the `editedTextRef`+commit-on-deselect pattern from `Heading.tsx`. Commit.
|
||||
- **D8 — Preview head + $-safe replace.** `panels/topbar/TopBar.tsx:189-198`: pass `headCode` to `exportToHtml`; replace `html.replace(bodyMatch[1], composedBody)` with a function replacer to avoid `$`-sequence corruption. Commit.
|
||||
- **D9 — headCode consolidation.** Remove dead per-page `headCode` (`types/index.ts:19` + `PageContext` hardcoded `''`); keep only `SiteDesign.headCode`. Commit.
|
||||
|
||||
Each D task: write/adjust a test, implement, `npx vitest run` + `npm run build` green, commit.
|
||||
|
||||
---
|
||||
|
||||
## Phase E — Dead code / maintainability (Medium). After C (so picker isn't removed as "dead").
|
||||
|
||||
- **E1 — Remove dead settings UI.** Confirm via `grep` that `RightPanel.tsx` renders only `<GuidedStyles/>` and that each `*Settings` component + all of `ui/` (AdvancedTab/TypographyControl/BorderControl/SpacingInput/AnchorIdField/SettingsTabs) is imported ONLY by `related.settings`. Delete the dead `related.settings` panels and the `ui/` files that become orphaned. Keep `ui/` files still used by live StylePanels (verify each individually before deleting). `npm run build` proves no live import breaks. Commit `chore(builder): remove dead related.settings panels + orphaned ui/`.
|
||||
- **E2 — HeaderZone/FooterZone.** They're absent from `resolver.ts` and dead → delete the files, and add a guard in `apply-ai-response`/`treeToState` that skips unknown `resolvedName` (already added in B3 — just confirm). Update `CLAUDE.md` stale component list/count. Commit.
|
||||
- **E3 — Consolidate tree-flatteners.** Extract one shared flattener used by `PageContext.treeToState` and `apply-ai-response.buildNodeTree`; delete dead `serializeTreeForCraft`. Ensure the `style:[]→{}` normalization is in the shared path. Tests for both callers. Commit.
|
||||
- **E4 — Panel/modal dedup.** Extract `useNodeProp(id)` hook into `shared.tsx` (replace `setProp`/`setPropStyle` boilerplate in the 9 style panels); extract the shared array-item field editor used by `SectionTypePanel` + `GenericPropsEditor`; extract one `<Modal>` for `TemplateModal`/`HeadCodeModal`/`SitesmithModal`; extract the shared nav-link editor (Menu↔Navbar). Behavior-preserving; `npx vitest run` green. Commit per extraction.
|
||||
- **E5 — Typing.** Type `StylePanelProps.nodeProps` per-panel where feasible; add `PatchOp`/`SerializedTreeNode` types at the AI boundary. Reduce `any` in `apply-ai-response.ts`/`treeToState`. `npm run build` strict green. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Accessibility (Medium). After C/E (touches widgets + panels).
|
||||
|
||||
- **F1 — Exported widget a11y.** `ContentSlider` (prev/next/dots `aria-label`, `aria-live`, pause control), `Tabs` (`role=tablist/tab/tabpanel`, arrow-key nav), `Gallery` lightbox (`role=dialog`, Escape close, focus trap, keyboard-operable thumbnails). Model on `Accordion`'s native `<details>`. Tests assert ARIA in `toHtml`. Commit per component.
|
||||
- **F2 — Exported semantics.** Form `<label for>`/input `id` association (`InputField`/`TextareaField`/`ContactForm`); `StarRating` `role="img"`+`aria-label`; `Navbar` hamburger accessible name + `aria-expanded`; iframe `title` (`VideoBlock`/`MapEmbed`); `aria-hidden` on decorative FA `<i>`. Tests. Commit.
|
||||
- **F3 — Editor chrome a11y.** Add `role="button"`/`tabIndex`/key handlers to clickable `<div>` rows (`PagesPanel`, `LayersPanel`, `AssetsPanel`, `TemplateModal` cards, `ImageStylePanel`/AssetPicker thumbnails); `aria-label` on topbar icon buttons. Commit.
|
||||
- **F4 — Confirm + clipboard.** `AssetsPanel` delete: add an in-app confirm consistent with the WHP no-native-dialog rule (reuse the app's existing confirm pattern, NOT `window.confirm`); also replace the existing `window.confirm` in `SitesmithModal.tsx:85`. `copyUrl` gets a `.catch` + fallback. Commit.
|
||||
- **F5 — Menu deterministic id.** `basic/Menu.tsx:482`: replace `Math.random()` id with a stable id (hash of content/props or a passed nodeId), matching Navbar. Test: two exports of unchanged Menu are byte-identical. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Phase G — Low. Last.
|
||||
|
||||
- **G1 — GuidedStyles cruft.** `panels/right/GuidedStyles.tsx`: remove unused `resolvedName`/`resolverMap`; anchor `/hero/i` like siblings; drop `isContainer` match for the removed Header/Footer Zone. Commit.
|
||||
- **G2 — Dev proxy.** `vite.config.ts:29`: point `/api` proxy at `whp-al10-test` (192.168.1.148:8080). Commit.
|
||||
- **G3 — Context-menu shortcuts.** Either implement Ctrl+C/Ctrl+V in `useKeyboardShortcuts.ts` (using B1's regen helper for paste) or remove the advertised hints in `ContextMenu.tsx:193,199`. Prefer implementing to match the UI. Test. Commit.
|
||||
- **G4 — Script nits.** ContentSlider autoplay: pause on hover/visibility + clear interval; Countdown: stop at zero; iframe `src`: emit `&` correctly. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Final verification (before report)
|
||||
|
||||
- [ ] `cd craft && npx vitest run` — all green; note count.
|
||||
- [ ] `cd craft && npm run build` — tsc strict + vite succeed.
|
||||
- [ ] `grep -rE "const (esc|escapeHtml)\s*=|function (esc|escapeHtml)" craft/src` → only `escape.ts`.
|
||||
- [ ] Manual `npm run dev` smoke: XSS payload in a text/href/date field is neutralized in exported HTML; duplicate/paste produces fresh ids; header edit + save persists to header slot; every image field offers Upload+Browse+URL.
|
||||
- [ ] Build the site-builder bundle and stage per `whp-deploy-release` (deploy itself is a separate, user-gated step — the report will note readiness, not auto-deploy).
|
||||
- [ ] Update `craft/CLAUDE.md` (component count/list) and this plan's checkboxes.
|
||||
|
||||
## Self-review notes
|
||||
- Spec coverage: security spec §1→A1-A5, §2→B1, §3→B2, §4→B3; asset-picker spec→C1-C4; Medium/Low backlog→D/E/F/G (each backlog bullet maps to a task).
|
||||
- Dependency ordering: A before C/D/E/F/G (escape util + shared-file edits); C before E (don't delete picker as dead); B independent.
|
||||
- Deploy is intentionally NOT automated here — user gates production per repo discipline.
|
||||
Reference in New Issue
Block a user