diff --git a/docs/next-steps.md b/docs/next-steps.md index 99677f4..eebda75 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -22,3 +22,8 @@ Focused follow-up work for `@knighted/develop`. 5. **In-browser component testing** - Explore authoring and running component-focused tests in-browser (for example, a Vitest-compatible flow) using CDN-delivered tooling. - Define a lightweight test UX that supports writing tests, running them on demand, and displaying results in-app. + +6. **CDN failure recovery UX** + - Detect transient CDN/module loading failures and surface a clear recovery action in-app. + - Add a user-triggered retry path (for example, Reload page / Force reload) when runtime bootstrap imports fail. + - Consider an optional automatic one-time retry before showing recovery controls, while avoiding infinite reload loops. diff --git a/eslint.config.js b/eslint.config.js index 7adedfa..b368ba7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,9 @@ export default [ sourceType: 'module', ecmaVersion: 'latest', }, + rules: { + 'no-unused-vars': 'error', + }, }, { ...playwrightConfig, diff --git a/playwright/app.spec.ts b/playwright/app.spec.ts index d52b2ea..6684213 100644 --- a/playwright/app.spec.ts +++ b/playwright/app.spec.ts @@ -29,6 +29,50 @@ const setStylesEditorSource = async (page: Page, source: string) => { await editorContent.fill(source) } +const getCollapseButton = (page: Page, panelName: 'component' | 'styles' | 'preview') => + page.locator(`#collapse-${panelName}`) + +const getToolsButton = (page: Page, panelName: 'component' | 'styles') => + page.locator(`#tools-${panelName}`) + +const ensurePanelToolsVisible = async (page: Page, panelName: 'component' | 'styles') => { + const button = getToolsButton(page, panelName) + const isPressed = await button.getAttribute('aria-pressed') + if (isPressed !== 'true') { + await button.click() + } +} + +const expectCollapseButtonState = async ( + page: Page, + panelName: 'component' | 'styles' | 'preview', + { + axis, + direction, + collapsed, + disabled, + }: { + axis: 'vertical' | 'horizontal' + direction: 'left' | 'right' | 'none' + collapsed: boolean + disabled?: boolean + }, +) => { + const button = getCollapseButton(page, panelName) + + await expect(button).toHaveAttribute('data-collapse-axis', axis) + await expect(button).toHaveAttribute('data-collapse-direction', direction) + await expect(button).toHaveAttribute('data-collapsed', collapsed ? 'true' : 'false') + + if (disabled !== undefined) { + if (disabled) { + await expect(button).toBeDisabled() + } else { + await expect(button).toBeEnabled() + } + } +} + test('renders default playground preview', async ({ page }) => { await waitForInitialRender(page) @@ -54,9 +98,219 @@ test('supports layout and theme toggles', async ({ page }) => { ) }) +test('side layout keeps preview panel height within editor stack height', async ({ + page, +}) => { + await waitForInitialRender(page) + + await page.getByLabel('Use side preview layout').click() + await expect(page.locator('.app-grid')).toHaveClass(/app-grid--preview-right/) + + const metrics = await page.evaluate(() => { + const stack = document.querySelector('.panels-stack--editors') + const previewPanel = document.getElementById('preview-panel') + const stackHeight = stack?.getBoundingClientRect().height ?? 0 + const previewHeight = previewPanel?.getBoundingClientRect().height ?? 0 + const previewOverflowY = previewPanel ? getComputedStyle(previewPanel).overflowY : '' + return { stackHeight, previewHeight, previewOverflowY } + }) + + expect(metrics.stackHeight).toBeGreaterThan(0) + expect(metrics.previewHeight).toBeGreaterThan(0) + expect(metrics.previewHeight).toBeLessThanOrEqual(metrics.stackHeight + 2) + expect(metrics.previewOverflowY).toBe('hidden') +}) + +test('side layout config keeps preview scrolling inside preview host', async ({ + page, +}) => { + await waitForInitialRender(page) + + await page.getByLabel('Use side preview layout').click() + + const scrollConfig = await page.evaluate(() => { + const previewPanel = document.getElementById('preview-panel') + const previewHost = document.getElementById('preview-host') + if (!previewPanel || !previewHost) { + return null + } + + const panelStyles = getComputedStyle(previewPanel) + const styles = getComputedStyle(previewHost) + return { + panelOverflowY: panelStyles.overflowY, + panelOverflowX: panelStyles.overflowX, + overflowY: styles.overflowY, + minHeight: styles.minHeight, + } + }) + + expect(scrollConfig).not.toBeNull() + expect(scrollConfig?.panelOverflowY).toBe('hidden') + expect(scrollConfig?.panelOverflowX).toBe('hidden') + expect(['auto', 'scroll']).toContain(scrollConfig?.overflowY) + expect(scrollConfig?.minHeight).toBe('0px') +}) + +test('expanded component and styles can shrink consistently in side layouts', async ({ + page, +}) => { + await waitForInitialRender(page) + + for (const layoutLabel of ['Use side preview layout', 'Use left preview layout']) { + await page.getByLabel(layoutLabel).click() + + const minHeights = await page.evaluate(() => { + const component = document.getElementById('component-panel') + const styles = document.getElementById('styles-panel') + return { + component: component + ? Number.parseFloat(getComputedStyle(component).minHeight) + : 0, + styles: styles ? Number.parseFloat(getComputedStyle(styles).minHeight) : 0, + } + }) + + expect(minHeights.component).toBeGreaterThanOrEqual(0) + expect(minHeights.styles).toBeGreaterThanOrEqual(0) + expect(Math.abs(minHeights.component - minHeights.styles)).toBeLessThanOrEqual(1) + } +}) + +test('panel collapse axis and direction adapt to active layout', async ({ page }) => { + await waitForInitialRender(page) + await expect(page.locator('.app-grid')).toHaveClass(/app-grid/) + + await expectCollapseButtonState(page, 'component', { + axis: 'horizontal', + direction: 'left', + collapsed: false, + }) + await expectCollapseButtonState(page, 'styles', { + axis: 'horizontal', + direction: 'right', + collapsed: false, + }) + await expectCollapseButtonState(page, 'preview', { + axis: 'vertical', + direction: 'none', + collapsed: false, + }) + + await page.getByLabel('Use side preview layout').click() + await expectCollapseButtonState(page, 'preview', { + axis: 'horizontal', + direction: 'right', + collapsed: false, + }) + await expectCollapseButtonState(page, 'component', { + axis: 'vertical', + direction: 'none', + collapsed: false, + }) + + await page.getByLabel('Use left preview layout').click() + await expectCollapseButtonState(page, 'preview', { + axis: 'horizontal', + direction: 'left', + collapsed: false, + }) +}) + +test('prevents collapsing all three panels at once', async ({ page }) => { + await waitForInitialRender(page) + + await getCollapseButton(page, 'component').click() + await getCollapseButton(page, 'styles').click() + + await expect(page.locator('#component-panel')).toHaveClass( + /panel--collapsed-horizontal/, + ) + await expect(page.locator('#styles-panel')).toHaveClass(/panel--collapsed-horizontal/) + + await expectCollapseButtonState(page, 'preview', { + axis: 'vertical', + direction: 'none', + collapsed: false, + disabled: true, + }) + await expect(getCollapseButton(page, 'preview')).toHaveAttribute( + 'title', + 'At least one panel must remain expanded.', + ) + + await getCollapseButton(page, 'component').click() + await expectCollapseButtonState(page, 'preview', { + axis: 'vertical', + direction: 'none', + collapsed: false, + disabled: false, + }) +}) + +test('does not persist panel collapse state across reload', async ({ page }) => { + await waitForInitialRender(page) + + await getCollapseButton(page, 'component').click() + await expect(page.locator('#component-panel')).toHaveClass( + /panel--collapsed-horizontal/, + ) + await expectCollapseButtonState(page, 'component', { + axis: 'horizontal', + direction: 'left', + collapsed: true, + }) + + await page.reload() + await waitForInitialRender(page) + + await expect(page.locator('#component-panel')).not.toHaveClass( + /panel--collapsed-horizontal|panel--collapsed-vertical/, + ) + await expectCollapseButtonState(page, 'component', { + axis: 'horizontal', + direction: 'left', + collapsed: false, + }) +}) + +test('gear tools toggles default inactive and switch active/inactive per panel', async ({ + page, +}) => { + await waitForInitialRender(page) + + const componentPanel = page.locator('#component-panel') + const stylesPanel = page.locator('#styles-panel') + const componentTools = getToolsButton(page, 'component') + const stylesTools = getToolsButton(page, 'styles') + + await expect(componentPanel).toHaveClass(/panel--tools-hidden/) + await expect(stylesPanel).toHaveClass(/panel--tools-hidden/) + await expect(componentTools).toHaveAttribute('aria-pressed', 'false') + await expect(stylesTools).toHaveAttribute('aria-pressed', 'false') + + await componentTools.click() + await expect(componentPanel).not.toHaveClass(/panel--tools-hidden/) + await expect(componentTools).toHaveAttribute('aria-pressed', 'true') + await expect(componentTools).toHaveAttribute('title', 'Hide component tools') + + await componentTools.click() + await expect(componentPanel).toHaveClass(/panel--tools-hidden/) + await expect(componentTools).toHaveAttribute('aria-pressed', 'false') + await expect(componentTools).toHaveAttribute('title', 'Show component tools') + + await stylesTools.click() + await expect(stylesPanel).not.toHaveClass(/panel--tools-hidden/) + await expect(stylesTools).toHaveAttribute('aria-pressed', 'true') + await expect(stylesTools).toHaveAttribute('title', 'Hide styles tools') +}) + test('renders in react mode with css modules', async ({ page }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + await page.getByLabel('ShadowRoot (open)').uncheck() await page.locator('#render-mode').selectOption('react') await page.locator('#style-mode').selectOption('module') @@ -117,21 +371,21 @@ test('jsx syntax errors affect status but not diagnostics toggle severity', asyn test('requires render button when auto render is disabled', async ({ page }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + const autoRenderToggle = page.getByLabel('Auto render') const renderButton = page.getByRole('button', { name: 'Render' }) const styleMode = page.locator('#style-mode') - const styleWarning = page.locator('#style-warning') - await expect(styleWarning).toHaveText('') await autoRenderToggle.uncheck() await expect(renderButton).toBeVisible() await styleMode.selectOption('module') - await expect(styleWarning).toHaveText('') await renderButton.click() await expect(page.locator('#status')).toHaveText('Rendered') - await expect(styleWarning).toContainText('CSS Modules are compiled in-browser') + await expect(page.locator('#preview-host pre')).toHaveCount(0) }) test('persists layout and theme across reload', async ({ page }) => { @@ -152,30 +406,30 @@ test('persists layout and theme across reload', async ({ page }) => { test('renders with less style mode', async ({ page }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'styles') + await page.getByLabel('ShadowRoot (open)').uncheck() await page.locator('#style-mode').selectOption('less') await expect(page.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#style-warning')).toContainText( - 'Less is compiled in-browser via @knighted/css/browser.', - ) await expectPreviewHasRenderedContent(page) }) test('renders with sass style mode', async ({ page }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'styles') + await page.getByLabel('ShadowRoot (open)').uncheck() await page.locator('#style-mode').selectOption('sass') await expect(page.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#style-warning')).toContainText( - 'Sass is compiled in-browser via @knighted/css/browser.', - ) await expectPreviewHasRenderedContent(page) }) test('style compilation errors populate styles diagnostics scope', async ({ page }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'styles') + await page.locator('#style-mode').selectOption('sass') await setStylesEditorSource(page, '.card { color: $missing; }') @@ -234,6 +488,8 @@ test('clearing styles keeps diagnostics error state but resets status styling', }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await setComponentEditorSource( page, ["const count: number = 'oops'", 'const App = () => '].join( @@ -268,6 +524,8 @@ test('clear component diagnostics removes type errors and restores rendered stat }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await setComponentEditorSource( page, ["const count: number = 'oops'", 'const App = () => '].join( @@ -298,6 +556,8 @@ test('clear component diagnostics removes type errors and restores rendered stat test('clear all diagnostics removes style compile diagnostics', async ({ page }) => { await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'styles') + await page.locator('#style-mode').selectOption('sass') await setStylesEditorSource(page, '.card { color: $missing; }') diff --git a/src/app.js b/src/app.js index 8f4b3fb..41317c4 100644 --- a/src/app.js +++ b/src/app.js @@ -15,6 +15,11 @@ const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]') const appThemeButtons = document.querySelectorAll('[data-app-theme]') +const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]') +const panelCollapseButtons = document.querySelectorAll('[data-panel-collapse]') +const componentPanel = document.getElementById('component-panel') +const stylesPanel = document.getElementById('styles-panel') +const previewPanel = document.getElementById('preview-panel') const renderMode = document.getElementById('render-mode') const autoRenderToggle = document.getElementById('auto-render') const typecheckButton = document.getElementById('typecheck-button') @@ -27,7 +32,6 @@ const clearStylesButton = document.getElementById('clear-styles') const shadowToggle = document.getElementById('shadow-toggle') const jsxEditor = document.getElementById('jsx-editor') const cssEditor = document.getElementById('css-editor') -const styleWarning = document.getElementById('style-warning') const diagnosticsToggle = document.getElementById('diagnostics-toggle') const diagnosticsDrawer = document.getElementById('diagnostics-drawer') const diagnosticsClose = document.getElementById('diagnostics-close') @@ -71,6 +75,206 @@ const layoutTheme = createLayoutThemeController({ const { applyAppGridLayout, applyTheme, getInitialAppGridLayout, getInitialTheme } = layoutTheme +const compactViewportMediaQuery = window.matchMedia('(max-width: 900px)') + +const getCurrentLayout = () => { + if (appGrid.classList.contains('app-grid--preview-right')) { + return 'preview-right' + } + + if (appGrid.classList.contains('app-grid--preview-left')) { + return 'preview-left' + } + + return 'default' +} + +const isCompactViewport = () => compactViewportMediaQuery.matches + +const getPanelCollapseAxis = panelName => { + if (isCompactViewport()) { + return 'vertical' + } + + const layout = getCurrentLayout() + + if (panelName === 'preview') { + return layout === 'default' ? 'vertical' : 'horizontal' + } + + if (panelName === 'component' || panelName === 'styles') { + return layout === 'default' ? 'horizontal' : 'vertical' + } + + return 'vertical' +} + +const getPanelCollapseDirection = panelName => { + const axis = getPanelCollapseAxis(panelName) + if (axis !== 'horizontal') { + return 'none' + } + + const layout = getCurrentLayout() + + if (panelName === 'preview') { + return layout === 'preview-left' ? 'left' : 'right' + } + + if (panelName === 'component') { + return 'left' + } + + if (panelName === 'styles') { + return 'right' + } + + return 'right' +} + +const panelCollapseState = { + component: false, + styles: false, + preview: false, +} + +const panelToolsState = { + component: false, + styles: false, +} + +const applyEditorToolsVisibility = () => { + componentPanel?.classList.toggle('panel--tools-hidden', !panelToolsState.component) + stylesPanel?.classList.toggle('panel--tools-hidden', !panelToolsState.styles) + + for (const button of editorToolsButtons) { + const panelName = button.dataset.editorToolsToggle + if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { + continue + } + + const isVisible = panelToolsState[panelName] + button.setAttribute('aria-pressed', isVisible ? 'true' : 'false') + button.setAttribute('aria-label', `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`) + button.setAttribute('title', `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`) + } +} + +const normalizePanelCollapseState = () => { + const collapsedPanels = Object.entries(panelCollapseState) + .filter(([, isCollapsed]) => isCollapsed) + .map(([panelName]) => panelName) + + if (collapsedPanels.length === Object.keys(panelCollapseState).length) { + panelCollapseState.preview = false + } +} + +const syncPanelCollapseButtons = () => { + const collapsedCount = Object.values(panelCollapseState).filter(Boolean).length + + for (const button of panelCollapseButtons) { + const panelName = button.dataset.panelCollapse + if (!panelName || !Object.hasOwn(panelCollapseState, panelName)) { + continue + } + + const axis = getPanelCollapseAxis(panelName) + const direction = getPanelCollapseDirection(panelName) + const isCollapsed = panelCollapseState[panelName] === true + const panelTitle = `${panelName.charAt(0).toUpperCase()}${panelName.slice(1)}` + const canCollapse = isCollapsed || collapsedCount < 2 + + button.dataset.collapseAxis = axis + button.dataset.collapseDirection = direction + button.dataset.collapsed = isCollapsed ? 'true' : 'false' + button.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true') + button.disabled = !canCollapse + button.setAttribute('aria-disabled', canCollapse ? 'false' : 'true') + button.setAttribute( + 'aria-label', + `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel`, + ) + button.setAttribute( + 'title', + canCollapse + ? `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel` + : 'At least one panel must remain expanded.', + ) + } +} + +const applyPanelCollapseState = () => { + normalizePanelCollapseState() + + const previewAxis = getPanelCollapseAxis('preview') + const componentAxis = getPanelCollapseAxis('component') + const stylesAxis = getPanelCollapseAxis('styles') + + if (componentPanel) { + const isCollapsed = panelCollapseState.component + componentPanel.classList.toggle( + 'panel--collapsed-vertical', + isCollapsed && componentAxis === 'vertical', + ) + componentPanel.classList.toggle( + 'panel--collapsed-horizontal', + isCollapsed && componentAxis === 'horizontal', + ) + } + + if (stylesPanel) { + const isCollapsed = panelCollapseState.styles + stylesPanel.classList.toggle( + 'panel--collapsed-vertical', + isCollapsed && stylesAxis === 'vertical', + ) + stylesPanel.classList.toggle( + 'panel--collapsed-horizontal', + isCollapsed && stylesAxis === 'horizontal', + ) + } + + if (previewPanel) { + const isCollapsed = panelCollapseState.preview + previewPanel.classList.toggle( + 'panel--collapsed-vertical', + isCollapsed && previewAxis === 'vertical', + ) + previewPanel.classList.toggle( + 'panel--collapsed-horizontal', + isCollapsed && previewAxis === 'horizontal', + ) + } + + appGrid.classList.toggle( + 'app-grid--preview-collapsed-horizontal', + panelCollapseState.preview && previewAxis === 'horizontal', + ) + appGrid.classList.toggle('app-grid--preview-collapsed', panelCollapseState.preview) + appGrid.classList.toggle('app-grid--component-collapsed', panelCollapseState.component) + appGrid.classList.toggle('app-grid--styles-collapsed', panelCollapseState.styles) + appGrid.classList.toggle( + 'app-grid--component-collapsed-horizontal', + panelCollapseState.component && componentAxis === 'horizontal', + ) + appGrid.classList.toggle( + 'app-grid--styles-collapsed-horizontal', + panelCollapseState.styles && stylesAxis === 'horizontal', + ) + + syncPanelCollapseButtons() +} + +const togglePanelCollapse = panelName => { + if (!Object.hasOwn(panelCollapseState, panelName)) { + return + } + + panelCollapseState[panelName] = !panelCollapseState[panelName] + applyPanelCollapseState() +} + const diagnosticsUi = createDiagnosticsUiController({ diagnosticsToggle, diagnosticsDrawer, @@ -222,7 +426,6 @@ renderRuntime = createRenderRuntimeController({ renderMode, styleMode, shadowToggle, - styleWarning, getCssSource: () => getCssSource(), getJsxSource: () => getJsxSource(), getPreviewHost: () => previewHost, @@ -451,6 +654,7 @@ for (const button of appGridLayoutButtons) { return } applyAppGridLayout(nextLayout) + applyPanelCollapseState() }) } @@ -464,8 +668,43 @@ for (const button of appThemeButtons) { }) } +for (const button of editorToolsButtons) { + button.addEventListener('click', () => { + const panelName = button.dataset.editorToolsToggle + if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { + return + } + + panelToolsState[panelName] = !panelToolsState[panelName] + applyEditorToolsVisibility() + }) +} + +for (const button of panelCollapseButtons) { + button.addEventListener('click', () => { + const panelName = button.dataset.panelCollapse + if (!panelName) { + return + } + + togglePanelCollapse(panelName) + }) +} + +const handleCompactViewportChange = () => { + applyPanelCollapseState() +} + +if (typeof compactViewportMediaQuery.addEventListener === 'function') { + compactViewportMediaQuery.addEventListener('change', handleCompactViewportChange) +} else { + compactViewportMediaQuery.onchange = handleCompactViewportChange +} + applyAppGridLayout(getInitialAppGridLayout(), { persist: false }) applyTheme(getInitialTheme(), { persist: false }) +applyEditorToolsVisibility() +applyPanelCollapseState() updateRenderButtonVisibility() renderDiagnosticsScope('component') diff --git a/src/index.html b/src/index.html index 7172ab3..72f12fb 100644 --- a/src/index.html +++ b/src/index.html @@ -129,114 +129,187 @@