- 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>
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>
- 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>
Each component defined a .craft.related.settings panel that was never
rendered -- the right panel renders only GuidedStyles (per-type
*StylePanel components), never .related.settings. Removed all dead
settings components across every component, their settings-only helpers
(including the dead uploadToWhp/showBrowser/handleBrowse asset-browse
blocks in Logo/Navbar/VideoBlock/FeaturesGrid, and CtasEditor in
_cta-helpers), and dropped the now-empty related keys. Render output,
.craft props/rules, and toHtml statics are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>