diff --git a/playwright/app.spec.ts b/playwright/app.spec.ts index 55a6a54..d52b2ea 100644 --- a/playwright/app.spec.ts +++ b/playwright/app.spec.ts @@ -11,6 +11,14 @@ const waitForInitialRender = async (page: Page) => { await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '') } +const expectPreviewHasRenderedContent = async (page: Page) => { + const previewHost = page.locator('#preview-host') + await expect(previewHost.locator('pre')).toHaveCount(0) + await expect + .poll(() => previewHost.evaluate(node => node.childElementCount)) + .toBeGreaterThan(0) +} + const setComponentEditorSource = async (page: Page, source: string) => { const editorContent = page.locator('.component-panel .cm-content').first() await editorContent.fill(source) @@ -26,10 +34,7 @@ test('renders default playground preview', async ({ page }) => { await page.getByLabel('ShadowRoot (open)').uncheck() await expect(page.locator('#status')).toHaveText('Rendered') - - const previewItems = page.locator('#preview-host li') - await expect(previewItems).toHaveCount(3) - await expect(previewItems.first()).toContainText('apple') + await expectPreviewHasRenderedContent(page) }) test('supports layout and theme toggles', async ({ page }) => { @@ -56,10 +61,7 @@ test('renders in react mode with css modules', async ({ page }) => { await page.locator('#render-mode').selectOption('react') await page.locator('#style-mode').selectOption('module') await expect(page.locator('#status')).toHaveText('Rendered') - - const previewItems = page.locator('#preview-host li') - await expect(previewItems).toHaveCount(3) - await expect(previewItems.first()).toContainText('apple') + await expectPreviewHasRenderedContent(page) }) test('transpiles TypeScript annotations in component source', async ({ page }) => { @@ -156,10 +158,7 @@ test('renders with less style mode', async ({ page }) => { await expect(page.locator('#style-warning')).toContainText( 'Less is compiled in-browser via @knighted/css/browser.', ) - - const previewItems = page.locator('#preview-host li') - await expect(previewItems).toHaveCount(3) - await expect(previewItems.first()).toContainText('apple') + await expectPreviewHasRenderedContent(page) }) test('renders with sass style mode', async ({ page }) => { @@ -171,10 +170,7 @@ test('renders with sass style mode', async ({ page }) => { await expect(page.locator('#style-warning')).toContainText( 'Sass is compiled in-browser via @knighted/css/browser.', ) - - const previewItems = page.locator('#preview-host li') - await expect(previewItems).toHaveCount(3) - await expect(previewItems.first()).toContainText('apple') + await expectPreviewHasRenderedContent(page) }) test('style compilation errors populate styles diagnostics scope', async ({ page }) => { diff --git a/scripts/build-prepare.js b/scripts/build-prepare.js index 4179319..336c169 100644 --- a/scripts/build-prepare.js +++ b/scripts/build-prepare.js @@ -1,7 +1,7 @@ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { cdnImportSpecs } from '../src/cdn.js' +import { cdnImportSpecs } from '../src/modules/cdn.js' const validPrimaryCdns = new Set(['importMap', 'esm', 'jspmGa']) @@ -49,12 +49,14 @@ const createProdImportsModule = async () => { ] if (specifiers.length === 0) { - throw new Error('No importMap specifiers found in src/cdn.js (cdnImportSpecs).') + throw new Error( + 'No importMap specifiers found in src/modules/cdn.js (cdnImportSpecs).', + ) } const lines = [ '/*', - ' * Generated by scripts/build-prepare.js from src/cdn.js (cdnImportSpecs).', + ' * Generated by scripts/build-prepare.js from src/modules/cdn.js (cdnImportSpecs).', ' * JSPM links this module to trace top-level production imports.', ' */', ...specifiers.map(specifier => `import '${specifier}'`), diff --git a/src/app.js b/src/app.js index 0365ac5..8f4b3fb 100644 --- a/src/app.js +++ b/src/app.js @@ -1,11 +1,15 @@ -import { cdnImports, getTypeScriptLibUrls, importFromCdnWithFallback } from './cdn.js' -import { createCodeMirrorEditor } from './editor-codemirror.js' -import { defaultCss, defaultJsx } from './defaults.js' -import { createDiagnosticsUiController } from './diagnostics-ui.js' -import { createLayoutThemeController } from './layout-theme.js' -import { createPreviewBackgroundController } from './preview-background.js' -import { createRenderRuntimeController } from './render-runtime.js' -import { createTypeDiagnosticsController } from './type-diagnostics.js' +import { + cdnImports, + getTypeScriptLibUrls, + importFromCdnWithFallback, +} from './modules/cdn.js' +import { createCodeMirrorEditor } from './modules/editor-codemirror.js' +import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js' +import { createDiagnosticsUiController } from './modules/diagnostics-ui.js' +import { createLayoutThemeController } from './modules/layout-theme.js' +import { createPreviewBackgroundController } from './modules/preview-background.js' +import { createRenderRuntimeController } from './modules/render-runtime.js' +import { createTypeDiagnosticsController } from './modules/type-diagnostics.js' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') @@ -48,6 +52,7 @@ let getCssSource = () => cssEditor.value let renderRuntime = null let pendingClearAction = null let suppressEditorChangeSideEffects = false +let hasAppliedReactModeDefault = false const clipboardSupported = Boolean(navigator.clipboard?.writeText) const previewBackground = createPreviewBackgroundController({ @@ -346,7 +351,15 @@ const updateRenderButtonVisibility = () => { renderButton.hidden = autoRenderToggle.checked } -renderMode.addEventListener('change', maybeRender) +renderMode.addEventListener('change', () => { + if (renderMode.value === 'react' && !hasAppliedReactModeDefault) { + hasAppliedReactModeDefault = true + setJsxSource(defaultReactJsx) + markTypeDiagnosticsStale() + } + + maybeRender() +}) styleMode.addEventListener('change', () => { if (cssCodeEditor) { cssCodeEditor.setLanguage(getStyleEditorLanguage(styleMode.value)) diff --git a/src/bootstrap.js b/src/bootstrap.js index 21fb4ec..f7c6032 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -1,10 +1,10 @@ -import { getPrimaryCdnImportUrls } from './cdn.js' +import { getPrimaryCdnImportUrls } from './modules/cdn.js' /* * Preload only the modules needed for the initial render path. * - Included: core runtime + React runtime modules used immediately. * - Excluded: optional style compilers (sass/less/lightningCssWasm), which stay lazy. - * Keep this list aligned with cdnImports keys in cdn.js. + * Keep this list aligned with cdnImports keys in modules/cdn.js. */ const preloadImportKeys = [ 'cssBrowser', diff --git a/src/defaults.js b/src/defaults.js deleted file mode 100644 index 2d5516b..0000000 --- a/src/defaults.js +++ /dev/null @@ -1,114 +0,0 @@ -export const defaultJsx = [ - 'const Item = ({ value }) =>
  • {value}
  • ', - 'const List = ({ items, onClick }) => (', - ' ', - ')', - 'const Checkbox = ({ checked = false }) => ', - 'const App = () => {', - " const items = ['apple', 'banana', 'orange']", - ' const checkbox = ', - ' const onClickList = evt => {', - ' if (evt.target.contains(checkbox)) {', - ' checkbox.remove()', - ' } else {', - ' evt.target.appendChild(checkbox)', - ' }', - ' }', - '', - ' return ', - '}', - '', -].join('\n') - -export const defaultCss = `ul { - --list-bg: linear-gradient(160deg, #d4dcec 0%, #c4cfe6 100%); - --list-border: #a5b4d8; - --item-bg: #b9bcc4; - --item-hover: #c5c8cf; - --item-text: #1f2a44; - --item-accent: #3658c8; - --check-ring: #6d86d1; - - margin: 0; - padding: 14px; - list-style: none; - display: grid; - gap: 10px; - max-width: 340px; - border-radius: 16px; - border: 1px solid var(--list-border); - background: var(--list-bg); - box-shadow: - 0 14px 30px rgba(17, 27, 56, 0.16), - inset 0 1px 0 rgba(255, 255, 255, 0.55); -} - -li { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - min-height: 40px; - padding: 10px 12px; - border-radius: 12px; - border: 1px solid rgba(54, 88, 200, 0.24); - background: var(--item-bg); - color: var(--item-text); - font-weight: 650; - letter-spacing: 0.01em; - cursor: pointer; - user-select: none; - transition: - transform 130ms ease, - background-color 130ms ease, - border-color 130ms ease, - box-shadow 130ms ease; -} - -li:hover { - transform: translateY(-1px); - background: var(--item-hover); - border-color: rgba(54, 88, 200, 0.34); - box-shadow: 0 8px 18px rgba(24, 40, 95, 0.16); -} - -input[type='checkbox'] { - appearance: none; - width: 18px; - height: 18px; - margin: 0; - border-radius: 5px; - border: 1.5px solid #4a63b6; - background: #fff; - display: inline-grid; - place-items: center; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); -} - -input[type='checkbox']::after { - content: ''; - width: 8px; - height: 4px; - border: 2px solid #fff; - border-top: 0; - border-right: 0; - transform: rotate(-45deg) scale(0); - transition: transform 120ms ease; -} - -input[type='checkbox']:checked { - background: linear-gradient(145deg, var(--item-accent), #345ee0); - border-color: var(--item-accent); -} - -input[type='checkbox']:checked::after { - transform: rotate(-45deg) scale(1); -} - -input[type='checkbox']:focus-visible { - outline: 2px solid var(--check-ring); - outline-offset: 2px; -} -` diff --git a/src/cdn.js b/src/modules/cdn.js similarity index 100% rename from src/cdn.js rename to src/modules/cdn.js diff --git a/src/modules/defaults.js b/src/modules/defaults.js new file mode 100644 index 0000000..be44a6d --- /dev/null +++ b/src/modules/defaults.js @@ -0,0 +1,94 @@ +export const defaultJsx = [ + 'type CounterButtonProps = {', + ' label: string', + ' onClick: (event: MouseEvent) => void', + '}', + '', + 'const CounterButton = ({ label, onClick }: CounterButtonProps) => (', + ' ', + ')', + '', + 'const App = () => {', + ' let count = 0', + ' const handleClick = (event: MouseEvent) => {', + ' count += 1', + ' const button = event.currentTarget as HTMLButtonElement', + ' button.textContent = `Clicks: ${count}`', + " button.dataset.active = count % 2 === 0 ? 'false' : 'true'", + " button.classList.toggle('is-even', count % 2 === 0)", + ' }', + '', + " return ", + '}', + '', +].join('\n') + +export const defaultReactJsx = [ + 'type CounterButtonProps = {', + ' label: string', + ' active: boolean', + ' onClick: (event: MouseEvent) => void', + '}', + '', + 'const CounterButton = ({ label, active, onClick }: CounterButtonProps) => (', + ' ', + ' {label}', + ' ', + ')', + '', + 'const App = () => {', + ' const { useState } = React', + ' const [count, setCount] = useState(0)', + ' const handleClick = (_event: MouseEvent) => {', + ' setCount(current => current + 1)', + ' }', + '', + ' return (', + ' ', + ' )', + '}', + '', +].join('\n') + +export const defaultCss = `#counter-button { + margin: 0; + padding: 0.75rem 1rem; + border: 1px solid #3558b8; + border-radius: 0.5rem; + background: #e9efff; + color: #1a2a52; + font-weight: 600; + cursor: pointer; + transition: background-color 120ms ease; +} + +#counter-button:hover { + background: #dce6ff; +} + +#counter-button[data-active='true'] { + background: #3558b8; + color: #fff; +} + +#counter-button.is-even { + border-style: dashed; +} + +#counter-button:focus-visible { + outline: 2px solid #6a84d8; + outline-offset: 2px; +} +` diff --git a/src/diagnostics-ui.js b/src/modules/diagnostics-ui.js similarity index 95% rename from src/diagnostics-ui.js rename to src/modules/diagnostics-ui.js index 5ad0885..c559d99 100644 --- a/src/diagnostics-ui.js +++ b/src/modules/diagnostics-ui.js @@ -55,6 +55,10 @@ export const createDiagnosticsUiController = ({ return 'pending' } + if (diagnosticsByScope.component.level === 'ok') { + return 'ok' + } + return 'neutral' } @@ -67,6 +71,7 @@ export const createDiagnosticsUiController = ({ if (diagnosticsToggle) { diagnosticsToggle.classList.remove( 'diagnostics-toggle--neutral', + 'diagnostics-toggle--ok', 'diagnostics-toggle--pending', 'diagnostics-toggle--error', ) @@ -108,7 +113,10 @@ export const createDiagnosticsUiController = ({ if (hasHeadline) { const headingNode = document.createElement('div') - headingNode.className = 'type-diagnostics-heading' + headingNode.className = + state.level === 'ok' + ? 'type-diagnostics-heading type-diagnostics-heading--ok' + : 'type-diagnostics-heading' headingNode.textContent = state.headline root.append(headingNode) } diff --git a/src/editor-codemirror.js b/src/modules/editor-codemirror.js similarity index 100% rename from src/editor-codemirror.js rename to src/modules/editor-codemirror.js diff --git a/src/layout-theme.js b/src/modules/layout-theme.js similarity index 100% rename from src/layout-theme.js rename to src/modules/layout-theme.js diff --git a/src/preview-background.js b/src/modules/preview-background.js similarity index 100% rename from src/preview-background.js rename to src/modules/preview-background.js diff --git a/src/render-runtime.js b/src/modules/render-runtime.js similarity index 100% rename from src/render-runtime.js rename to src/modules/render-runtime.js diff --git a/src/type-diagnostics.js b/src/modules/type-diagnostics.js similarity index 100% rename from src/type-diagnostics.js rename to src/modules/type-diagnostics.js diff --git a/src/styles/diagnostics.css b/src/styles/diagnostics.css index 35aaf37..300675b 100644 --- a/src/styles/diagnostics.css +++ b/src/styles/diagnostics.css @@ -20,6 +20,13 @@ margin: 0; } +.type-diagnostics-heading--ok::before { + content: '\2713'; + color: #22c55e; + font-weight: 700; + margin-right: 6px; +} + .type-diagnostics-list { margin: 8px 0 0; padding-left: 0; @@ -60,6 +67,17 @@ color: var(--shell-text); } +.diagnostics-toggle--ok { + border-color: color-mix(in srgb, #22c55e 52%, var(--border-control)); + background: color-mix(in srgb, #22c55e 14%, transparent); + color: color-mix(in srgb, #22c55e 88%, var(--panel-text)); +} + +.diagnostics-toggle--ok::after { + content: ' \2713'; + font-weight: 700; +} + .diagnostics-toggle--pending { border-color: color-mix(in srgb, var(--accent) 55%, var(--border-control)); background: color-mix(in srgb, var(--accent) 18%, transparent);