sitesmith: null-safe esc() across all toHtml + WorkingIndicator

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.
This commit is contained in:
2026-05-24 15:54:48 -07:00
parent ac0347ae5f
commit 069ea1235a
19 changed files with 106 additions and 22 deletions

View File

@@ -171,7 +171,7 @@ SearchBar.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => { (SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
placeholder = 'Search...', placeholder = 'Search...',
buttonText = 'Search', buttonText = 'Search',

View File

@@ -372,7 +372,7 @@ ContactForm.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => { (ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const formStyle = cssPropsToString({ const formStyle = cssPropsToString({
padding: '32px', padding: '32px',
display: 'flex', display: 'flex',

View File

@@ -165,7 +165,7 @@ InputField.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => { (InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({ const wrapStyle = cssPropsToString({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',

View File

@@ -249,7 +249,7 @@ SubscribeForm.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => { (SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
heading = 'Subscribe to our newsletter', heading = 'Subscribe to our newsletter',
placeholder = 'Enter your email', placeholder = 'Enter your email',

View File

@@ -167,7 +167,7 @@ TextareaField.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => { (TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({ const wrapStyle = cssPropsToString({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',

View File

@@ -293,7 +293,7 @@ Accordion.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => { (Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
...props.style, ...props.style,

View File

@@ -173,7 +173,7 @@ CTASection.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => { (CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
background: props.gradient || defaultGradient, background: props.gradient || defaultGradient,
padding: '80px 20px', padding: '80px 20px',

View File

@@ -405,7 +405,7 @@ CallToAction.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => { (CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bgType = props.bgType || 'gradient'; const bgType = props.bgType || 'gradient';
const bgValue = props.bgValue || defaultGradient; const bgValue = props.bgValue || defaultGradient;

View File

@@ -443,7 +443,7 @@ ContentSlider.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => { (ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
slides = defaultSlides, slides = defaultSlides,
autoplay = true, autoplay = true,

View File

@@ -249,7 +249,7 @@ Countdown.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => { (Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
targetDate = DEFAULT_TARGET, targetDate = DEFAULT_TARGET,
heading = 'Coming Soon', heading = 'Coming Soon',

View File

@@ -177,7 +177,7 @@ FeaturesGrid.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => { (FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '80px 20px', padding: '80px 20px',
...props.style, ...props.style,

View File

@@ -277,7 +277,7 @@ Gallery.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => { (Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
...props.style, ...props.style,

View File

@@ -404,7 +404,7 @@ HeroSimple.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => { (HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bg = buildBackground(props); const bg = buildBackground(props);
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' }; const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };

View File

@@ -305,7 +305,7 @@ NumberCounter.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => { (NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
counters = defaultCounters, counters = defaultCounters,
columns = 4, columns = 4,

View File

@@ -398,7 +398,7 @@ PricingTable.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => { (PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bulletType = props.bulletType || 'check'; const bulletType = props.bulletType || 'check';
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '80px 20px', padding: '80px 20px',

View File

@@ -290,7 +290,7 @@ Tabs.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => { (Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
...props.style, ...props.style,

View File

@@ -371,7 +371,7 @@ Testimonials.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => { (Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
testimonials = defaultTestimonials, testimonials = defaultTestimonials,
layout = 'grid', layout = 'grid',

View File

@@ -8,6 +8,7 @@ import { UpgradeBanner } from './UpgradeBanner';
import { ScopeConfirmDialog } from './ScopeConfirmDialog'; import { ScopeConfirmDialog } from './ScopeConfirmDialog';
import { MessageList } from './MessageList'; import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput'; import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith'; import { SitesmithResponse } from '../../types/sitesmith';
interface Props { onClose: () => void; } interface Props { onClose: () => void; }
@@ -76,11 +77,15 @@ export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
: <MessageList messages={messages} />} : <MessageList messages={messages} />}
</div> </div>
<div style={footer}> <div style={footer}>
<ChatInput {busy ? (
disabled={!canChat || busy} <WorkingIndicator />
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : busy ? 'Thinking…' : 'Describe what you want…'} ) : (
onSend={handleSend} <ChatInput
/> disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
/>
)}
</div> </div>
<ScopeConfirmDialog <ScopeConfirmDialog
open={!!pendingReplace} open={!!pendingReplace}

View File

@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react';
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const PHRASES = [
'Thinking',
'Sketching layout',
'Choosing colors',
'Writing copy',
'Picking components',
'Wiring up the hero',
'Polishing typography',
'Arranging sections',
'Composing the layout',
'Adding finishing touches',
];
/**
* Animated "AI is working" indicator. Modeled after Claude Code's bottom-bar
* status: a Braille-cycle spinner, a phrase that rotates every few seconds,
* and an elapsed-seconds counter. Mounts only while a request is in flight.
*/
export const WorkingIndicator: React.FC = () => {
const [frame, setFrame] = useState(0);
const [phrase, setPhrase] = useState('Thinking');
const [elapsed, setElapsed] = useState(0);
const [startTime] = useState(() => Date.now());
useEffect(() => {
const spinnerTimer = window.setInterval(() => {
setFrame((f) => (f + 1) % SPINNER.length);
}, 80);
const phraseTimer = window.setInterval(() => {
setPhrase(PHRASES[Math.floor(Math.random() * PHRASES.length)]);
}, 2500);
const elapsedTimer = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
return () => {
window.clearInterval(spinnerTimer);
window.clearInterval(phraseTimer);
window.clearInterval(elapsedTimer);
};
}, [startTime]);
return (
<div style={containerStyle} role="status" aria-live="polite">
<span style={spinnerStyle} aria-hidden>{SPINNER[frame]}</span>
<span style={phraseStyle}>{phrase}</span>
<span style={metaStyle}>({elapsed}s)</span>
</div>
);
};
const containerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '14px 4px',
fontSize: 14,
};
const spinnerStyle: React.CSSProperties = {
color: '#8b5cf6',
fontSize: 18,
width: 18,
display: 'inline-block',
textAlign: 'center',
fontFamily: 'monospace',
};
const phraseStyle: React.CSSProperties = {
color: '#e4e4e7',
fontStyle: 'italic',
fontWeight: 500,
};
const metaStyle: React.CSSProperties = {
color: '#71717a',
fontSize: 12,
marginLeft: 'auto',
};