- 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>
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>
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>
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>
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>
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>
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>
Entity-escaping alone doesn't protect JS-string or raw-HTML sinks:
- HtmlBlock.toHtml exported props.code raw; now runs it through the
same purifyHtml (DOMPurify) config already used for the live editor
preview, so <script>/on*= payloads can't survive export either.
- Countdown.toHtml interpolated targetDate directly into
`new Date("${targetDate}")` inside an inline <script> -- a value
like `2026-01-01");alert(1)//` broke out of the string literal. Now
validated against a strict date/datetime shape and JSON.stringify'd
before embedding, falling back to `new Date()` for anything invalid.
- Gallery.toHtml's lightbox used
`onclick="${id}_open('${esc(img.src)}')"`, which a single quote in
img.src could break out of. Replaced with a `data-lb-src` attribute
per thumbnail and one delegated click listener on the grid
(`e.target.closest('[data-lb-src]')`) instead of a per-item inline
handler string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace divergent, buggy local esc/escapeHtml helpers across 26 files with
imports from src/utils/escape (escapeHtml/escapeAttr). Attribute call sites use
escapeAttr, text-content sites use escapeHtml. Several toHtml outputs now
correctly escape & where old local escapers omitted it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-world AI output frequently sends mismatched prop names (e.g.
items vs features, cta object vs buttonText/Href). The toHtml functions
of section/form/sections-folder components each defined a local
esc = (s: string) => s.replace(...) that crashed when called with
undefined, taking the auto-save export with it.
Patched every local esc() to coerce non-strings:
const esc = (s: any) => String(s ?? "").replace(...)
17 files touched; behavior unchanged for valid string inputs.
Also adds a WorkingIndicator (Claude Code-style spinner + rotating
phrase + elapsed seconds) shown in the modal footer while a generation
is in flight, replacing the disabled "Thinking..." placeholder.
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>