diff --git a/next.config.ts b/next.config.ts index ea3cf9ff..630af18c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,16 +5,62 @@ const withNextIntl = createNextIntlPlugin() const nextConfig: NextConfig = { async headers() { + const isDev = process.env.NODE_ENV !== 'production' + const sharedSecurityHeaders = [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + ] + const frameDenyHeaders = [ + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" }, + ] + return [ { - source: '/:path*', - headers: [ - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" }, - ], + // Exclude the workbench demo (always) and, in development, the Slice + // Machine simulator route so they can be embedded in an iframe. + // Otherwise this rule's deny headers get combined onto those routes, + // leaving a stray X-Frame-Options: DENY that blocks framing. + source: isDev + ? '/((?!workbench-demo|slice-simulator).*)' + : '/((?!workbench-demo).*)', + headers: [...sharedSecurityHeaders, ...frameDenyHeaders], + }, + // Allow embedding the interactive workbench demo on our own pages. + // In dev, also allow the Slice Machine UI (other localhost port) so the + // demo iframe loads inside the nested simulator frames. + { + source: '/workbench-demo/:path*', + headers: isDev + ? [ + ...sharedSecurityHeaders, + { + key: 'Content-Security-Policy', + value: + "frame-ancestors 'self' http://localhost:* http://127.0.0.1:*", + }, + ] + : [ + ...sharedSecurityHeaders, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'Content-Security-Policy', value: "frame-ancestors 'self'" }, + ], }, + // Dev-only: let the Slice Machine UI iframe the simulator. + ...(isDev + ? [ + { + source: '/slice-simulator', + headers: [ + ...sharedSecurityHeaders, + { + key: 'Content-Security-Policy', + value: 'frame-ancestors http://localhost:* http://127.0.0.1:*', + }, + ], + }, + ] + : []), ] }, async redirects() { diff --git a/package.json b/package.json index dd469c7b..68726730 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,13 @@ }, "scripts": { "dev": "concurrently \"npm:dev:next\" \"npm:dev:slicemachine\" --names \"next,slicemachine\" --prefix-colors blue,magenta", - "dev:next": "next dev -p 3004", + "dev:next": "npm run sync:workbench-tour && next dev -p 3004", "dev:slicemachine": "start-slicemachine", - "build": "next build", + "build": "npm run sync:workbench-tour && next build", "start": "next start", "lint": "next lint", "lint:fix": "next lint --fix", + "sync:workbench-tour": "node scripts/sync-workbench-tour-config.mjs", "prepare": "husky" }, "dependencies": { diff --git a/prismicio-types.d.ts b/prismicio-types.d.ts index fc62cb4d..a9553a2a 100644 --- a/prismicio-types.d.ts +++ b/prismicio-types.d.ts @@ -2449,6 +2449,112 @@ export type HeroSliceExclusive = prismic.SharedSliceVariation< never > +/** + * Primary content in *Hero → Exclusive (interactive demo) → Primary* + */ +export interface HeroSliceExclusiveDemoPrimary { + /** + * Eyebrow field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Link + * - **Placeholder**: *None* + * - **API ID Path**: hero.exclusiveDemo.primary.eyebrow + * - **Documentation**: https://prismic.io/docs/fields/link + */ + eyebrow: prismic.LinkField + + /** + * Title field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Rich Text + * - **Placeholder**: *None* + * - **API ID Path**: hero.exclusiveDemo.primary.title + * - **Documentation**: https://prismic.io/docs/fields/rich-text + */ + title: prismic.RichTextField + + /** + * Description field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Rich Text + * - **Placeholder**: *None* + * - **API ID Path**: hero.exclusiveDemo.primary.description + * - **Documentation**: https://prismic.io/docs/fields/rich-text + */ + description: prismic.RichTextField + + /** + * CTA field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Link + * - **Placeholder**: *None* + * - **API ID Path**: hero.exclusiveDemo.primary.cta + * - **Documentation**: https://prismic.io/docs/fields/link + */ + cta: prismic.LinkField< + string, + string, + unknown, + prismic.FieldState, + 'Primary' | 'Secondary' + > + + /** + * Eyebrow Emphasized field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Boolean + * - **Placeholder**: *None* + * - **Default Value**: true + * - **API ID Path**: hero.exclusiveDemo.primary.eyebrow_emphasized + * - **Documentation**: https://prismic.io/docs/fields/boolean + */ + eyebrow_emphasized: prismic.BooleanField + + /** + * Full Bleed Image field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Image + * - **Placeholder**: *None* + * - **API ID Path**: hero.exclusiveDemo.primary.full_bleed_image + * - **Documentation**: https://prismic.io/docs/fields/image + */ + full_bleed_image: prismic.ImageField + + /** + * Is Short Image field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Boolean + * - **Placeholder**: *None* + * - **Default Value**: false + * - **API ID Path**: hero.exclusiveDemo.primary.is_short_image + * - **Documentation**: https://prismic.io/docs/fields/boolean + */ + is_short_image: prismic.BooleanField + + /** + * Image Mobile field in *Hero → Exclusive (interactive demo) → Primary* + * + * - **Field Type**: Image + * - **Placeholder**: *None* + * - **API ID Path**: hero.exclusiveDemo.primary.image_mobile + * - **Documentation**: https://prismic.io/docs/fields/image + */ + image_mobile: prismic.ImageField +} + +/** + * Exclusive (interactive demo) variation for Hero Slice + * + * - **API ID**: `exclusiveDemo` + * - **Description**: Homepage hero with embedded workbench walkthrough + * - **Documentation**: https://prismic.io/docs/slices + */ +export type HeroSliceExclusiveDemo = prismic.SharedSliceVariation< + 'exclusiveDemo', + Simplify, + never +> + /** * Primary content in *Hero → Video → Primary* */ @@ -2782,6 +2888,7 @@ export type HeroSliceExclusiveAnimated = prismic.SharedSliceVariation< */ type HeroSliceVariation = | HeroSliceExclusive + | HeroSliceExclusiveDemo | HeroSliceVideo | HeroSliceDefault | HeroSliceImage @@ -5068,12 +5175,14 @@ declare module '@prismicio/client' { FeaturesSliceHorizontallyScrollable, HeroSlice, HeroSliceExclusivePrimary, + HeroSliceExclusiveDemoPrimary, HeroSliceVideoPrimary, HeroSliceDefaultPrimary, HeroSliceImagePrimary, HeroSliceExclusiveAnimatedPrimary, HeroSliceVariation, HeroSliceExclusive, + HeroSliceExclusiveDemo, HeroSliceVideo, HeroSliceDefault, HeroSliceImage, diff --git a/public/workbench-demo/app.jsx b/public/workbench-demo/app.jsx new file mode 100644 index 00000000..f18871a6 --- /dev/null +++ b/public/workbench-demo/app.jsx @@ -0,0 +1,159 @@ +/* Marketing embed — workbench walkthrough (home → prompt → alerts → job). */ + +const App = () => { + const [screen, setScreen] = React.useState('home'); + const [promptKey, setPromptKey] = React.useState(0); + const [jobKey, setJobKey] = React.useState(0); + const [jobPhase, setJobPhase] = React.useState('running'); + const [promptReady, setPromptReady] = React.useState(false); + const screenRef = React.useRef(screen); + screenRef.current = screen; + + const tour = React.useMemo( + () => window.getTourState(screen, promptReady, jobPhase), + [screen, promptReady, jobPhase] + ); + + React.useEffect(() => { + const r = document.documentElement; + r.style.setProperty('--purple-400', '#4A51F2'); + r.style.setProperty('--purple-350', '#5D63F4'); + r.style.setProperty('--purple-300', '#747AF6'); + r.style.setProperty('--purple-200', '#9FA3F9'); + r.style.setProperty('--action-primary', '#4A51F2'); + r.style.setProperty('--action-primary-hover', '#5D63F4'); + r.style.setProperty('--density', '1'); + r.removeAttribute('data-theme'); + }, []); + + const postStep = React.useCallback(() => { + const t = window.getTourState(screen, promptReady, jobPhase); + const step = t?.index ?? 0; + try { + window.parent.postMessage( + { + type: 'workbench-demo-step', + step, + total: t?.total ?? window.TOUR_TOTAL, + title: t?.title ?? null, + barVerb: t?.barVerb ?? null, + barRest: t?.barRest ?? null, + srCaption: t?.caption ?? null, + passive: !!t?.passive, + substep: screen === 'job' ? jobPhase : null, + }, + window.location.origin + ); + } catch (_) {} + }, [screen, promptReady, jobPhase]); + + React.useEffect(() => { + postStep(); + }, [postStep]); + + const resetPromptScreen = React.useCallback(() => { + setPromptReady(false); + setPromptKey((k) => k + 1); + }, []); + + const openPrompt = React.useCallback( + (opts) => { + const reset = opts?.reset !== false; + if (reset) resetPromptScreen(); + setScreen('prompt'); + }, + [resetPromptScreen] + ); + + const handlePromptReady = React.useCallback(() => { + setPromptReady(true); + }, []); + + const openWorkbench = () => setScreen('workbench'); + + const viewJob = (done = false) => { + const settled = done === true; + setJobPhase(settled ? 'done' : 'running'); + setJobKey((k) => k + 1); + setScreen('job'); + }; + + const backToAlerts = () => setScreen('workbench'); + + const goHome = () => { + setPromptReady(false); + setScreen('home'); + }; + + const goToTourStep = React.useCallback( + (step) => { + switch (step) { + case 0: + goHome(); + break; + case 1: + openPrompt({ reset: screenRef.current !== 'prompt' }); + break; + case 2: + setScreen('workbench'); + break; + case 3: + viewJob(false); + break; + case 4: + viewJob(true); + break; + default: + break; + } + }, + [openPrompt] + ); + + React.useEffect(() => { + const onMessage = (event) => { + if (event.origin !== window.location.origin) return; + if (event.source !== window.parent) return; + if (event.data?.type !== 'workbench-demo-advance') return; + const step = Number(event.data.step); + if (Number.isFinite(step) && step >= 0 && step < window.TOUR_TOTAL) { + goToTourStep(step); + } + }; + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [goToTourStep]); + + return ( + +
+ + {screen === 'home' && ( +
+ +
+ )} + {screen === 'prompt' && ( +
+ +
+ )} + {screen === 'workbench' && } + {screen === 'job' && ( + + )} +
+
+ ); +}; + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/public/workbench-demo/cc-home-sections.jsx b/public/workbench-demo/cc-home-sections.jsx new file mode 100644 index 00000000..98af0728 --- /dev/null +++ b/public/workbench-demo/cc-home-sections.jsx @@ -0,0 +1,88 @@ +/* Pre-saved prompts on the workbench home screen. */ + +const SAVED_PROMPTS = [ + { + title: 'Investigate Flow 500s', + description: + 'Triage the firing Flow 500s alert on demo-prod: trace sustained 5xx on GET /ping, pull metrics and logs, and open a minimal fix PR.', + featured: true, + }, + { + title: 'Correlate /ping with metrics', + description: + 'Compare flow-test-stateless request rates, latency, and error budgets against Grafana dashboards for the flow-test namespace.', + }, + { + title: 'Scan logs for root cause', + description: + 'Search pod logs around the alert window for exceptions, failed health checks, and deployment changes tied to Flow 500s.', + }, +]; + +const SavedPromptCard = ({ title, description, onClick, tourId, selected, static: isStatic }) => { + const className = + 'pc-saved-prompt-card' + + (selected ? ' pc-saved-prompt-card--selected' : '') + + (isStatic ? ' pc-saved-prompt-card--static' : ''); + + const body = ( + <> +
+

{title}

+ + + +
+

{description}

+ + ); + + const card = isStatic ? ( +
+ {body} +
+ ) : ( + + ); + + if (!tourId) return card; + + return ( + + + {card} + + + ); +}; + +const CCHomeSections = ({ onComposePrompt, static: isStatic, selectedIndex }) => { + const openPrompt = () => onComposePrompt && onComposePrompt(); + + return ( +
+
+ {SAVED_PROMPTS.map((prompt, index) => { + const isFeatured = index === 0; + const clickable = !isStatic && isFeatured; + + return ( + + ); + })} +
+
+ ); +}; + +Object.assign(window, { CCHomeSections }); diff --git a/public/workbench-demo/cc-home-shared.jsx b/public/workbench-demo/cc-home-shared.jsx new file mode 100644 index 00000000..962f46fe --- /dev/null +++ b/public/workbench-demo/cc-home-shared.jsx @@ -0,0 +1,100 @@ +/* Shared workbench home chrome — hero + prompt card shell. */ + +const PROMPT_CARD_STYLE = { + background: 'var(--fill-one)', + border: '1px solid var(--border-input)', + borderRadius: 'var(--r-large)', + padding: 'var(--sp-medium)', + display: 'flex', + flexDirection: 'column', + gap: 'var(--sp-medium)', + marginBottom: 56, +}; + +const PROMPT_TEXTAREA_STYLE = { + border: 'none', + outline: 'none', + resize: 'none', + background: 'transparent', + color: 'var(--text)', + fontFamily: 'var(--font-sans)', + fontSize: 15, + lineHeight: '24px', + letterSpacing: '0.5px', + minHeight: 56, + width: '100%', +}; + +const WorkbenchHero = () => ( +
+ + + +

+ What can I do for you today, Michael? +

+
+); + +const WorkbenchPillButton = ({ label, onClick, withHint }) => { + const pill = ( + + ); + if (!withHint) return pill; + return ( + + + {pill} + + + ); +}; + +Object.assign(window, { + PROMPT_CARD_STYLE, + PROMPT_TEXTAREA_STYLE, + WorkbenchHero, + WorkbenchPillButton, +}); diff --git a/public/workbench-demo/cc-home.jsx b/public/workbench-demo/cc-home.jsx new file mode 100644 index 00000000..18692027 --- /dev/null +++ b/public/workbench-demo/cc-home.jsx @@ -0,0 +1,47 @@ +/* cc-home.jsx — Workbenches home: hero prompt + pre-saved prompts. */ + +const CCHome = ({ onComposePrompt }) => { + return ( +
+
+ + +
+