Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
- name: Build Web Studio
working-directory: Web/studio
run: npm ci && npm test && npm run build
- name: Verify homepage code and preview
run: node Web/studio/scripts/hero-smoke.ts
- name: Assemble site
run: |
mkdir -p _site
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/web-browsers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ on:
push:
paths:
- 'Web/**'
- 'site/**'
- 'Editor/editorfe/src/**'
- 'Shared/ScriptWidgetRuntime/Widget/Resource/support.bundle/**'
- '.github/workflows/web-browsers.yml'
pull_request:
paths:
- 'Web/**'
- 'site/**'
- 'Editor/editorfe/src/**'
- '.github/workflows/web-browsers.yml'
workflow_dispatch:
Expand Down Expand Up @@ -56,3 +58,6 @@ jobs:
- name: Cross-browser checks
working-directory: Web/studio
run: node scripts/cross-browser.ts chromium firefox webkit
- name: Homepage code and preview across browsers
working-directory: Web/studio
run: node scripts/hero-browsers.ts chromium firefox webkit
47 changes: 47 additions & 0 deletions Web/studio/scripts/hero-browsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Verify the landing-page integration across the same browser engines as Studio.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, firefox, webkit } from 'playwright';
import { serveDirectory } from '../../preview-engine/scripts/lib/chrome.mjs';

const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptwidget-hero-browsers-'));
fs.cpSync(path.join(repository, 'site'), root, { recursive: true });
fs.cpSync(path.join(repository, 'Web/studio/dist'), path.join(root, 'studio'), { recursive: true });
const server = await serveDirectory(root);
const engines = { chromium, firefox, webkit };
const requested = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(engines);
try {
for (const name of requested) {
assert.ok(name in engines, `Unknown browser: ${name}`);
const browser = await engines[name as keyof typeof engines].launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(server.origin);
for (const example of ['Reading habit', 'Weather', 'Countdown']) {
await page.getByRole('button', { name: example, exact: true }).click();
await page.waitForFunction(name => document.getElementById('hero-preview')?.dataset.example === name
&& !!document.querySelector('#hero-preview .sw-widget')
&& document.getElementById('hero-preview')?.getAttribute('aria-busy') === 'false', example);
const source = await page.locator('#hero-source').textContent();
const link = await page.locator('#hero-open').getAttribute('href');
await page.getByRole('button', { name: 'Change widget color' }).click();
await page.waitForFunction(() => document.getElementById('hero-preview')?.getAttribute('aria-busy') === 'false');
assert.notEqual(await page.locator('#hero-source').textContent(), source);
assert.notEqual(await page.locator('#hero-open').getAttribute('href'), link);
}
await page.setViewportSize({ width: 375, height: 844 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
assert.deepEqual(errors, []);
console.log(`${name}: three JSX previews, theme/source/link changes, mobile layout and zero page errors passed.`);
} finally { await browser.close(); }
}
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
91 changes: 91 additions & 0 deletions Web/studio/scripts/hero-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchChrome, serveDirectory, clickReady } from '../../preview-engine/scripts/lib/chrome.mjs';

const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const assembled = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptwidget-hero-'));
// Exercise the deployed /scriptwidget/ prefix, including sandbox/support URLs.
const root = path.join(assembled, 'scriptwidget');
fs.cpSync(path.join(repository, 'site'), root, { recursive: true });
fs.cpSync(path.join(repository, 'Web/studio/dist'), path.join(root, 'studio'), { recursive: true });
const routes: Record<string, { type: string; body: string }> = {};
const server = await serveDirectory(assembled, routes);
const chrome = await launchChrome({ width: 1440, height: 1000, scale: 1 });
const screenshots = process.env.SCRIPTWIDGET_HERO_SCREENSHOTS;
if (screenshots) fs.mkdirSync(screenshots, { recursive: true });
try {
const page = await chrome.newPage();
await page.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
await page.goto(`${server.origin}/scriptwidget/`);
await page.waitFor("!!document.querySelector('#hero-preview .sw-widget')", 20000);
const originalSource = await page.evaluate("document.getElementById('hero-source').textContent");
const links: Array<{ href: string; source: string; name: string }> = [];
for (const [index, name] of ['Reading habit', 'Weather', 'Countdown'].entries()) {
await clickReady(page, `document.querySelectorAll('[data-hero-example]')[${index}]`);
await page.waitFor(`document.getElementById('hero-preview').dataset.example===${JSON.stringify(name)} && document.getElementById('hero-preview').getAttribute('aria-busy')==='false'`);
assert.equal(await page.evaluate("document.querySelectorAll('[data-hero-example][aria-pressed=true]').length"), 1);
const treeBefore = await page.evaluate("document.querySelector('#hero-preview .sw-widget').outerHTML");
await clickReady(page, "document.querySelector('.theme-token')");
await page.waitFor("document.getElementById('hero-preview').getAttribute('aria-busy')==='false'");
const source = await page.evaluate<string>("document.getElementById('hero-source').textContent");
assert.notEqual(await page.evaluate("document.querySelector('#hero-preview .sw-widget').outerHTML"), treeBefore, 'Changing source color must change rendered output');
assert.equal(await page.evaluate("document.activeElement.classList.contains('theme-token')"), true);
links.push({ href: await page.evaluate<string>("document.getElementById('hero-open').href"), source, name });
assert.match(links.at(-1)!.href, /\/scriptwidget\/studio\/#sw-preview=v1\./);
assert.match(await page.evaluate<string>("document.getElementById('hero-status').textContent"), /sample/);
if (screenshots) {
await page.evaluate('scrollTo(0,0)');
fs.writeFileSync(path.join(screenshots, `desktop-${index}.png`), await page.screenshot());
}
}
// Rapid changes must not let an older async render overwrite the latest.
await page.evaluate("document.querySelectorAll('[data-hero-example]')[0].click();document.querySelector('.theme-token').click();document.querySelectorAll('[data-hero-example]')[2].click()");
await page.waitFor("document.getElementById('hero-preview').dataset.example==='Countdown' && document.getElementById('hero-preview').getAttribute('aria-busy')==='false'");
for (const width of [320, 375, 390, 768, 1024, 1440]) {
await page.call('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await page.evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
assert.equal(await page.evaluate('document.documentElement.scrollWidth<=innerWidth'), true, `Page overflow at ${width}`);
assert.equal(await page.evaluate("document.getElementById('hero-preview').scrollWidth<=document.getElementById('hero-preview').clientWidth"), true, `Preview overflow at ${width}`);
if (screenshots) { await page.evaluate('scrollTo(0,0)'); fs.writeFileSync(path.join(screenshots, `layout-${width}.png`), await page.screenshot()); }
}
await page.call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false });
// Each link must import the exact visible code, with prior drafts preserved.
let projectCount = 0;
for (const { href, source, name } of links) {
await page.goto(href);
await page.waitFor("document.getElementById('save-state')?.textContent==='Saved in this browser' && !!document.querySelector('.sw-widget')", 20000);
assert.equal(await page.evaluate('location.hash'), '');
const saved = await page.evaluate<{ files: Record<string, string> }>(`(async()=>{
const db=await new Promise(resolve=>{const r=indexedDB.open('scriptwidget-studio',1);r.onsuccess=()=>resolve(r.result)});
try{return await new Promise(resolve=>{const r=db.transaction('projects').objectStore('projects').get(JSON.parse(localStorage.getItem('scriptwidget-studio.project')));r.onsuccess=()=>resolve(r.result)})}finally{db.close()}
})()`);
assert.equal(saved.files['main.jsx'], source, `${name} must open with the visible theme and full source`);
if (screenshots) {
const folder = path.join(screenshots, 'packages', name);
fs.mkdirSync(folder, { recursive: true });
for (const file of ['main.jsx', 'widget.json']) fs.writeFileSync(path.join(folder, file), saved.files[file]!);
}
const count = await page.evaluate<number>("document.querySelectorAll('#project-list li').length");
assert.ok(count > projectCount, 'Handoff must create a project rather than overwrite the previous draft');
projectCount = count;
}
await page.goto(`${server.origin}/scriptwidget/`);
await page.waitFor("!!document.querySelector('#hero-preview .sw-widget')", 20000);
assert.equal(await page.evaluate("document.getElementById('hero-source').textContent"), originalSource);
// A blocked runtime offers an honest fallback and a working editing link.
routes['/scriptwidget/studio/sandbox.html'] = { type: 'text/html', body: '<!doctype html><title>Runtime unavailable</title>' };
await page.call('Network.enable');
await page.call('Network.setCacheDisabled', { cacheDisabled: true });
await page.goto(`${server.origin}/scriptwidget/?fallback-check`);
await page.waitFor("document.getElementById('hero-status').textContent.includes('could not render')", 15000);
assert.match(await page.evaluate<string>("document.getElementById('hero-preview').textContent"), /unavailable/);
assert.match(await page.evaluate<string>("document.getElementById('hero-open').href"), /#sw-preview=v1\./);
console.log('Hero: 3 real JSX previews, theme changes, rapid switching, 6 screen widths, exact Studio handoff, draft preservation, reload, blocked-runtime fallback passed.');
} finally {
await chrome.close();
server.close();
fs.rmSync(assembled, { recursive: true, force: true });
}
4 changes: 2 additions & 2 deletions Web/studio/scripts/onboarding-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ try {
}
await page.call("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "light" }, { name: "prefers-reduced-motion", value: "reduce" }] });
await page.goto(`${server.origin}/`);
await page.waitFor("[...document.querySelectorAll('.widget-showcase img')].every(i=>i.complete&&i.naturalWidth>0)");
await page.waitFor("!!document.querySelector('#hero-preview .sw-widget')");
assert.equal(await page.evaluate("document.querySelectorAll('.hero .actions a').length"), 3);
assert.equal(await page.evaluate("document.querySelector('.hero .actions a:first-child').classList.contains('primary')"), true);
assert.equal(await page.evaluate("document.querySelector('.hero .actions a:first-child').href"), "https://apps.apple.com/us/app/scriptwidget/id1555600758");
assert.equal(await page.evaluate("document.querySelectorAll('.widget-showcase img').length"), 3);
assert.equal(await page.evaluate("document.querySelectorAll('[data-hero-example]').length"), 3);
assert.equal(await page.evaluate("new URL(document.querySelector('.hero .actions a:last-child').href).pathname"), "/studio/");
const output = process.env.SCRIPTWIDGET_ONBOARDING_SCREENSHOTS;
if (output) { fs.mkdirSync(output, { recursive: true }); fs.writeFileSync(path.join(output, "home-desktop.png"), await page.screenshot()); }
Expand Down
2 changes: 2 additions & 0 deletions Web/studio/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ export default defineConfig({
chunkSizeWarningLimit: 600,
// Libraries change less often than the app: separate chunks cache longer.
rollupOptions: {
input: { studio: path.join(root, "index.html"), hero: path.join(repository, "site/hero.js") },
output: {
entryFileNames: (chunk) => chunk.name === "hero" ? "hero/demo.js" : "assets/[name]-[hash].js",
manualChunks(id) {
if (/node_modules\/(react|react-dom|scheduler|radix-ui|@radix-ui|sonner|@floating-ui)\//.test(id)) return "ui";
if (/node_modules\/(@codemirror|@lezer|crelt|style-mod|w3c-keyname)\//.test(id)) return "editor";
Expand Down
31 changes: 30 additions & 1 deletion site/growth.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,34 @@
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.button{transition:none}.button:hover{transform:none}}
.copy-block code{white-space:pre-wrap;overflow-wrap:anywhere}

.hero h1{font-size:clamp(32px,5vw,60px);line-height:1.04}
.hero h1{font-size:clamp(32px,4vw,50px);line-height:1.04}
.hero h1 span{display:block}

/* The source and preview share one runnable example, with no autoplay. */
.hero{grid-template-columns:.95fr 1.05fr;gap:36px}
.code-showcase{min-width:0;background:#fff;border:1px solid var(--line);border-radius:22px;box-shadow:0 16px 48px #17202a08;overflow:hidden}
.demo-heading{display:flex;align-items:center;gap:9px;padding:20px 20px 12px}
.demo-heading h2{font-size:17px;line-height:1.4;letter-spacing:-.025em;margin:0}
.demo-dot{width:8px;height:8px;border-radius:50%;background:#258557}
.demo-tabs{display:flex;gap:4px;padding:0 16px 16px;flex-wrap:wrap}
.demo-tabs button{font-family:inherit;line-height:1.4;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid transparent;border-radius:8px;padding:8px 10px;cursor:pointer}
.demo-tabs button[aria-pressed=true]{background:#edf5ef;border-color:#d7e7dc;color:#226342}
.demo-workspace{display:grid;grid-template-columns:minmax(0,1fr) 212px;border-block:1px solid var(--line)}
.demo-editor{min-width:0;background:#fafbf9;border-right:1px solid var(--line)}
.demo-pane-label{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
.demo-pane-label span{font-size:10px;color:#88908d}
.demo-editor pre{font:11px/1.85 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:auto;margin:0;padding:8px 14px 20px;height:306px;tab-size:2;color:#33443b}
.syntax-keyword{color:#6855a5}.syntax-string{color:#287447}
.theme-token{font:inherit;border:0;border-bottom:2px dotted var(--swatch);padding:0 2px;background:#e9f2ec;border-radius:3px;cursor:pointer}
.demo-result{min-width:0;display:flex;flex-direction:column;background:linear-gradient(145deg,#fcfdfb,#eef3ec)}
#hero-preview{min-height:240px;display:flex;align-items:center;justify-content:center;font-size:12px;padding:0 2px;text-align:center}
.hero-widget-frame{flex-shrink:0;border-radius:22px;overflow:hidden;filter:drop-shadow(0 8px 12px #243d2410)}
#hero-status{font-size:10px;line-height:1.5;text-align:center;color:var(--muted);padding:0 10px;margin:auto 0 18px}
.demo-footer{padding:16px 18px;display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center}
.demo-footer span{font-size:11px;color:var(--muted)}
.demo-footer a{font-size:12px;font-weight:700;color:#226342;text-underline-offset:4px}
.demo-fallback{padding:0 18px;font-size:13px}
.demo-tabs button:focus-visible,.theme-token:focus-visible,.demo-footer a:focus-visible,.demo-editor pre:focus-visible{outline:3px solid #0a84ff;outline-offset:2px}
@media(max-width:1100px) and (min-width:821px){.hero{grid-template-columns:1fr}.code-showcase{width:100%;max-width:720px}.demo-workspace{grid-template-columns:minmax(0,1fr) 240px}}
@media(max-width:820px){.hero{grid-template-columns:1fr}.code-showcase{width:100%}}
@media(max-width:540px){.demo-workspace{grid-template-columns:1fr}.demo-editor{border-right:0;border-bottom:1px solid var(--line)}.demo-editor pre{height:240px;font-size:12px}.demo-result{min-height:245px}#hero-preview{min-height:180px}.demo-pane-label{padding-block:10px}#hero-status{margin:8px 0 16px}.demo-footer{padding:14px}.demo-heading{padding-inline:14px}}
Loading
Loading