Commit Graph
76 Commits
Author SHA1 Message Date
shadowdaoandClaude Sonnet 5.5 2877bc06fa feat(site-builder): tokenise colours hard-coded inside components
Component render/toHtml now use var(--wsb-*) instead of hex so Site Design
palette changes reach FeaturesGrid, Testimonials, Accordion, PricingTable,
CTA/CallToAction, forms, Gallery, Tabs and others. Exemption lists removed.

Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:44:39 -07:00
shadowdaoandClaude Opus 5.5 dd8b99be06 feat(site-builder): reusable global sections with per-page overrides
Make a top-level section global (context menu), place instances from the
Section library's new Global tab, edit the master once in its own zone
("Editing global section" banner, Done - update N instances) and every
instance on every page/header/footer is rebuilt from it. Instances are real
inlined subtrees (custom.global on the root, custom.gkey on every node), so
export and published output are unchanged static HTML.

- utils/global-sections.ts: pure model (createMaster, instantiate,
  syncInstances, collectOverridesFromInstance, applyOverrides, detach,
  findInstanceRoot, master zone round trip). Unchanged input returns the
  identical string; sync keeps the instance root id and the instance's own
  inner ids per gkey.
- PageContext: __global__:<id> zone (editGlobal / finishGlobal); leaving it
  commits the master (rev + 1 only when it changed), syncs every stored
  slot and applies the sync to the returned-to canvas as ONE undo step.
- GlobalSectionsContext: makeGlobal / insertInstance / detach / setOverride
  / resetOverride / remove (refused while used), one undo step each.
- Inline text edits inside an instance record an override (text + html)
  via useGlobalOverrideCommit; GuidedStyles shows a Global section panel
  (Edit master, Detach, content override fields) instead of style panels.
- RenderNode: Global badge, dashed outline, lock icon on inner nodes.
- buildSavePayload: a master zone on the canvas is never saved into a page.

Refs #57

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:28:53 -07:00
shadowdao 48061d37ae Merge issue-60 (Refs #60) 2026-10-07 12:09:09 -07:00
shadowdao 0ac420a23c Merge issue-55-fe (Refs #55)
# Conflicts:
#	craft/src/styles/editor.css
2026-10-07 12:09:09 -07:00
shadowdaoandClaude Opus 5.5 52477bff45 feat(site-builder): menus that follow pages, with dropdowns
- utils/nav-links.ts: shared NavLink type (source/pageId/children),
  pageHref, buildPageLinks, mergeLinks, adoptPageLinks, rewriteHeaderLinks
- PageData.nav {show, parentId, label} + updatePageNav; a nav-sync effect in
  PageProvider materialises page-following links into the stored header
  (or the live header nodes while it is being edited); string/identity
  guards keep it a no-op when nothing changed
- Navbar/Menu: followPages prop (default false; the default header seed is
  true), one-level dropdowns (hover/focus-within + inline click toggle,
  indented in the hamburger layout), hover/CTA colours default to the
  primary token
- NavStylePanel: Follow pages toggle (adopts matching manual links, keeps
  custom text as the page's menu label), read-only page rows with Hide from
  menu, sub-link editor for manual links, LinkPicker records pageId
- Page Settings: Show in menu / Menu label / Nest under; Pages panel badge
- useWhpApi: nav passes through pages_craft_state on save and load

Legacy navs (no followPages) are never rewritten.

Refs #53

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:03:16 -07:00
shadowdao 2cb04d2537 feat(site-builder): local-business block pack (hours, call bar, reviews, booking)
Refs #60
2026-10-07 11:53:44 -07:00
shadowdao c0e0003460 feat(site-builder): asset search/filter, stock photo tab and background focal point
Search + type chips in the Assets panel and picker library; Stock photos
entry in the picker (feature-detected, hidden when stock_disabled/absent);
bgPosition focal point for Hero and Background Section.

Refs #55
2026-10-07 11:52:32 -07:00
shadowdao 98e32a68b9 Merge issue-46 (Refs #44 #46)
# Conflicts:
#	craft/src/editor/CanvasOverlays.tsx
#	craft/src/styles/editor.css
2026-10-07 11:47:54 -07:00
shadowdaoandClaude Opus 5.5 7e8812a752 feat(site-builder): inline bold/italic/underline/links on Heading and Text
Select text inside a selected Heading or Text block and a floating
toolbar offers B / I / U / Link / Unlink (execCommand; mousedown is
prevented so the selection survives; mod+b/i/u handled on the editable).

Storage: new `html` prop holding sanitised inline HTML, with `text` kept
as the plain mirror for AI summaries, search and legacy content.
Unmarked edits store html '' so legacy nodes keep their shape. The
allowlist (b strong i em u a br span; style=color only, hex/rgb/
var(--wsb-*); href via DOMPurify URI check AND safeUrl, unsafe links
unwrapped; target only _blank with forced rel=noopener) runs on commit,
on canvas render and again at export. If `text` was changed without
`html` (Sitesmith update_props), the stale html is ignored.

Each edit commits once (blur, or deselect if no blur came first) as one
setProp = one undo step; the old blur+deselect double commit is gone.
Heading colour default now tokenRef('text'). Sitesmith canvas summary
skips `html`.

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:41:56 -07:00
shadowdao 22c59eb970 feat(site-builder): tablet/mobile canvas emulates published hide, stacking and full-width
Frame gets data-device; RenderNode mirrors hide flags and selection onto the
dom; editor.css emulates the exported breakpoints inside the narrowed frame.
Also: data-wsb-overridden counts only set values, responsive scope ids are
zone-prefixed (h-/b-/f-) so ROOT no longer collides, and comment delimiters
are stripped from responsive declarations.

Refs #37
2026-10-07 11:39:25 -07:00
shadowdaoandClaude Sonnet 5.5 5006089a4a test(site-builder): real-editor test that new blocks serialize token defaults
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Sonnet 5.5 045643d570 feat(site-builder): Section background and dividers default to bg token
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Sonnet 5.5 488f208262 feat(site-builder): section blocks default to palette tokens
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Sonnet 5.5 af43af7dc1 feat(site-builder): form blocks default to palette tokens
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Sonnet 5.5 d70f4933af feat(site-builder): basic blocks default to palette tokens
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Opus 5 422697acec fix(site-builder): review findings on the webhook destination controls
I-1: WebhookSecretField now mounts with key={selectedId}. GuidedStyles renders
FormStylePanel with no key, so a selection change re-rendered rather than
remounted it and React kept `draft`/`status`. Since a failed store deliberately
retains the draft, clicking a second contact form showed node A's raw secret in
node B's field, and the next blur assigned the returned secret_id to the wrong
form (burning a slot against the per-site cap). The status banner leaked the
same way. Both pinned by the reviewer's repro sequence.

I-2: the button is "Clear", not "Remove", and says so -- nothing deletes a
stored key file, so the 50-per-site cap counts stores-ever. Labelling it Remove
told the customer they had reclaimed a slot right up until the 429 that said
otherwise. Actually deleting the file is Task 8's territory.

M-1: inline warning when the webhook URL is blank or not absolute https. The
publish step does refuse these, but into an error_log the customer never reads.
Warning only -- isHttpsWebhookUrl never edits the value or blocks the publish,
since either would trade a loud server-side refusal for a silently inert form.

M-2: the BYTE-IDENTITY test now compares against a literal marker captured by
executing the emitter at 071f3447, not against another head-revision output. The
self-comparison could only catch a drift affecting one side; a uniform one
passed it. Verified: a uniform `<!-- WHP-FORM` drift now fails this assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:57:38 -07:00
shadowdaoandClaude Opus 5 d1c57db967 feat(site-builder): webhook destination controls on the contact form
ContactForm gains four craft props (destinationType, webhookUrl,
webhookSecretId, webhookAuthMode), all present in craft.props defaults so
FormStylePanel's `nodeProps.X !== undefined` gates actually render their
controls. The controls live in FormStylePanel (RightPanel renders only
GuidedStyles, so related.settings would be dead UI).

relayFormWiring widens the marker to optionally carry type/url/secret/
authmode BETWEEN `id` and `recipient`, which is where FormRelayRewrite.php's
parser looks. A marker with no type is byte-identical to what shipped before
-- pinned by a test that diffs an explicit-email form against one with no
destination props at all, since every already-published site depends on that
shape continuing to provision an email endpoint.

Every optional attribute value goes through one escaping site (markerAttr ->
escapeAttr); type and authmode are additionally allowlisted, so a case-drifted
"Bearer" reaches the relay as the exact literal it compares against instead of
being silently downgraded to unsigned.

The raw shared secret is never a prop: it is held in WebhookSecretField's
local state, POSTed to /api/form-webhook-secret.php on blur, and only the
returned opaque id is persisted. The field is write-only (set / replace /
remove, never view) because the endpoint has no read route, and the endpoint's
429 cap message is surfaced verbatim so a customer can act on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:45:05 -07:00
shadowdaoandClaude Opus 5 98f2ebf118 Fix: HtmlBlock sanitizer strips select/meter presentation attrs
size, low, high, and optimum were missing from PURIFY_CONFIG.ALLOWED_ATTR
even though <select> and <meter> are already in ALLOWED_TAGS, so
<select size="4"> rendered at default height and <meter low/high/optimum>
lost its threshold-based gauge colouring. All four are pure
presentation/semantic attributes with no URL/script/event-handler
surface, so no security implication.

Also regenerates the pinned pre-Task-25 output fixture: its source
(html-block-test-body.html) already exercises size/low/high/optimum, so
the byte-identity test's expected output legitimately changes to include
them; verified the regenerated fixture's only diff from the prior one is
those four attributes now surviving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 07:23:36 -07:00
shadowdaoandClaude Opus 5 916a568e9f fix(site-builder): address Task 25 review findings on <style> scoping
Four issues from adversarial review of the block-scoped <style> feature:

1. (Critical) transformBlock() recursed once per @media/@supports/@container
   nesting level with no cap -- ~7000 nested rules blew the call stack, and
   nothing between a Custom HTML block's toHtml() and the publish pipeline
   catches exceptions, so this took down the whole page's publish and
   crashed the live editor on every keystroke. Added MAX_NESTING_DEPTH=20
   (pass the body through unscoped beyond it) and wrapped scopeCss() so it
   never throws on any input, matching repairOrphanNodes's existing
   contract. Caught and fixed a variable-shadowing bug in my own first pass
   at this: the new depth parameter was silently shadowed by a pre-existing
   `let depth` used for brace-matching in the same block, which would have
   defeated the cap with no type error.

2. (Important) FORCE_BODY: true was unconditional, but it isn't a no-op for
   style-free input: it also changes how the parser preserves whitespace
   after a LEADING html comment, which this repo's own fixture starts with.
   Verified via a raw byte-diff against HtmlBlock.tsx@6a9b227 (extracted
   verbatim, run standalone against real dompurify+jsdom) that the fixture
   gained bytes. Fixed by applying FORCE_BODY only when the input has a
   real (non-comment) <style> tag to rescue -- confirmed empirically that
   this is a true no-op for every other input. Pinned the old output as a
   checked-in regression fixture and added a raw toBe() diff test.

3. (Important) scopeStyleBlocks() wasn't idempotent -- pasting previously
   published/exported output into a fresh block nested a second wrapper
   and re-prefixed every selector. Added isAlreadyScoped(), which detects
   a lone root wrapper whose <style> content is already a no-op under
   scopeCss for that wrapper's own class (reusing scopeCss's own
   idempotency guarantee) and leaves it untouched.

4. (Minor) Documented, not fixed: the 32-bit scope-id hash is
   brute-forceable (CSS-only impact, same trust tier as other accepted
   risks here), and DOMPurify's SAFE_FOR_XML silently drops an entire
   <style> block when its content merely looks tag-like (e.g.
   content: "<Read More>").

1155/1155 tests passing (was 1141), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 17:36:12 -07:00
shadowdaoandClaude Opus 5 32f4092156 feat(site-builder): add block-scoped <style> support to Custom HTML block
<style> was previously in FORBID_TAGS and stripped entirely. It's now
allowed, but its CSS is rewritten by a new hand-rolled scoper
(src/utils/scope-css.ts) so a customer's rules only match inside their own
block's wrapper -- never leak out and restyle the rest of the page. The
wrapper div (class="whp-html-<hash>") is only emitted when a block actually
has surviving <style> content, so blocks that don't use it stay
byte-identical to before this change.

Key findings, both covered by tests:
- DOMPurify's body-only serialization silently drops a <style> tag that
  appears before any other content in a block (the HTML5 parser implicitly
  places it in <head>, which DOMPurify never looks at). Fixed with
  FORCE_BODY: true.
- DOMPurify does not sanitize CSS declaration values at all (expression(),
  behavior:, url() to any host all pass through verbatim) -- @import is
  stripped explicitly by scopeCss() since it's the one CSS-level
  exfiltration/fetch vector in scope here.

Scope identifier reuses the existing djb2 stableHash() from utils/escape.ts
(already used for this exact class of problem) over the block's own `code`
string -- deterministic, no node id, no Math.random/Date.now.

1141/1141 tests passing (was 1077), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 17:10:57 -07:00
shadowdaoandClaude Opus 5 6a9b227dda fix(site-builder): repair dead-code data:image clause in HTML block URI regex
Review follow-up on the Task 24 sanitiser widening (approved, no bypass
found). Two Important findings to close:

1. ALLOWED_URI_REGEXP's data:image/...;base64, arm sat inside the group
   that appends a trailing `:` to every alternative, so it required a
   second colon no real data URI has -- the clause could never match.
   Confirmed dead before the fix (poster/cite/href all stripped a valid
   base64 PNG data URI) and working after (all three now survive), while
   javascript:/data:text/html stay blocked. Pulled the arm out into its
   own top-level alternative.

2. Corrected an inaccurate comment/report claim that every allowed
   attribute value goes through this regex -- `src` on
   img/video/audio/source/image/track is additionally covered by
   DOMPurify's own DATA_URI_TAGS allow-list, which is mimetype-blind and
   bypasses the regex entirely (acceptable: none of those tags execute
   src as a document; iframe is correctly excluded from that list).

Adds two regression tests: the regex fix actually working, and the
DATA_URI_TAGS bypass pinned so a future DOMPurify change surfaces as a
failing test rather than a surprise. Fixture byte figures unchanged
(15,815 -> 14,899; fixture has no data:image URIs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:50:32 -07:00
shadowdaoandClaude Opus 5 156c5bae35 fix(site-builder): widen Custom HTML block sanitiser allow-list
A customer's broad HTML fixture showed 38% of it silently deleted by
the shipped DOMPurify config: colspan/rowspan/scope, <dl>, <sub>/<sup>,
<details>/<summary>, inline <svg>, <video>/<audio>, lang/dir/role, and
<ol start/reversed> were all stripped. The site owner's call: be
generous, this block is an explicit escape hatch, allow forms too.

Widens PURIFY_CONFIG in HtmlBlock.tsx (45->119 tags, 16->108 attrs;
form/input/button/select/textarea removed from FORBID_TAGS) while
keeping the four non-negotiables intact: no <script>, no on*, no
javascript: URLs, iframes stay sandboxed. <style> stays blocked
(separate task adds scoped support later), including inside the newly
allowed inline SVG. SVG support is an explicit tag list mirroring
DOMPurify's own SVG vocabulary rather than USE_PROFILES, which turned
out to silently discard ALLOWED_ATTR entirely and pull in unaudited
tags (dialog, template, marquee, ...) not in scope here.

Fixture survival goes from 61.6% (9,739/15,815 bytes) to 94.2%
(14,899/15,815 bytes). Adds a fixture-driven regression + security
test file (HtmlBlock.security.test.ts) plus a checked-in copy of the
reference fixture, loaded via Vite's ?raw import so tests need no new
dependencies and can't silently drift from the thing being tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:36:01 -07:00
shadowdaoandClaude Opus 5 69e61ab4b2 fix(site-builder): final whole-branch review fixes
C1: HtmlBlock's PURIFY_CONFIG omitted 'style' from ALLOWED_ATTR, so the
toolbar colour picker added in this branch was silently deleted by
DOMPurify -- issue #2 was regressed, not fixed. Adds style/id plus table
tags, with tests pinning the markup path in both render and toHtml.

I3: PagesPanel's three confirmation states were not mutually exclusive;
cancelling delete revealed an unbidden reset prompt on a destructive action.

I5: orphan repair logged at console.warn, which the new console buffer
cannot see -- the reporter would never capture the most diagnostic signal
for the still-unreproduced drop bug. Also aligns useWhpApi's initial-load
failure handling with loadState's fallback.

I7: corrects comments (and the design spec) that asserted an orphan
"renders somewhere on the canvas", which a mid-plan audit disproved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:47:23 -07:00
shadowdaoandClaude Opus 5 a30e82accf fix(site-builder): HtmlBlock render stops applying style so editor matches published output
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:35:12 -07:00
jknapp f7da654c11 Merge PR #20: enh nav 2026-07-14 13:58:11 +00:00
jknapp d6666f6f79 Merge PR #19: enh containers 2026-07-14 13:56:11 +00:00
shadowdaoandClaude Opus 4.8 4a426e3513 fix(containers): only flex-convert Container/Section when vertical-align set (avoid blockifying inline-block children)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:55:20 -07:00
jknapp 6e628d68dd Merge PR #18: enh sections 2026-07-14 13:54:52 +00:00
jknapp 8f17b74e2b Merge PR #17: enh forms 2026-07-14 13:49:57 +00:00
shadowdaoandClaude Opus 4.8 54572f648a feat(builder): nav/menu link-to-page picker, page sync, download attr, box-model rollout
NavStylePanel (Navbar/Menu/Logo/Footer):
- LinkPicker: dropdown of the site's pages (read-only via usePages()) plus
  manual URL / #anchor / tel: / mailto: entry, wired into every link-href
  field (standalone Logo href, Navbar logoUrl, Navbar/Menu link items).
- "Sync links with Pages" button in the Links section: repopulates the
  links array from the current pages list (label = page name, href = '/'
  for the landing page else '/{slug}'), preserving any existing CTA link.
  Regression-fix vs the legacy GrapesJS builder, which had this.
- `download` checkbox per link (Navbar/Menu links, standalone Logo href)
  emits the `download` attribute on export for links to files.
- Links/Colors sections now gate on the component actually carrying a
  `links`/color prop, so Footer (no links array) no longer shows a dead
  "Add Link" editor.
- Box-model (Margin/Padding via SpacingControl, Border & Effects via
  BorderControl + box-shadow presets + opacity), AnimationControl, and
  VisibilityControl added for all four owned components, backed by new
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props
  (with blank/default values in each component's .craft.props).

Tests: NavStylePanel.test.tsx (new, 17 tests: LinkPicker modes, sync
preserves CTA, download toggle, box-model/animation/visibility wiring) +
extended Navbar/Menu/Logo/Footer .toHtml.test.ts (download attribute,
craft.props defaults). Full suite: 683/683 passing. `npm run build` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:59 -07:00
shadowdaoandClaude Opus 4.8 8bf525c600 feat(builder): surface hidden section/pricing/social props + box-model rollout
SectionTypePanel (Accordion/Tabs/Testimonials/Countdown/NumberCounter/
CTASection/CallToAction/FeaturesGrid), PricingStylePanel, and
SocialStylePanel all gain a Spacing & Border section (margin/padding
per-side, border, box-shadow, opacity), an Animation section, and a
Visibility section, wired to the shared SpacingControl/BorderControl/
AnimationControl/VisibilityControl.

PricingTable's per-card colors (cardBg/textColor/subColor/featColor/
checkColor/btnBg/btnColor) were previously hard-coded literals computed
from featuredBg inside toHtml -- promoted to real optional props (each
falling back to the exact prior literal when unset) and exposed via
ColorPickerField in PricingStylePanel.

SocialStylePanel now exposes SocialLinks' iconShape/gap (already-built
props with no control), plus Icon's bgColor/bgShape/bgSize/link and
StarRating's filledColor/emptyColor, which the panel's generic
iconBgColor/starColor checks never matched since those aren't Icon's or
StarRating's real prop names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:58 -07:00
jknapp b03425ac39 Merge PR #15: enh text+button 2026-07-14 13:46:08 +00:00
shadowdaoandClaude Opus 4.8 9750a6c2bf feat(builder): containers package -- vertical alignment + box-model/anim/vis rollout
- ColumnLayout: exposes style.alignItems on its flex ROW (aligns uneven
  columns) -- render/toHtml already spread `style` onto the row div, so this
  is a craft.props default + panel control addition, no structural change.
- Container/Section: root element is now unconditionally display:flex;
  flex-direction:column (both editor render and toHtml), so the new
  Vertical Alignment control maps to style.justifyContent, paired with a
  Min Height (NumericUnitInput) control on style.minHeight. Default
  justify-content/align-items reproduce ordinary block-flow stacking, so
  this is a visual no-op for existing published content. Works in both
  normal and "boxed" (contentWidth) modes -- the boxed inner wrapper's own
  margin:0-auto horizontal centering is preserved via flex auto-margin
  override semantics.
- ContainerStylePanel (serves Container/Section/Columns) distinguishes the
  Columns case from Container/Section via nodeProps.columns/split presence
  (no typeName plumbing needed) to pick align-items vs justify-content for
  the shared Vertical Alignment control.
- All 3 owned components: added margin/padding (per-side)/border/box-shadow/
  opacity style defaults + AnimationControl/VisibilityControl-backed
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props.
  New containerBoxModel.tsx (package-local, not shared.tsx) DRYs the
  box-model + border/effects + animation/visibility panel sections across
  the single shared ContainerStylePanel, mirroring the sibling media
  package's mediaBoxModel.tsx.
- Tests: extended all 3 *.toHtml.test.ts files (align-items/justify-content/
  min-height emission, box-model style emission, craft.props presence).
  673 tests green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:37 -07:00
shadowdaoandClaude Opus 4.8 5b19ae97af feat(builder): FORMS package -- field editor, functional Subscribe/Search, box-model+anim rollout
- FormStylePanel: ContactForm field editor (add/remove/reorder via
  ArrayPropEditor + manual move up/down) covering label/name/placeholder/
  type (full sanitizeInputType allowlist + textarea/select)/required/
  options.
- SubscribeForm.toHtml: was a dead `<form method="POST">` with no action at
  all -- wired through the same relayFormWiring contract as ContactForm/
  FormContainer so a recipientEmail makes it actually submit (marker +
  placeholder action + honeypot), falling back to action="#" otherwise.
- SearchBar: was purely decorative (no action/method/input name) -- now a
  real GET form (configurable target, default "/") with input name="q",
  safeUrl-guarded against javascript:/vbscript: breakout.
- Box-model (margin/padding per-side, border, shadow, opacity), entrance
  animation, and hide-on-device controls added to FormStylePanel and
  rolled out (blank/false craft.props defaults) across ContactForm,
  FormContainer, InputField, TextareaField, FormButton, SubscribeForm,
  SearchBar. No toHtml changes needed for animation/visibility --
  html-export.ts's buildDataAttrs() already emits data-animation/
  data-hide-* generically from these prop names.
- Extended toHtml tests for all 7 components: field-type rendering
  (incl. textarea/select), type-attribute XSS sanitization, relay/GET
  functional wiring, box-model style passthrough, craft.props defaults.

npx vitest run: 689/689 passed. npm run build: green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:21 -07:00
shadowdaoandClaude Opus 4.8 e1b4ab735c feat(builder): text+button package -- typography depth, button target/hover, box-model+anim/vis rollout
- TextStylePanel (Heading/TextBlock): line-height/letter-spacing presets,
  text-transform (none/uppercase/lowercase/capitalize), italic + underline
  toggles (fontStyle/textDecoration), and a custom NumericUnitInput
  font-size alongside the existing preset row. All write to component
  `style`; no toHtml changes needed (style already flows through
  cssPropsToString for both components).
- ButtonStylePanel/ButtonLink: "Open in new tab" checkbox writes the
  existing `target` prop ('_self'/'_blank' -- toHtml already emitted
  rel="noopener noreferrer" for _blank). New Hover State section
  (hoverBg/hoverColor via ColorPickerField) renders a scoped
  `<style>.btn_<hash>:hover{...}</style>` block before the `<a>` in
  toHtml, scoped per-node via scopeId (same pattern as Navbar/Menu) so
  two buttons on one page don't collide; both values sanitized through
  cssValue against <style>-element breakout. Editor canvas gets a live
  hover preview via onMouseEnter/onMouseLeave local state (mirrors Menu's
  approach), since there's no way to preview a CSS :hover rule directly
  on an inline-styled React element.
- Heading/TextBlock/ButtonLink: added margin(per-side)/padding(per-side,
  Text only)/border/box-shadow/opacity style defaults + AnimationControl/
  VisibilityControl-backed animation/animationDelay/hideOnDesktop/
  hideOnTablet/hideOnMobile props, each panel gaining collapsible
  Spacing / Border & Effects / Animation & Visibility sections. Button
  keeps its existing padding-shorthand preset row rather than adding a
  redundant per-side padding control; only margin got the new
  per-side SpacingControl.
- Tests: extended all three *.toHtml.test.ts files -- typography style
  emission (line-height/letter-spacing/text-transform/font-style/
  text-decoration/custom font-size), button target+rel, scoped hover
  style emission + two adversarial style-breakout cases (</style><script>
  and rule-injection via `;}selector{`), and craft.props assertions for
  every new prop on all three components. 674 tests green, tsc + vite
  build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:41:53 -07:00
shadowdaoandClaude Opus 4.8 0419291259 feat(builder): media package -- image crop/perf, video size+picker, gallery cols/lightbox, box-model+anim/vis rollout
- ImageBlock/ImageStylePanel: SizeControl width(absorbs old maxWidth
  presets)/height, AspectRatioControl + OBJECT_FIT grid + FocalPointGrid for
  CSS framing crop (aspect-ratio/object-fit/object-position on the <img>).
  Exported <img> always gets loading="lazy" decoding="async", plus width/
  height attrs when the style has a plain px length (pxAttr helper).
- VideoBlock/MediaStylePanel: Video URL text input replaced with
  AssetPicker(mediaType="video") writing videoUrl (paste-URL still handles
  YouTube/Vimeo, upload/browse handle files). Added SizeControl(width) +
  AspectRatioControl so the video frame honors real size/aspect instead of
  a hardcoded 16:9 (padding-bottom hack replaced with CSS aspect-ratio).
  New optional `poster` prop (image AssetPicker) + preload="metadata" on
  file-type <video>.
- Gallery: surfaced the existing-but-unexposed `columns` and `lightbox`
  props with panel controls.
- All 5 owned components (ImageBlock, VideoBlock, Gallery, ContentSlider,
  MapEmbed): added margin/padding (per-side)/border/box-shadow/opacity style
  defaults + AnimationControl/VisibilityControl-backed animation/
  animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props. New shared
  mediaBoxModel.tsx (package-local, not shared.tsx) DRYs the box-model +
  border/effects + animation/visibility panel sections across
  ImageStylePanel and MediaStylePanel.
- Tests: extended *.toHtml.test.ts for all 5 components (crop/perf attrs,
  video size/aspect/poster/preload, gallery columns/lightbox, box-model
  style emission, craft.props presence) + new MediaStylePanel.video.test.tsx
  verifying the AssetPicker wiring writes videoUrl/poster and the video-only
  size controls are gated on videoUrl. 694 tests green, tsc + vite build
  clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:30:50 -07:00
shadowdao 621bb21d52 fix(builder): safeImageUrl for FeaturesGrid/ContentSlider image sinks + tighten data:image allowlist
FeaturesGrid's <img src> and ContentSlider's CSS background-image url()
were still on safeUrl, which blocks data:image/svg+xml -- inconsistent
with other image sinks already swapped to safeImageUrl and a latent
regression for those two components. Swapped both to safeImageUrl;
left their navigation sinks (buttonUrl/buttonHref) on safeUrl.

Also tightened safeImageUrl's data:image allowlist check to require the
slash (dataimage/ not dataimage), so a bogus MIME like
data:imagehtml/... can no longer slip past the prefix check.
2026-07-12 19:53:02 -07:00
shadowdaoandClaude Opus 4.8 3f3c6fb851 security: add safeImageUrl, un-break M-5's over-blocking of image-context SVG data URIs
M-5 made safeUrl() block data:image/svg+xml everywhere, including the
image-only sinks (<img src>, CSS url()) that Gallery's default images and
other SVG placeholders rely on. Loaded as an image, an SVG is rasterized
and never executes an inline <script>/onload= -- that only happens when
it's navigated to or loaded as an <iframe> document -- so M-5 over-blocked
the safe contexts and broke every published Gallery (and other components
using an SVG placeholder) using safeUrl's default images in prod.

Adds safeImageUrl(): identical javascript:/vbscript: handling to safeUrl,
but treats data: as an allowlist of image/* subtypes instead of a
blocklist -- allows all data:image/* (including svg+xml, with or without
base64), still blocks data:text/html and any other non-image data: type.

Swapped to safeImageUrl at IMAGE-src / CSS-image url() sinks only:
- Gallery.tsx img src + lightbox data-lb-src
- ImageBlock.tsx img src (toHtml)
- Logo.tsx / Navbar.tsx logo <img> src (their href/link targets keep safeUrl)
- style-helpers.ts sanitizeCssValue's url(...) handling (background-image
  for HeroSimple/BackgroundSection/Section/CallToAction)

Left on safeUrl (href/iframe/form-action/navigation sinks, where
data:image/svg+xml must stay blocked): ButtonLink, Icon link, SocialLinks,
Menu/Navbar link hrefs, PricingTable buttonHref, _cta-helpers,
ContentSlider buttonHref, FeaturesGrid buttonUrl, FormContainer action
(via form-relay-wiring), MapEmbed/VideoBlock iframe src.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:58 -07:00
shadowdaoandClaude Opus 4.8 802938ec1a fix(builder): Image block placeholder no longer overridden by explicit src=""
BlocksPanel dropped a new Image block with an explicit `src=""` prop, which
overrides ImageBlock's `src = PLACEHOLDER_SRC` default parameter (defaults
only apply when a prop is undefined, not when it's an empty string). Craft
then persisted `src:''`, and the canvas rendered a broken-image icon instead
of the placeholder.

- ImageBlock render now falls back to PLACEHOLDER_SRC whenever src is falsy
  (belt-and-braces: also recovers any legacy saved src:'' state).
- BlocksPanel no longer passes src="" when dropping a new Image block, so
  the craft default applies.
- ImageStylePanel now restores the placeholder (instead of blanking to '')
  when the URL field is cleared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:28 -07:00
shadowdaoandClaude Opus 4.8 bf4a9f48eb security: block data:image/svg+xml + sandbox HtmlBlock iframes
M-5: safeUrl() blocked javascript:/vbscript:/data:text/html but
allowed data:image/svg+xml, which can execute inline <script>/onload=
when loaded as a document/navigation target despite its "image" MIME
type (defense in depth -- not currently reachable to execution via
this sink, but closing it). Added `data:image/svg+xml` to the existing
DANGEROUS_SCHEME_PREFIXES check, so it's caught after the same
entity-decode/whitespace-strip/lowercase normalization used for the
other blocked schemes (obfuscated variants included). Other
data:image/* types (png/jpeg/gif/webp, ...) remain allowed unchanged.

M-6: HtmlBlock's purifyHtml() allowed <iframe src> through with no
`sandbox` attribute -- a clickjacking/phishing vector even with
DOMPurify already stripping script/on*=. Added a DOMPurify
afterSanitizeAttributes hook, scoped tightly to each purifyHtml() call
(added right before sanitize(), removed in a finally right after) so
it can't leak onto other DOMPurify uses or accumulate duplicates
across repeated calls, that force-sets a restrictive sandbox
(allow-scripts allow-same-origin allow-popups allow-forms -- no
allow-top-navigation) and referrerpolicy=no-referrer on every iframe
that survives sanitization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:31:25 -07:00
shadowdaoandClaude Opus 4.8 0cbc58f8d1 a11y/security: scope Navbar ids + hover styles, Gallery lightbox focus trap
M-1: Navbar.toHtml emitted a fixed id="navbar-links" and unscoped
.navbar-link/.navbar-cta :hover selectors -- two Navbars on one page
collided on the duplicate id and cross-applied each other's hover
colors (later <style> block wins in the cascade). Scope both on the
Craft node id via scopeId(), matching the Menu/Tabs pattern: the links
container gets a unique id, aria-controls/the hamburger toggle script
reference it, and the hover rules are prefixed with a per-instance
class on the <nav> root.

M-2: Gallery lightbox had no focus management -- opening it left focus
wherever it was (behind the now-visible overlay) and closing it never
restored it. The inline script now stashes document.activeElement on
open, moves focus to a new accessible close button, traps Tab on the
close button while the dialog is open, and restores the saved focus on
close (Escape, backdrop click, or the close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:30:40 -07:00
shadowdaoandClaude Opus 4.8 591a51dcc2 fix(builder): escape/allowlist all attribute-value sinks incl. numeric/enum props (XSS)
An adversarial pass found 5 Critical XSS sinks where props declared number/enum
in TypeScript were interpolated raw into exported HTML attribute values,
trusting the type — but nothing enforces it at runtime (AI update_props only
validates node_id; deserialized saved state is untyped JSON). Fixed all 5
(NumberCounter data-target, StarRating aria-label, FormContainer method,
ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive
audit of every attribute-value interpolation across src/components: a
JS-source injection into ContentSlider's inline setInterval script, a
prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup,
TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and
MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist
helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue
primitives. Every fix is TDD'd: a malicious-value test reproduces the raw
injection against the pre-fix code, then passes after the fix.

502 tests green (npx vitest run), tsc + vite build green (npm run build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:03:44 -07:00
shadowdaoandClaude Opus 4.8 7ba91d9829 fix(builder): sanitize non-string style values + allowlist element tags (XSS)
Adversarial re-review found the C1 fix incomplete plus an adjacent
same-class XSS, both reachable via the AI update_props path and
deserialized saved state:

- cssPropsToString only ran sanitizeCssValue on typeof-string values, so a
  non-string style value (array/object) with a valid key skipped
  sanitization entirely and was template-coerced raw into style="...",
  e.g. { color: ['red', '"><img src=x onerror=alert(1)>'] }. Now every
  non-number value is coerced with String() and sanitized; numbers stay
  raw. sanitizeBreakoutChars also now escapes < and > (previously only ;
  and ") as defense-in-depth, since values can reach it from non-string
  sources.

- props.tag (Container) and props.level (Heading) were interpolated raw
  into the tag position of exported HTML (`<${tag}`, `<${level}`) with no
  runtime validation, letting a malicious value break out of the tag
  entirely. Both are now allowlisted/clamped against their known-safe sets
  (div/section/article/header/footer/main; h1-h6), falling back to
  div/h2. Applied in Container's live render + toHtml, Heading's live
  render + toHtml, and the typeName==='div' fallback branch in
  html-export.ts's renderNode (hit for unresolved/legacy node types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:44:59 -07:00
shadowdaoandClaude Opus 4.8 25507cb57a Fix INT: ContentSlider Slides editor wrote wrong prop key (image vs imageSrc)
MediaStylePanel's Slides array editor guarded on item.image !== undefined
and wrote `image` via AssetPicker's onChange, but ContentSlider (render
+ toHtml) reads slide.imageSrc. Default slides (imageSrc:'', no `image`
key) never showed an image picker at all, and any `image` value written
was a silent no-op on render/export.

Editor now guards/reads/writes `imageSrc` throughout, and the
"add slide" emptyItem matches defaultSlides' exact shape
(type/imageSrc/heading/text/bgColor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:32:47 -07:00
shadowdaoandClaude Opus 4.8 36c3b2f503 fix(builder): sanitize CSS-value sinks to prevent style/<style> breakout XSS
Adds a single cssValue() sanitizer (src/utils/escape.ts) that strips
<>{};"'\ and neutralizes url(), safe for both style="..." attribute and
<style>...</style> element contexts. Applies it at every raw user-prop
CSS-value interpolation sink found via grep across src/components (colors,
sizes, gaps interpolated directly into style strings/<style> blocks),
including the highest-risk <style>-context sinks: ColumnLayout gap,
Menu/Navbar hover and background colors. Also Number()-coerces the
`columns` grid-template-columns sinks in Gallery/Testimonials/NumberCounter
as defense in depth. Regression tests assert </style><script> payloads are
neutralized and normal colors still render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:56:25 -07:00
shadowdao a036843728 fix(builder): deterministic ids for NumberCounter + form-relay marker
Two components of the det-id bug class were missed in the earlier migration:
NumberCounter's wrapper/counter ids and count-up script, and the shared
form-relay-wiring fid (used by ContactForm/FormContainer). Both used
Math.random() for exported HTML ids, breaking caching/diffing across exports.

Migrate both to scopeId(nodeId, fallbackSeed, prefix), threading nodeId
through NumberCounter.toHtml and relayFormWiring (via ContactForm.toHtml and
FormContainer.toHtml, both now passing nodeId as their 3rd arg).
2026-07-12 14:43:31 -07:00
shadowdaoandClaude Opus 4.8 177eda93d0 script nits: slider hover/visibility pause + silent autoplay + iframe amp guard
ContentSlider: autoplay's setInterval now pauses on mouseenter and on
document visibilitychange (tab hidden), resumes on mouseleave/visible,
and is always clearable (timer var + clearInterval). The aria-live
region is "off" while autoplay is silently auto-rotating and only
flips to "polite" on manual next/prev/dot navigation, so screen readers
aren't spammed with an announcement every `interval` ms (F-export
review Minor). Updates the one existing test that hard-coded
aria-live="polite" as always-on to match this intentional behavior
change.

(Countdown's ticking-interval-stops-at-zero nit shipped in the previous
commit alongside its node-id migration, since both touched the same
inline script.)

Adds regression tests locking in that MapEmbed/VideoBlock iframe src
attributes (built by string concatenation with literal `&` query
params) are HTML-entity-encoded via the existing escapeAttr(safeUrl())
pipeline -- verified already correct, no source change needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:35:27 -07:00
shadowdaoandClaude Opus 4.8 8029126ab7 deterministic + unique ids in 6 components (node-derived scope)
Migrates Menu, ColumnLayout, Countdown, Gallery, Tabs, InputField, and
TextareaField from Math.random()/content-hash scope ids to the threaded
Craft node id (via scopeId()), keeping every emitted element id,
aria-controls/aria-labelledby/for, and inline <script> function
name/getElementById() call consistently scoped per component.

Resolves the two Important id-collision review findings:
- Tabs: tabId was djb2(anchorId||labels) -- two default Tabs instances
  produced identical aria-controls/aria-labelledby ids, so one instance's
  arrow-key script clobbered the other's tab/panel wiring.
- InputField/TextareaField: fieldId was `field-${name}` -- two fields
  sharing a (often default) name produced duplicate <label for>/<input
  id> pairs, breaking the for/id association for one of them.

Also eliminates the remaining Math.random() scope ids in Menu (hover CSS
class scope) and ColumnLayout (nth-child width CSS class scope), so no
export component is non-deterministic anymore.

All fall back to a stable content hash (never Math.random) for legacy
2-arg toHtml() call sites without a node id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:35:12 -07:00
shadowdaoandClaude Opus 4.8 9969adca72 a11y: exported semantics (forms/rating/nav/iframe/icons)
- InputField/TextareaField/ContactForm: every control gets a
  deterministic id (slugId() in utils/escape.ts, derived from the
  field's name/label + index for ContactForm's looped fields -- no
  Math.random) with a matching <label for=>; fields with no visible
  label get an aria-label from the placeholder/name instead.
- StarRating: wrapped in role="img" aria-label="Rating: N out of M",
  individual star glyphs marked aria-hidden.
- Navbar: the mobile hamburger toggle gets aria-label="Toggle
  navigation menu", aria-controls="navbar-links", and aria-expanded
  wired to flip true/false in the inline onclick handler.
- VideoBlock and MapEmbed: every exported <iframe> gets a title
  (generic "Embedded video", or "Map of {address}" for MapEmbed).
- Decorative Font Awesome icons (ContentSlider arrows already covered
  in the prior commit; SocialLinks, SearchBar, Testimonials stars) are
  aria-hidden; SocialLinks' icon-only links get an aria-label naming
  the platform alongside the existing title tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:19:01 -07:00
shadowdaoandClaude Opus 4.8 9b532e36e8 a11y: exported widget ARIA (slider/tabs/gallery)
ContentSlider, Tabs, and Gallery toHtml exports and their inline scripts
now carry ARIA semantics and keyboard support:

- ContentSlider: prev/next arrows and dot buttons get aria-labels, the
  slide stack is wrapped in an aria-live="polite" region, and the
  decorative chevron icons are aria-hidden.
- Tabs: tablist/tab/tabpanel roles, aria-selected/aria-controls/
  aria-labelledby, and Left/Right/Home/End arrow-key navigation in the
  inline script. The tab<->panel id scope is now derived from a
  deterministic hash of anchorId/labels instead of Math.random, since
  the ARIA linking ids must be stable across export runs.
- Gallery: the lightbox overlay gets role="dialog"/aria-modal/aria-label,
  Escape closes it, and thumbnails are keyboard-operable (role="button"
  tabindex="0" plus Enter/Space handling in the existing delegated
  listener).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:18:49 -07:00