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 }) => (',
- ' ',
- ' {items.map(item => )}',
- '
',
- ')',
- '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) => (',
+ ' ',
+ ')',
+ '',
+ '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);