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:
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
buttonText = 'Search',
|
buttonText = 'Search',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const formStyle = cssPropsToString({
|
const formStyle = cssPropsToString({
|
||||||
padding: '32px',
|
padding: '32px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const wrapStyle = cssPropsToString({
|
const wrapStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
heading = 'Subscribe to our newsletter',
|
heading = 'Subscribe to our newsletter',
|
||||||
placeholder = 'Enter your email',
|
placeholder = 'Enter your email',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const wrapStyle = cssPropsToString({
|
const wrapStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
background: props.gradient || defaultGradient,
|
background: props.gradient || defaultGradient,
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
const bgType = props.bgType || 'gradient';
|
const bgType = props.bgType || 'gradient';
|
||||||
const bgValue = props.bgValue || defaultGradient;
|
const bgValue = props.bgValue || defaultGradient;
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
slides = defaultSlides,
|
slides = defaultSlides,
|
||||||
autoplay = true,
|
autoplay = true,
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
targetDate = DEFAULT_TARGET,
|
targetDate = DEFAULT_TARGET,
|
||||||
heading = 'Coming Soon',
|
heading = 'Coming Soon',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
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' };
|
||||||
|
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
counters = defaultCounters,
|
counters = defaultCounters,
|
||||||
columns = 4,
|
columns = 4,
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
const bulletType = props.bulletType || 'check';
|
const bulletType = props.bulletType || 'check';
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
|
|||||||
@@ -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, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
testimonials = defaultTestimonials,
|
testimonials = defaultTestimonials,
|
||||||
layout = 'grid',
|
layout = 'grid',
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
{busy ? (
|
||||||
|
<WorkingIndicator />
|
||||||
|
) : (
|
||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={!canChat || busy}
|
disabled={!canChat}
|
||||||
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : busy ? 'Thinking…' : 'Describe what you want…'}
|
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScopeConfirmDialog
|
<ScopeConfirmDialog
|
||||||
open={!!pendingReplace}
|
open={!!pendingReplace}
|
||||||
|
|||||||
79
craft/src/panels/sitesmith/WorkingIndicator.tsx
Normal file
79
craft/src/panels/sitesmith/WorkingIndicator.tsx
Normal 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',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user