diff --git a/AGENTS.md b/AGENTS.md index 42cdb76..c6f5acd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,3 +102,4 @@ Never: - Edit generated output folders unless explicitly requested. - Modify node_modules or lockfiles unless explicitly requested. - Reintroduce cross-workspace overwrite/delete behavior with any changes. +- Use eslint disable comments. diff --git a/playwright/github-pr-drawer/active-context-sync.spec.ts b/playwright/github-pr-drawer/active-context-sync.spec.ts index 89c3c55..a856018 100644 --- a/playwright/github-pr-drawer/active-context-sync.spec.ts +++ b/playwright/github-pr-drawer/active-context-sync.spec.ts @@ -10,6 +10,7 @@ import { getWorkspaceTabsRecord, mockRepositoryBranches, openMostRecentStoredWorkspaceContext, + openStoredWorkspaceContextByHead, renameWorkspaceTab, seedActivePrWorkspaceContext, seedLocalWorkspaceContexts, @@ -1304,7 +1305,6 @@ test('Active PR context push commit uses Git Database API atomic path by default await connectByotWithSingleRepo(page) await openMostRecentStoredWorkspaceContext(page) await ensureOpenPrDrawerOpen(page) - await setComponentEditorSource(page, 'const commitMarker = 2') await setStylesEditorSource(page, '.commit-marker { color: blue; }') const pushCommitMessage = 'chore: push active context sync (atomic)' @@ -1563,8 +1563,11 @@ test('Open PR uses module tab paths when stale target file paths collide', async }, ]) - await connectByotWithSingleRepo(page) - await openMostRecentStoredWorkspaceContext(page) + await connectByotWithSingleRepo(page, { + autoOpenWorkspace: false, + assertPrRepositorySelected: false, + }) + await openStoredWorkspaceContextByHead(page, 'develop/open-pr-stale-target-paths') await ensureOpenPrDrawerOpen(page) const commitMessage = 'chore: open pr with stale module target path metadata' diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 4447f47..ef2772a 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -2,6 +2,7 @@ import { expect } from '@playwright/test' import type { Page } from '@playwright/test' const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev' +const pagesWithStubbedExternalFonts = new WeakSet() export const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html' @@ -71,7 +72,34 @@ const navigateToApp = async (page: Page, path: string) => { } } +const stubExternalFontRequests = async (page: Page) => { + if (pagesWithStubbedExternalFonts.has(page)) { + return + } + + pagesWithStubbedExternalFonts.add(page) + + await page.route('https://fonts.googleapis.com/**', async route => { + await route.fulfill({ + status: 200, + contentType: 'text/css; charset=utf-8', + body: '', + headers: { + 'cache-control': 'public, max-age=31536000, immutable', + }, + }) + }) + + await page.route('https://fonts.gstatic.com/**', async route => { + await route.fulfill({ + status: 204, + body: '', + }) + }) +} + export const waitForAppReady = async (page: Page, path = appEntryPath) => { + await stubExternalFontRequests(page) await navigateToApp(page, path) await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() await expect @@ -483,8 +511,12 @@ export const connectByotWithSingleRepo = async ( page: Page, { branchesByRepo, + autoOpenWorkspace = true, + assertPrRepositorySelected = true, }: { branchesByRepo?: BranchesByRepo + autoOpenWorkspace?: boolean + assertPrRepositorySelected?: boolean } = {}, ) => { await page.route('https://api.github.com/user/repos**', async route => { @@ -525,33 +557,37 @@ export const connectByotWithSingleRepo = async ( await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop') await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop') - const initializeButton = page.getByRole('button', { - name: 'Initialize', - exact: true, - }) + if (autoOpenWorkspace) { + const initializeButton = page.getByRole('button', { + name: 'Initialize', + exact: true, + }) - if (await initializeButton.isVisible()) { - await initializeButton.click() - } else { - const storedWorkspace = page.getByLabel('Stored workspace') - if (await storedWorkspace.isVisible()) { - const workspaceValue = await storedWorkspace - .locator('option:not([value=""])') - .first() - .getAttribute('value') - - if (workspaceValue) { - await storedWorkspace.selectOption(workspaceValue) - await page.getByRole('button', { name: 'Open', exact: true }).click() + if (await initializeButton.isVisible()) { + await initializeButton.click() + } else { + const storedWorkspace = page.getByLabel('Stored workspace') + if (await storedWorkspace.isVisible()) { + const workspaceValue = await storedWorkspace + .locator('option:not([value=""])') + .first() + .getAttribute('value') + + if (workspaceValue) { + await storedWorkspace.selectOption(workspaceValue) + await page.getByRole('button', { name: 'Open', exact: true }).click() + } } } } await ensureWorkspacesDrawerClosed(page) - const repoSelect = page.getByLabel('Pull request repository') - await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') - await expect(repoSelect).toBeDisabled() + if (assertPrRepositorySelected) { + const repoSelect = page.getByLabel('Pull request repository') + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(repoSelect).toBeDisabled() + } await expect( page.getByRole('button', { diff --git a/playwright/rendering-modes/core.spec.ts b/playwright/rendering-modes/core.spec.ts index 4a28a12..08b601a 100644 --- a/playwright/rendering-modes/core.spec.ts +++ b/playwright/rendering-modes/core.spec.ts @@ -128,6 +128,7 @@ const readLatestWorkspaceSnapshot = async (page: import('@playwright/test').Page return { renderMode: typeof latest.renderMode === 'string' ? latest.renderMode : '', + fontCssUrl: typeof latest.fontCssUrl === 'string' ? latest.fontCssUrl : '', styleLanguage: typeof primaryStylesTab?.language === 'string' ? primaryStylesTab.language : '', } @@ -147,6 +148,12 @@ const readPreviewUserStyleText = async (page: import('@playwright/test').Page) = }) } +const readPreviewBodyFontFamily = async (page: import('@playwright/test').Page) => { + return getPreviewFrame(page) + .locator('html') + .evaluate(() => getComputedStyle(document.body).fontFamily || '') +} + test.beforeEach(async ({ page }) => { await resetWorkbenchStorage(page) }) @@ -228,6 +235,46 @@ test('reactJsx tag interpolation renders memo and forwardRef components', async .toBe(0) }) +test('workspace font CSS URL applies to preview and persists per workspace', async ({ + page, +}) => { + await waitForInitialRender(page) + + await page.getByRole('button', { name: 'Workspaces' }).click() + + const fontCssUrlInput = page.getByRole('textbox', { + name: 'Workspace font stylesheet URL', + }) + await fontCssUrlInput.fill( + 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;700&display=swap', + ) + await page.getByRole('button', { name: 'Load', exact: true }).click() + + await expect + .poll(async () => (await readPreviewBodyFontFamily(page)).toLowerCase()) + .toContain('ibm plex sans') + + await expect + .poll(async () => { + const snapshot = await readLatestWorkspaceSnapshot(page) + return snapshot?.fontCssUrl ?? '' + }) + .toBe( + 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;700&display=swap', + ) + + await page.reload() + await waitForInitialRender(page) + await page.getByRole('button', { name: 'Workspaces' }).click() + + await expect(fontCssUrlInput).toHaveValue( + 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;700&display=swap', + ) + await expect + .poll(async () => (await readPreviewBodyFontFamily(page)).toLowerCase()) + .toContain('ibm plex sans') +}) + test('react mode keeps App.ts entry but surfaces rename guidance until compatible', async ({ page, }) => { diff --git a/src/app.js b/src/app.js index 5bdd8bb..990ffcc 100644 --- a/src/app.js +++ b/src/app.js @@ -66,6 +66,11 @@ import { createGitHubPrDrawer } from './modules/github/pr/drawer/controller/crea import { createLayoutThemeController } from './modules/ui/layout-theme.js' import { createLintDiagnosticsController } from './modules/diagnostics/lint-diagnostics.js' import { createPreviewBackgroundController } from './modules/preview/preview-background.js' +import { + createPreviewFontController, + defaultPreviewFontCssUrl, + normalizePreviewFontCssUrl, +} from './modules/preview/preview-font.js' import { getReactEntryTabCompatibilityError } from './modules/preview/preview-entry-resolver.js' import { createRenderRuntimeController } from './modules/preview/render-runtime.js' import { createTypeDiagnosticsController } from './modules/diagnostics/type-diagnostics.js' @@ -74,6 +79,8 @@ import { ensureJsxTransformSource } from './modules/preview/jsx-transform-runtim import { createEditorPoolManager } from './modules/editor/editor-pool-manager.js' import { createWorkspaceTabsState } from './modules/workspace/workspace-tabs-state.js' import { createWorkspacesDrawer } from './modules/workspace/workspaces-drawer/drawer.js' +import { createApplyWorkspaceFontCssUrl } from './modules/app-core/workspace-font-css-url-load.js' +import { createPreviewFontSetup } from './modules/app-core/preview-font-setup.js' import { createDebouncedWorkspaceSaver, createWorkspaceStorageAdapter, @@ -151,6 +158,8 @@ const workspacesInitialize = document.getElementById('workspaces-initialize') const workspacesShare = document.getElementById('workspaces-share') const workspacesNew = document.getElementById('workspaces-new') const workspacesSelect = document.getElementById('workspaces-select') +const workspacesFontCssUrlInput = document.getElementById('workspaces-font-css-url') +const workspacesFontCssUrlLoad = document.getElementById('workspaces-font-css-url-load') const workspacesOpen = document.getElementById('workspaces-open') const workspacesRename = document.getElementById('workspaces-rename') const workspacesRemove = document.getElementById('workspaces-remove') @@ -349,6 +358,14 @@ const previewBackground = createPreviewBackgroundController({ }, }) +const previewFont = createPreviewFontSetup({ + createPreviewFontController, + previewFontCssUrlInput: workspacesFontCssUrlInput, + defaultPreviewFontCssUrl, + getRenderRuntime: () => renderRuntime, + queueWorkspaceSave: () => queueWorkspaceSave?.(), +}) + const layoutTheme = createLayoutThemeController({ appThemeButtons, syncPreviewBackgroundPickerFromTheme: () => @@ -757,7 +774,9 @@ const workspaceSyncController = createWorkspaceSyncController({ getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, getRenderModeValue: () => renderMode.value, + getPreviewFontCssUrlValue: () => previewFont.getPreviewFontCssUrl(), normalizeRenderMode: mode => normalizeRenderMode(mode), + normalizePreviewFontCssUrl, }) const getTypecheckSourcePath = () => @@ -891,10 +910,15 @@ const { workspaceTabsState, resolveWorkspaceActiveTabId, normalizeRenderMode: mode => normalizeRenderMode(mode), + normalizePreviewFontCssUrl, getRenderModeValue: () => renderMode.value, + getPreviewFontCssUrlValue: () => previewFont.getPreviewFontCssUrl(), setRenderModeValue: value => { renderMode.value = value }, + setPreviewFontCssUrlValue: (value, options) => { + previewFont.applyPreviewFontCssUrl(value, options) + }, getActiveWorkspaceTab, onActiveWorkspaceTabChange: (_tab, { changed } = {}) => { syncDiagnosticsDrawerLayout() @@ -948,6 +972,13 @@ const { onWorkspaceRecordApplied: onWorkspaceRecordAppliedWithStatusMetadata, }) +const applyWorkspaceFontCssUrl = createApplyWorkspaceFontCssUrl({ + previewFont, + flushWorkspaceSave, + normalizePreviewFontCssUrl, + defaultPreviewFontCssUrl, +}) + const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = createWorkspaceScopeForkActions({ toNonEmptyWorkspaceText, @@ -1201,6 +1232,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspacesShare, workspacesNew, workspacesSelect, + workspacesFontCssUrlInput, + workspacesFontCssUrlLoad, workspacesOpen, workspacesRename, workspacesRemove, @@ -1220,6 +1253,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ listLocalContextRecords, refreshLocalContextOptions, applyWorkspaceRecord, + applyWorkspaceFontCssUrl, syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState, flushWorkspaceSave, @@ -1424,6 +1458,7 @@ const runtimeCoreOptions = createRuntimeCoreOptions({ getRenderRuntime: () => renderRuntime, getPreviewHost: () => previewHost, previewBackground, + previewFont, clearDiagnosticsScope, clearConfirmDialog, clearConfirmTitle, @@ -1602,6 +1637,7 @@ bindAppEventsAndStart({ typeDiagnostics, clipboardSupported, previewBackground, + previewFont, initializeCodeEditors, }, }) diff --git a/src/index.html b/src/index.html index 4c30df0..8e08ae1 100644 --- a/src/index.html +++ b/src/index.html @@ -806,6 +806,26 @@

Workspaces

+
+ + +
+