diff --git a/docs/next-steps.md b/docs/next-steps.md index 19d5106..07ebe3b 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -37,3 +37,27 @@ Focused follow-up work for `@knighted/develop`. - Do not add dependencies without explicit approval. - Remaining Phase 3 mini-spec (agent implementation prompt): - "Continue Issue #18 in @knighted/develop from the current baseline where PR filename/path groundwork and Open PR flow are already shipped. Implement the two remaining Phase 3 assistant deliverables. (1) Add mode-aware assistant guidance: when collecting AI context, include explicit policy hints derived from render mode and style mode, and ensure recommendations avoid incompatible patterns (for example, avoid React hook/state guidance in DOM mode unless user explicitly asks for React migration). (2) Add assistant-to-editor apply flow: support structured assistant responses that can propose edits for component and/or styles editors; render these as reviewable actions in the chat drawer, require explicit user confirmation to apply, and support a one-step undo for last applied assistant edit per editor. Keep all AI/BYOT behavior behind the existing browser-only AI feature flag and preserve current token/repo persistence semantics. Do not add dependencies. Validate with npm run lint and targeted Playwright tests covering mode-aware recommendation constraints and apply/undo editor actions." + +5. **Phase 2 UX/UI continuation: fixed editor tabs first pass (Component, Styles, App)** + - Continue the tabs/editor UX work with a constrained first implementation that supports exactly three editor tabs: Component, Styles, and App. + - Do not introduce arbitrary/custom tab names in this pass; treat custom naming as future scope after baseline tab behavior is stable. + - Preserve existing runtime behavior and editor content semantics while adding tab switching, active tab indication, and predictable persistence/reset behavior consistent with current app patterns. + - Ensure assistant/editor integration remains compatible with this model (edits should target one of the fixed tabs) without expanding to dynamic tab metadata yet. + - Suggested implementation prompt: + - "Implement Phase 2 UX/UI tab support in @knighted/develop with a fixed first-pass tab model: Component, Styles, and App only (no arbitrary tab names yet). Add a clear tab UI for switching editor panes, preserve existing editor behavior/content wiring, and keep render/lint/typecheck/diagnostics flows working with the selected tab context where relevant. Keep AI/BYOT feature-flag behavior unchanged, maintain CDN-first runtime constraints, and do not add dependencies. Add targeted Playwright coverage for tab switching, default/active tab behavior, and interactions with existing render/style-mode flows. Validate with npm run lint and targeted Playwright tests." + +6. **Document implicit App strict-flow behavior (auto render)** + - Add a short behavior matrix in docs that explains when implicit App wrapping is allowed versus when users must define `App` explicitly. + - Include concrete Component editor examples for each case so reviewer/user expectations are clear. + - Suggested example cases to document: + - Allowed implicit wrap (standalone top-level JSX, no imports/declarations), for example: + - `() as any` + - Requires explicit `App` (top-level JSX with declarations/imports), for example: + - `const label = 'Hello'` + - `const Button = () => ` + - `(` + - `const App = () => ', + 'const App = () => ', + ].join('\n') + + await setComponentEditorSource(page, componentSource) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('develop/repo/editor-sync-without-app') + await page.getByRole('button', { name: 'Open PR' }).last().click() + await page.getByRole('dialog').getByRole('button', { name: 'Open PR' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', + ) + + const componentUpserts = upsertRequests.filter(request => + request.path.endsWith('/App.jsx'), + ) + + expect(componentUpserts).toHaveLength(1) + + const strippedComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) + + expect(strippedComponentSource).toContain('const CounterButton = () =>') + expect(strippedComponentSource).not.toContain('const App = () =>') +}) + +test('Open PR drawer includes App wrapper in committed source when toggled on', async ({ + page, +}) => { + const upsertRequests: Array<{ path: string; body: Record }> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const path = + new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path' + + if (method === 'GET') { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + const body = request.postDataJSON() as Record + upsertRequests.push({ path: decodeURIComponent(path), body }) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ commit: { sha: 'commit-sha' } }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 101, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/101', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + + await setComponentEditorSource( + page, + [ + 'const CounterButton = () => ', + 'const App = () => ', + ].join('\n'), + ) + await ensureOpenPrDrawerOpen(page) + + const includeWrapperToggle = page.getByLabel( + 'Include App wrapper in committed component source', + ) + await includeWrapperToggle.check() + + await page.getByLabel('Head').fill('develop/repo/editor-sync-with-app') + await page.getByRole('button', { name: 'Open PR' }).last().click() + await page.getByRole('dialog').getByRole('button', { name: 'Open PR' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', + ) + + const componentUpserts = upsertRequests.filter(request => + request.path.endsWith('/App.jsx'), + ) + + expect(componentUpserts).toHaveLength(1) + + const fullComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) + expect(fullComponentSource).toContain('const CounterButton = () =>') + expect(fullComponentSource).toContain('const App = () =>') +}) diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts index 051b6f9..7e5f031 100644 --- a/playwright/rendering-modes.spec.ts +++ b/playwright/rendering-modes.spec.ts @@ -190,6 +190,244 @@ test('requires render button when auto render is disabled', async ({ page }) => await expect(page.locator('#preview-host pre')).toHaveCount(0) }) +test('clears preview when auto render is toggled', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const autoRenderToggle = page.getByLabel('Auto render') + + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toHaveCount(1) + + await autoRenderToggle.uncheck() + + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toHaveCount(0) + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + +test('shows App-only error when auto render is disabled and App is missing', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const autoRenderToggle = page.getByLabel('Auto render') + const renderButton = page.getByRole('button', { name: 'Render' }) + + await autoRenderToggle.uncheck() + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await renderButton.click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) +}) + +test('auto render implicitly wraps source with App in dom and react modes', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText('implicit app dom') + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText('implicit app react') +}) + +test('auto render implicit App includes multiple component declarations', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + [ + 'const OtherButton = () => ', + 'const Button = () => ', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toHaveCount(2) + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText(['bar', 'foo']) +}) + +test('auto render does not treat lowercase helpers as implicit components', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + [ + 'const helper = () => ', + 'function render() { return
}', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) +}) + +test('auto render wraps standalone JSX with trailing semicolon and comment', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + '() as any; // trailing', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText('implicit app from jsx expression') +}) + +test('auto render requires explicit App for declarations plus top-level JSX expression', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + [ + "const label = 'kept declarations'", + 'const Button = () => ', + '(', + ) + + await page.getByRole('button', { name: 'Render' }).click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText('default export arrow') +}) + +test('renders export default class component in react mode', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await page.getByLabel('Auto render').uncheck() + + await setComponentEditorSource( + page, + [ + "import React from 'react'", + 'export default class extends React.Component {', + ' render() {', + ' return ', + ' }', + '}', + ].join('\n'), + ) + + await page.getByRole('button', { name: 'Render' }).click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + const previewButtons = page + .getByRole('region', { name: 'Preview output' }) + .getByRole('button') + await expect(previewButtons.first()).toContainText('default export class') +}) + +test('supports export default App without redeclaration', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + await page.getByLabel('Auto render').uncheck() + + await setComponentEditorSource( + page, + [ + 'function App() {', + ' return ', + '}', + 'export default App', + ].join('\n'), + ) + + await page.getByRole('button', { name: 'Render' }).click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button').first(), + ).toContainText('export default App') +}) + test('persists layout and theme across reload', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/app.js b/src/app.js index 1fd0cae..5414456 100644 --- a/src/app.js +++ b/src/app.js @@ -16,6 +16,8 @@ import { createLintDiagnosticsController } from './modules/lint-diagnostics.js' import { createPreviewBackgroundController } from './modules/preview-background.js' import { createRenderRuntimeController } from './modules/render-runtime.js' import { createTypeDiagnosticsController } from './modules/type-diagnostics.js' +import { collectTopLevelDeclarations } from './modules/jsx-top-level-declarations.js' +import { ensureJsxTransformSource } from './modules/jsx-transform-runtime.js' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') @@ -48,6 +50,7 @@ const githubPrComponentPath = document.getElementById('github-pr-component-path' const githubPrStylesPath = document.getElementById('github-pr-styles-path') const githubPrTitle = document.getElementById('github-pr-title') const githubPrBody = document.getElementById('github-pr-body') +const githubPrIncludeAppWrapper = document.getElementById('github-pr-include-app-wrapper') const githubPrSubmit = document.getElementById('github-pr-submit') const viewControlsToggle = document.getElementById('view-controls-toggle') const viewControlsDrawer = document.getElementById('view-controls-drawer') @@ -622,6 +625,18 @@ const getCurrentWritableRepositories = () => const setCurrentSelectedRepository = fullName => byotControls.setSelectedRepository(fullName) +const getTopLevelDeclarations = async source => { + if (typeof source !== 'string' || !source.trim()) { + return [] + } + + const transformJsxSource = await ensureJsxTransformSource({ + cdnImports, + importFromCdnWithFallback, + }) + return collectTopLevelDeclarations({ source, transformJsxSource }) +} + chatDrawerController = createGitHubChatDrawer({ featureEnabled: aiAssistantFeatureEnabled, toggleButton: aiChatToggle, @@ -660,6 +675,7 @@ prDrawerController = createGitHubPrDrawer({ stylesPathInput: githubPrStylesPath, prTitleInput: githubPrTitle, prBodyInput: githubPrBody, + includeAppWrapperToggle: githubPrIncludeAppWrapper, submitButton: githubPrSubmit, statusNode: githubPrStatus, getToken: getCurrentGitHubToken, @@ -668,6 +684,7 @@ prDrawerController = createGitHubPrDrawer({ setSelectedRepository: setCurrentSelectedRepository, getComponentSource: () => getJsxSource(), getStylesSource: () => getCssSource(), + getTopLevelDeclarations, getDrawerSide: () => { const layout = getCurrentLayout() return layout === 'preview-left' ? 'left' : 'right' @@ -1030,6 +1047,7 @@ renderRuntime = createRenderRuntimeController({ renderMode, styleMode, shadowToggle, + isAutoRenderEnabled: () => autoRenderToggle.checked, getCssSource: () => getCssSource(), getJsxSource: () => getJsxSource(), getPreviewHost: () => previewHost, @@ -1214,6 +1232,7 @@ styleMode.addEventListener('change', () => { }) shadowToggle.addEventListener('change', maybeRender) autoRenderToggle.addEventListener('change', () => { + renderRuntime.clearPreview() updateRenderButtonVisibility() if (autoRenderToggle.checked) { renderPreview() diff --git a/src/index.html b/src/index.html index 89cd3ba..dc59bf3 100644 --- a/src/index.html +++ b/src/index.html @@ -785,6 +785,14 @@

Open Pull Request

/> + +