diff --git a/docs/build-and-deploy.md b/docs/build-and-deploy.md index 1a40d18..b35e162 100644 --- a/docs/build-and-deploy.md +++ b/docs/build-and-deploy.md @@ -3,7 +3,7 @@ This project uses two runtime modes: - Local development mode: dynamic CDN resolution from `src/cdn.js` with esm.sh as default. -- Production mode: JSPM-generated import map injected into `dist/index.html` and hosted on GitHub Pages. +- Production mode: CDN-first build artifacts in `dist`, with `build:esm` as the current preferred deploy build. ## Local Development @@ -51,8 +51,8 @@ npm run build:importmap-mode Mode notes: -- `importMap`: Preferred production mode when JSPM has indexed the required graph. -- `esm`: Stable fallback mode while waiting on JSPM indexing. +- `importMap`: Import-map mode when JSPM has indexed the required graph. +- `esm`: Current preferred deploy build mode. - `jspmGa`: Direct ga.jspm.io URL mode without import-map generation. This runs two steps: @@ -80,6 +80,14 @@ Preview the built site locally: npm run preview ``` +End-to-end tests run against a preview build by default: + +```sh +npm run test:e2e +``` + +This command builds with `build:esm` first, then runs Playwright against the preview server. + ## CI And Deployment - CI workflow (`.github/workflows/ci.yml`) installs dependencies, runs lint, and runs `npm run build`. @@ -91,7 +99,7 @@ Related docs: - `docs/code-mirror.md` for CodeMirror CDN integration rules, fallback behavior, and validation checklist. -- In production, the preferred/default mode is import-map-based resolution (`window.__KNIGHTED_PRIMARY_CDN__ = "importMap"`). +- In production, the current preferred deploy mode is ESM resolution (`window.__KNIGHTED_PRIMARY_CDN__ = "esm"`). - In `importMap` mode, runtime resolution is import-map first; if a specifier is missing from the generated map, runtime falls back through the CDN provider chain configured in `src/cdn.js`. - In `esm` and `jspmGa` modes, runtime resolution is handled entirely by the CDN provider chain configured in `src/cdn.js` without an import map. diff --git a/docs/next-steps.md b/docs/next-steps.md index 2a86ced..99677f4 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -15,15 +15,10 @@ Focused follow-up work for `@knighted/develop`. - Prefer CDN-delivered tooling where possible and preserve graceful fallback behavior when unavailable. 4. **In-browser component type checking** - - Explore TypeScript/JSX type checking for component source in-browser using CDN-delivered tooling. - - Keep diagnostics responsive and surface clear inline/editor feedback without blocking the preview loop. + - Add editor-linked diagnostics navigation so each issue can jump to the exact line/column in the component source. + - Surface line/column context directly in the diagnostics UI (not just message text) to speed up triage. + - Continue improving typecheck performance for first-run and large sources while keeping the preview loop non-blocking. 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. **App runtime modularization** - - Plan a refactor that splits `src/app.js` into scoped modules organized by functionality (for example: diagnostics, render pipeline, editor integration, UI controls, and persistence). - - Preserve `src/app.js` as the main runtime orchestration entrypoint while moving implementation details into focused modules. - - Split stylesheet concerns into focused files (for example: layout/shell, panel controls, diagnostics, editor overrides, dialogs/overlays) while keeping `src/styles.css` as the single entrypoint via ordered `@import` directives. - - Define clear module boundaries and shared interfaces so behavior stays stable while maintainability and readability improve. diff --git a/package-lock.json b/package-lock.json index 5e2ca99..2894b3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/develop", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/develop", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@playwright/test": "^1.58.2", @@ -17,6 +17,7 @@ "http-server": "^14.1.1", "husky": "^9.1.7", "jspm": "^4.4.0", + "lightningcss": "^1.32.0", "lint-staged": "^16.4.0", "oxlint": "^1.55.0", "prettier": "^3.8.1", @@ -1866,6 +1867,16 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2789,6 +2800,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lint-staged": { "version": "16.4.0", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", diff --git a/package.json b/package.json index 7ee4346..60f432d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/develop", - "version": "0.1.0", + "version": "0.2.0", "description": "Develop UI components directly in the browser using JSX and CSS.", "keywords": [ "ui", @@ -16,7 +16,6 @@ "license": "MIT", "author": "KCM ", "type": "module", - "main": "index.js", "engines": { "node": ">=22.22.1" }, @@ -25,16 +24,18 @@ "dev": "http-server . -a localhost -c-1 -o src/index.html", "prepare": "husky", "build:prepare": "node scripts/build-prepare.js", + "build:css": "node scripts/build-css.js", "build:importmap": "node scripts/build-importmap.js", - "build": "npm run build:prepare && npm run build:importmap", + "build": "npm run build:prepare && npm run build:css && npm run build:importmap", "build:esm": "KNIGHTED_PRIMARY_CDN=esm npm run build", "build:jspm": "KNIGHTED_PRIMARY_CDN=jspmGa npm run build", "build:importmap-mode": "KNIGHTED_PRIMARY_CDN=importMap npm run build", - "preview": "http-server dist -a localhost -c-1 -o index.html", + "preview": "http-server dist -a localhost -p 8081 -c-1 -o index.html", "check-types": "tsc -p tsconfig.json", "lint:playwright": "eslint playwright playwright.config.ts", - "test:e2e": "playwright test", - "test:e2e:headed": "playwright test --headed", + "test:e2e": "npm run build:esm && PLAYWRIGHT_WEB_SERVER_MODE=preview PLAYWRIGHT_PORT=8081 playwright test", + "test:e2e:dev": "playwright test", + "test:e2e:headed": "npm run build:esm && PLAYWRIGHT_WEB_SERVER_MODE=preview PLAYWRIGHT_PORT=8081 playwright test --headed", "test": "echo \"Error: no test specified\" && exit 1", "prettier": "prettier --write .", "lint": "oxlint src scripts && npm run lint:playwright" @@ -48,6 +49,7 @@ "http-server": "^14.1.1", "husky": "^9.1.7", "jspm": "^4.4.0", + "lightningcss": "^1.32.0", "lint-staged": "^16.4.0", "oxlint": "^1.55.0", "prettier": "^3.8.1", diff --git a/playwright.config.ts b/playwright.config.ts index be47e6b..d9eaade 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,12 +1,18 @@ import { defineConfig, devices } from '@playwright/test' -const env = (globalThis as { process?: { env?: Record } }) - .process?.env - +const env = process.env const isCI = env?.CI === 'true' const HOST = env?.PLAYWRIGHT_HOST ?? '127.0.0.1' const PORT = Number(env?.PLAYWRIGHT_PORT ?? 4174) const baseURL = env?.PLAYWRIGHT_BASE_URL ?? `http://${HOST}:${PORT}` +const webServerMode = env?.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev' +const usePreviewServer = webServerMode === 'preview' +const webServerCommand = usePreviewServer + ? `npx http-server dist -a ${HOST} -p ${PORT} -c-1` + : `npx http-server . -a ${HOST} -p ${PORT} -c-1` +const webServerReadyUrl = usePreviewServer + ? `${baseURL}/index.html` + : `${baseURL}/src/index.html` const projects = [ { name: 'chromium', @@ -36,8 +42,8 @@ export default defineConfig({ video: 'retain-on-failure', }, webServer: { - command: `npx http-server . -a ${HOST} -p ${PORT} -c-1`, - url: `${baseURL}/src/index.html`, + command: webServerCommand, + url: webServerReadyUrl, reuseExistingServer: !isCI, timeout: 120_000, }, diff --git a/playwright/app.spec.ts b/playwright/app.spec.ts index 49239b2..55a6a54 100644 --- a/playwright/app.spec.ts +++ b/playwright/app.spec.ts @@ -1,8 +1,11 @@ import { expect, test } from '@playwright/test' import type { Page } from '@playwright/test' +const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev' +const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html' + const waitForInitialRender = async (page: Page) => { - await page.goto('/src/index.html') + await page.goto(appEntryPath) await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() await expect(page.locator('#status')).toHaveText('Rendered') await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '') @@ -263,3 +266,61 @@ test('clearing styles keeps diagnostics error state but resets status styling', ) await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/) }) + +test('clear component diagnostics removes type errors and restores rendered status', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + ["const count: number = 'oops'", 'const App = () => '].join( + '\n', + ), + ) + + await page.getByRole('button', { name: 'Typecheck' }).click() + await expect(page.locator('#diagnostics-toggle')).toHaveClass( + /diagnostics-toggle--error/, + ) + await expect(page.locator('#status')).toHaveText(/Rendered \(Type errors: [1-9]\d*\)/) + + await page.locator('#diagnostics-toggle').click() + await page.locator('#diagnostics-clear-component').click() + + await expect(page.locator('#diagnostics-component')).toContainText( + 'No diagnostics yet.', + ) + await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') + await expect(page.locator('#diagnostics-toggle')).toHaveClass( + /diagnostics-toggle--neutral/, + ) + await expect(page.locator('#status')).toHaveText('Rendered') + await expect(page.locator('#status')).toHaveClass(/status--neutral/) +}) + +test('clear all diagnostics removes style compile diagnostics', async ({ page }) => { + await waitForInitialRender(page) + + await page.locator('#style-mode').selectOption('sass') + await setStylesEditorSource(page, '.card { color: $missing; }') + + await expect(page.locator('#diagnostics-toggle')).toHaveClass( + /diagnostics-toggle--error/, + ) + + await page.locator('#diagnostics-toggle').click() + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Style compilation failed.', + ) + + await page.locator('#diagnostics-clear-all').click() + await expect(page.locator('#diagnostics-component')).toContainText( + 'No diagnostics yet.', + ) + await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.') + await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') + await expect(page.locator('#diagnostics-toggle')).toHaveClass( + /diagnostics-toggle--neutral/, + ) +}) diff --git a/scripts/build-css.js b/scripts/build-css.js new file mode 100644 index 0000000..10026e5 --- /dev/null +++ b/scripts/build-css.js @@ -0,0 +1,17 @@ +import { access, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { bundle } from 'lightningcss' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const projectRoot = resolve(__dirname, '..') +const distStylesFile = resolve(projectRoot, 'dist', 'styles.css') + +await access(distStylesFile) + +const result = bundle({ + filename: distStylesFile, + minify: true, +}) + +await writeFile(distStylesFile, result.code) diff --git a/src/app.js b/src/app.js index 01151e7..0365ac5 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,11 @@ 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' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') @@ -40,43 +45,50 @@ let jsxCodeEditor = null let cssCodeEditor = null let getJsxSource = () => jsxEditor.value let getCssSource = () => cssEditor.value -let scheduled = null -let reactRoot = null -let reactRuntime = null -let sassCompiler = null -let lessCompiler = null -let lightningCssWasm = null -let coreRuntime = null -let typeScriptCompiler = null -let typeScriptCompilerProvider = null -let typeScriptLibFiles = null -let compiledStylesCache = { - key: null, - value: null, -} +let renderRuntime = null let pendingClearAction = null -let hasCompletedInitialRender = false -let previewBackgroundColor = null -let previewBackgroundCustomized = false -let typeCheckRunId = 0 -let lastTypeErrorCount = 0 -let hasUnresolvedTypeErrors = false -let scheduledTypeRecheck = null -let activeTypeDiagnosticsRuns = 0 -let diagnosticsDrawerOpen = false let suppressEditorChangeSideEffects = false -let statusLevel = 'neutral' const clipboardSupported = Boolean(navigator.clipboard?.writeText) -const appGridLayoutStorageKey = 'knighted-develop:app-grid-layout' -const appThemeStorageKey = 'knighted-develop:theme' -const defaultTypeScriptLibFileName = 'lib.esnext.full.d.ts' -const styleLabels = { - css: 'Native CSS', - module: 'CSS Modules', - less: 'Less', - sass: 'Sass', -} +const previewBackground = createPreviewBackgroundController({ + previewBgColorInput, + getPreviewHost: () => previewHost, +}) + +const layoutTheme = createLayoutThemeController({ + appGrid, + appGridLayoutButtons, + appThemeButtons, + syncPreviewBackgroundPickerFromTheme: () => + previewBackground.syncPreviewBackgroundPickerFromTheme(), +}) + +const { applyAppGridLayout, applyTheme, getInitialAppGridLayout, getInitialTheme } = + layoutTheme + +const diagnosticsUi = createDiagnosticsUiController({ + diagnosticsToggle, + diagnosticsDrawer, + diagnosticsComponent, + diagnosticsStyles, + statusNode, +}) + +const { + clearAllDiagnostics, + clearDiagnosticsScope, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, + getDiagnosticsDrawerOpen, + incrementTypeDiagnosticsRuns, + renderDiagnosticsScope, + setDiagnosticsDrawerOpen, + setStatus, + setStyleDiagnosticsDetails, + setTypeDiagnosticsDetails, + updateDiagnosticsToggleLabel, + updateUiIssueIndicators, +} = diagnosticsUi const getStyleEditorLanguage = mode => { if (mode === 'less') return 'less' @@ -137,226 +149,6 @@ const initializeCodeEditors = async () => { } } -const ensureCoreRuntime = async () => { - if (coreRuntime) return coreRuntime - - try { - const [cssBrowser, jsxDom, jsxTranspile] = await Promise.all([ - importFromCdnWithFallback(cdnImports.cssBrowser), - importFromCdnWithFallback(cdnImports.jsxDom), - importFromCdnWithFallback(cdnImports.jsxTranspile), - ]) - - if (typeof cssBrowser.module.cssFromSource !== 'function') { - throw new Error(`cssFromSource export was not found from ${cssBrowser.url}`) - } - - if (typeof jsxDom.module.jsx !== 'function') { - throw new Error(`jsx export was not found from ${jsxDom.url}`) - } - - if (typeof jsxTranspile.module.transpileJsxSource !== 'function') { - throw new Error(`transpileJsxSource export was not found from ${jsxTranspile.url}`) - } - - coreRuntime = { - cssFromSource: cssBrowser.module.cssFromSource, - jsx: jsxDom.module.jsx, - transpileJsxSource: jsxTranspile.module.transpileJsxSource, - } - - return coreRuntime - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unknown runtime module loading failure' - throw new Error(`Unable to load core runtime from CDN: ${message}`, { - cause: error, - }) - } -} - -const setStatus = (text, level) => { - statusNode.textContent = text - statusLevel = level ?? 'neutral' - updateUiIssueIndicators() -} - -const getDiagnosticsErrorCount = () => { - const componentErrors = - diagnosticsByScope.component.level === 'error' - ? diagnosticsByScope.component.lines.length - : 0 - const styleErrors = - diagnosticsByScope.styles.level === 'error' - ? diagnosticsByScope.styles.lines.length - : 0 - return componentErrors + styleErrors -} - -const getDiagnosticsIssueLevel = () => { - if (getDiagnosticsErrorCount() > 0) { - return 'error' - } - - if (activeTypeDiagnosticsRuns > 0) { - return 'pending' - } - - return 'neutral' -} - -const updateUiIssueIndicators = () => { - const diagnosticsLevel = getDiagnosticsIssueLevel() - - statusNode.classList.remove('status--neutral', 'status--pending', 'status--error') - statusNode.classList.add(`status--${statusLevel}`) - - if (diagnosticsToggle) { - diagnosticsToggle.classList.remove( - 'diagnostics-toggle--neutral', - 'diagnostics-toggle--pending', - 'diagnostics-toggle--error', - ) - diagnosticsToggle.classList.add(`diagnostics-toggle--${diagnosticsLevel}`) - } -} - -const diagnosticsByScope = { - component: { - headline: '', - lines: [], - level: 'muted', - }, - styles: { - headline: '', - lines: [], - level: 'muted', - }, -} - -const getDiagnosticsScopeNode = scope => { - if (scope === 'component') { - return diagnosticsComponent - } - - if (scope === 'styles') { - return diagnosticsStyles - } - - return null -} - -const renderDiagnosticsScope = scope => { - const root = getDiagnosticsScopeNode(scope) - const state = diagnosticsByScope[scope] - if (!root || !state) { - return - } - - root.classList.remove('panel-footer--muted', 'panel-footer--ok', 'panel-footer--error') - root.replaceChildren() - - const hasHeadline = typeof state.headline === 'string' && state.headline.length > 0 - const hasLines = Array.isArray(state.lines) && state.lines.length > 0 - - if (!hasHeadline && !hasLines) { - const emptyNode = document.createElement('div') - emptyNode.className = 'diagnostics-empty' - emptyNode.textContent = 'No diagnostics yet.' - root.append(emptyNode) - root.classList.add('panel-footer--muted') - return - } - - if (hasHeadline) { - const headingNode = document.createElement('div') - headingNode.className = 'type-diagnostics-heading' - headingNode.textContent = state.headline - root.append(headingNode) - } - - if (hasLines) { - const listNode = document.createElement('ol') - listNode.className = 'type-diagnostics-list' - for (const line of state.lines) { - const itemNode = document.createElement('li') - itemNode.textContent = line - listNode.append(itemNode) - } - root.append(listNode) - } - - if (state.level === 'ok') { - root.classList.add('panel-footer--ok') - return - } - - if (state.level === 'error') { - root.classList.add('panel-footer--error') - return - } - - root.classList.add('panel-footer--muted') -} - -const updateDiagnosticsToggleLabel = () => { - if (!diagnosticsToggle) { - return - } - - const totalErrors = getDiagnosticsErrorCount() - diagnosticsToggle.textContent = - totalErrors > 0 ? `Diagnostics (${totalErrors})` : 'Diagnostics' -} - -const setDiagnosticsDrawerOpen = isOpen => { - diagnosticsDrawerOpen = Boolean(isOpen) - - if (diagnosticsDrawer) { - diagnosticsDrawer.hidden = !diagnosticsDrawerOpen - } - - if (diagnosticsToggle) { - diagnosticsToggle.setAttribute( - 'aria-expanded', - diagnosticsDrawerOpen ? 'true' : 'false', - ) - } -} - -const setDiagnosticsScope = (scope, { headline = '', lines = [], level = 'muted' }) => { - if (!diagnosticsByScope[scope]) { - return - } - - diagnosticsByScope[scope] = { - headline, - lines, - level, - } - - renderDiagnosticsScope(scope) - updateDiagnosticsToggleLabel() - updateUiIssueIndicators() -} - -const clearDiagnosticsScope = scope => { - setDiagnosticsScope(scope, { headline: '', lines: [], level: 'muted' }) -} - -const clearAllDiagnostics = () => { - clearDiagnosticsScope('component') - clearDiagnosticsScope('styles') -} - -const setTypeDiagnosticsDetails = ({ headline, lines = [], level = 'muted' }) => { - setDiagnosticsScope('component', { headline, lines, level }) -} - -const setStyleDiagnosticsDetails = ({ headline, lines = [], level = 'muted' }) => { - setDiagnosticsScope('styles', { headline, lines, level }) -} - const setTypecheckButtonLoading = isLoading => { if (!typecheckButton) { return @@ -367,437 +159,81 @@ const setTypecheckButtonLoading = isLoading => { typecheckButton.disabled = isLoading } -const clearTypeRecheckTimer = () => { - if (!scheduledTypeRecheck) { - return - } - - clearTimeout(scheduledTypeRecheck) - scheduledTypeRecheck = null -} - -const scheduleTypeRecheck = () => { - clearTypeRecheckTimer() - - if (!hasUnresolvedTypeErrors) { - return - } - - scheduledTypeRecheck = setTimeout(() => { - scheduledTypeRecheck = null - typeCheckRunId += 1 - void runTypeDiagnostics(typeCheckRunId) - }, 450) +const setCdnLoading = isLoading => { + if (!cdnLoading) return + cdnLoading.hidden = !isLoading } const setRenderedStatus = () => { - if (lastTypeErrorCount > 0) { - setStatus(`Rendered (Type errors: ${lastTypeErrorCount})`, 'error') - return - } - - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } -} - -const flattenTypeDiagnosticMessage = (compiler, messageText) => { - if (typeof compiler.flattenDiagnosticMessageText === 'function') { - return compiler.flattenDiagnosticMessageText(messageText, '\n') - } - - if (typeof messageText === 'string') { - return messageText - } - - if (messageText && typeof messageText.messageText === 'string') { - return messageText.messageText - } - - return 'Unknown TypeScript diagnostic' -} - -const formatTypeDiagnostic = (compiler, diagnostic) => { - const message = flattenTypeDiagnosticMessage(compiler, diagnostic.messageText) - - if (!diagnostic.file || typeof diagnostic.start !== 'number') { - return `TS${diagnostic.code}: ${message}` - } - - const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) - return `L${position.line + 1}:${position.character + 1} TS${diagnostic.code}: ${message}` -} - -const ensureTypeScriptCompiler = async () => { - if (typeScriptCompiler) { - return typeScriptCompiler - } - - try { - const loaded = await importFromCdnWithFallback(cdnImports.typescript) - typeScriptCompiler = loaded.module.default ?? loaded.module - typeScriptCompilerProvider = loaded.provider ?? null - - if (typeof typeScriptCompiler.transpileModule !== 'function') { - throw new Error(`transpileModule export was not found from ${loaded.url}`) - } - - return typeScriptCompiler - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unknown TypeScript module loading failure' - throw new Error( - `Unable to load TypeScript diagnostics runtime from CDN: ${message}`, - { - cause: error, - }, + if (typeDiagnostics.getLastTypeErrorCount() > 0) { + setStatus( + `Rendered (Type errors: ${typeDiagnostics.getLastTypeErrorCount()})`, + 'error', ) - } -} - -const shouldIgnoreTypeDiagnostic = diagnostic => { - const ignoredCodes = new Set([2318, 6053]) - return ignoredCodes.has(diagnostic.code) -} - -const normalizeVirtualFileName = fileName => - typeof fileName === 'string' && fileName.startsWith('/') ? fileName.slice(1) : fileName - -const fetchTypeScriptLibText = async fileName => { - const urls = getTypeScriptLibUrls(fileName, { - typeScriptProvider: typeScriptCompilerProvider, - }) - - const attempts = urls.map(async url => { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`HTTP ${response.status} from ${url}`) - } - - return response.text() - }) - - try { - return await Promise.any(attempts) - } catch (error) { - let message = error instanceof Error ? error.message : String(error) - - if (error instanceof AggregateError) { - const reasons = Array.from(error.errors ?? []) - .slice(0, 3) - .map(reason => (reason instanceof Error ? reason.message : String(reason))) - const reasonSummary = reasons.length ? ` Causes: ${reasons.join(' | ')}` : '' - - message = `Tried URLs: ${urls.join(', ')}.${reasonSummary}` - } - - throw new Error(`Unable to fetch TypeScript lib file ${fileName}: ${message}`, { - cause: error, - }) - } -} - -const parseTypeScriptLibReferences = sourceText => { - const references = new Set() - const libReferencePattern = /\/\/\/\s*/g - const pathReferencePattern = /\/\/\/\s*/g - - for (const match of sourceText.matchAll(libReferencePattern)) { - const libName = match[1]?.trim() - if (libName) { - references.add(`lib.${libName}.d.ts`) - } - } - - for (const match of sourceText.matchAll(pathReferencePattern)) { - const pathName = match[1]?.trim() - if (pathName) { - references.add(pathName.replace(/^\.\//, '')) - } - } - - return [...references] -} - -const hydrateTypeScriptLibFiles = async (pendingFileNames, loaded) => { - const batch = [...new Set(pendingFileNames.map(normalizeVirtualFileName))].filter( - fileName => - typeof fileName === 'string' && fileName.length > 0 && !loaded.has(fileName), - ) - - if (batch.length === 0) { return } - const discoveredReferences = await Promise.all( - batch.map(async fileName => { - const sourceText = await fetchTypeScriptLibText(fileName) - loaded.set(fileName, sourceText) - return parseTypeScriptLibReferences(sourceText).map(normalizeVirtualFileName) - }), - ) - - await hydrateTypeScriptLibFiles(discoveredReferences.flat(), loaded) -} - -const ensureTypeScriptLibFiles = async () => { - if (typeScriptLibFiles) { - return typeScriptLibFiles - } - - const loaded = new Map() - await hydrateTypeScriptLibFiles([defaultTypeScriptLibFileName], loaded) - typeScriptLibFiles = loaded - return typeScriptLibFiles -} - -const collectTypeDiagnostics = async (compiler, sourceText) => { - const sourceFileName = 'component.tsx' - const jsxTypesFileName = 'knighted-jsx-runtime.d.ts' - const libFiles = await ensureTypeScriptLibFiles() - const jsxTypes = - 'declare namespace React {\n' + - ' type Key = string | number\n' + - ' interface Attributes { key?: Key | null }\n' + - '}\n' + - 'declare namespace JSX {\n' + - ' type Element = unknown\n' + - ' interface ElementChildrenAttribute { children: unknown }\n' + - ' interface IntrinsicAttributes extends React.Attributes {}\n' + - ' interface IntrinsicElements { [elemName: string]: Record }\n' + - '}\n' - - const files = new Map([ - [sourceFileName, sourceText], - [jsxTypesFileName, jsxTypes], - ...libFiles.entries(), - ]) - - const options = { - jsx: compiler.JsxEmit?.Preserve, - target: compiler.ScriptTarget?.ES2022, - module: compiler.ModuleKind?.ESNext, - strict: true, - noEmit: true, - skipLibCheck: true, - } - - const host = { - fileExists: fileName => files.has(normalizeVirtualFileName(fileName)), - readFile: fileName => files.get(normalizeVirtualFileName(fileName)), - getSourceFile: (fileName, languageVersion) => { - const normalizedFileName = normalizeVirtualFileName(fileName) - const text = files.get(normalizedFileName) - if (typeof text !== 'string') { - return undefined - } - - const scriptKind = normalizedFileName.endsWith('.tsx') - ? compiler.ScriptKind?.TSX - : normalizedFileName.endsWith('.d.ts') - ? compiler.ScriptKind?.TS - : compiler.ScriptKind?.TS - - return compiler.createSourceFile( - normalizedFileName, - text, - languageVersion, - true, - scriptKind, - ) - }, - getDefaultLibFileName: () => defaultTypeScriptLibFileName, - writeFile: () => {}, - getCurrentDirectory: () => '/', - getDirectories: () => [], - getCanonicalFileName: fileName => normalizeVirtualFileName(fileName), - useCaseSensitiveFileNames: () => true, - getNewLine: () => '\n', - } - - const program = compiler.createProgram({ - rootNames: [sourceFileName, jsxTypesFileName], - options, - host, - }) - - return compiler - .getPreEmitDiagnostics(program) - .filter(diagnostic => !shouldIgnoreTypeDiagnostic(diagnostic)) -} - -const runTypeDiagnostics = async runId => { - activeTypeDiagnosticsRuns += 1 - setTypecheckButtonLoading(true) - - setTypeDiagnosticsDetails({ - headline: 'Type checking…', - level: 'muted', - }) - - try { - const compiler = await ensureTypeScriptCompiler() - if (runId !== typeCheckRunId) { - return - } - - const diagnostics = await collectTypeDiagnostics(compiler, getJsxSource()) - const errorCategory = compiler.DiagnosticCategory?.Error - const errors = diagnostics.filter(diagnostic => diagnostic.category === errorCategory) - lastTypeErrorCount = errors.length - hasUnresolvedTypeErrors = errors.length > 0 - clearTypeRecheckTimer() - - if (errors.length === 0) { - setTypeDiagnosticsDetails({ - headline: 'No TypeScript errors found.', - level: 'ok', - }) - } else { - setTypeDiagnosticsDetails({ - headline: `TypeScript found ${errors.length} error${errors.length === 1 ? '' : 's'}:`, - lines: errors.map(diagnostic => formatTypeDiagnostic(compiler, diagnostic)), - level: 'error', - }) - } - - if ( - statusNode.textContent === 'Rendered' || - statusNode.textContent.startsWith('Rendered (Type errors:') - ) { - setRenderedStatus() - } - } catch (error) { - if (runId !== typeCheckRunId) { - return - } - - lastTypeErrorCount = 0 - hasUnresolvedTypeErrors = false - clearTypeRecheckTimer() - const message = error instanceof Error ? error.message : String(error) - setTypeDiagnosticsDetails({ - headline: `Type diagnostics unavailable: ${message}`, - level: 'error', - }) - - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } - } finally { - activeTypeDiagnosticsRuns = Math.max(0, activeTypeDiagnosticsRuns - 1) - setTypecheckButtonLoading(activeTypeDiagnosticsRuns > 0) - } -} - -const markTypeDiagnosticsStale = () => { - if (hasUnresolvedTypeErrors) { - setTypeDiagnosticsDetails({ - headline: 'Source changed. Re-checking type errors…', - level: 'muted', - }) - scheduleTypeRecheck() - return - } - - lastTypeErrorCount = 0 - setTypeDiagnosticsDetails({ - headline: 'Source changed. Click Typecheck to run diagnostics.', - level: 'muted', - }) - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { setStatus('Rendered', 'neutral') } } -const appGridLayouts = ['default', 'preview-right', 'preview-left'] - -const applyAppGridLayout = (layout, { persist = true } = {}) => { - if (!appGrid || !appGridLayouts.includes(layout)) { - return - } - - appGrid.classList.toggle('app-grid--preview-right', layout === 'preview-right') - appGrid.classList.toggle('app-grid--preview-left', layout === 'preview-left') - - for (const button of appGridLayoutButtons) { - const isActive = button.dataset.appGridLayout === layout - button.setAttribute('aria-pressed', isActive ? 'true' : 'false') - } - - if (persist) { - try { - localStorage.setItem(appGridLayoutStorageKey, layout) - } catch { - /* Ignore storage write errors in restricted browsing modes. */ - } - } -} - -const getInitialAppGridLayout = () => { - try { - const value = localStorage.getItem(appGridLayoutStorageKey) - if (appGridLayouts.includes(value)) { - return value - } - } catch { - /* Ignore storage read errors in restricted browsing modes. */ - } - - return 'default' -} - -const applyTheme = (theme, { persist = true } = {}) => { - if (!['dark', 'light'].includes(theme)) { - return - } - - document.documentElement.dataset.theme = theme - syncPreviewBackgroundPickerFromTheme() - - for (const button of appThemeButtons) { - const isActive = button.dataset.appTheme === theme - button.setAttribute('aria-pressed', isActive ? 'true' : 'false') - } - - if (persist) { - try { - localStorage.setItem(appThemeStorageKey, theme) - } catch { - /* Ignore storage write errors in restricted browsing modes. */ - } - } -} - -const getInitialTheme = () => { - try { - const value = localStorage.getItem(appThemeStorageKey) - if (value === 'dark' || value === 'light') { - return value - } - } catch { - /* Ignore storage read errors in restricted browsing modes. */ - } - - return 'dark' -} +const typeDiagnostics = createTypeDiagnosticsController({ + cdnImports, + importFromCdnWithFallback, + getTypeScriptLibUrls, + getJsxSource: () => getJsxSource(), + setTypecheckButtonLoading, + setTypeDiagnosticsDetails, + setStatus, + setRenderedStatus, + isRenderedStatus: () => + statusNode.textContent === 'Rendered' || + statusNode.textContent.startsWith('Rendered (Type errors:'), + isRenderedTypeErrorStatus: () => + statusNode.textContent.startsWith('Rendered (Type errors:'), + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, +}) -const setCdnLoading = isLoading => { - if (!cdnLoading) return - cdnLoading.hidden = !isLoading +const markTypeDiagnosticsStale = () => { + typeDiagnostics.markTypeDiagnosticsStale() } -const setStyleCompiling = isCompiling => { - previewHost.dataset.styleCompiling = isCompiling ? 'true' : 'false' +const renderPreview = async () => { + await renderRuntime.renderPreview() } -const debounceRender = () => { - if (scheduled) { - clearTimeout(scheduled) - } - scheduled = setTimeout(renderPreview, 200) -} +const maybeRender = () => { + if (autoRenderToggle.checked) { + renderRuntime.scheduleRender() + } +} + +renderRuntime = createRenderRuntimeController({ + cdnImports, + importFromCdnWithFallback, + renderMode, + styleMode, + shadowToggle, + styleWarning, + getCssSource: () => getCssSource(), + getJsxSource: () => getJsxSource(), + getPreviewHost: () => previewHost, + setPreviewHost: nextHost => { + previewHost = nextHost + }, + applyPreviewBackgroundColor: color => + previewBackground.applyPreviewBackgroundColor(color), + getPreviewBackgroundColor: () => previewBackground.getPreviewBackgroundColor(), + clearStyleDiagnostics: () => clearDiagnosticsScope('styles'), + setStyleDiagnosticsDetails, + setStatus, + setRenderedStatus, + onFirstRenderComplete: () => {}, + setCdnLoading, +}) const setJsxSource = value => { if (jsxCodeEditor) { @@ -826,9 +262,7 @@ const setCssSource = value => { const clearComponentSource = () => { setJsxSource('') clearDiagnosticsScope('component') - lastTypeErrorCount = 0 - hasUnresolvedTypeErrors = false - clearTypeRecheckTimer() + typeDiagnostics.clearTypeDiagnosticsState() setStatus('Component cleared', 'neutral') if (!jsxCodeEditor) { @@ -904,644 +338,8 @@ const copyStylesSource = async () => { } } -const toHexChannel = value => value.toString(16).padStart(2, '0') - -const normalizeColorToHex = colorValue => { - if (typeof colorValue !== 'string' || colorValue.length === 0) { - return '#12141c' - } - - if (/^#[\da-f]{6}$/i.test(colorValue)) { - return colorValue.toLowerCase() - } - - if (/^#[\da-f]{3}$/i.test(colorValue)) { - return colorValue - .slice(1) - .split('') - .map(channel => channel + channel) - .join('') - .replace(/^/, '#') - .toLowerCase() - } - - const channels = colorValue.match(/\d+/g) - if (!channels || channels.length < 3) { - return '#12141c' - } - - const [red, green, blue] = channels.slice(0, 3).map(value => Number.parseInt(value, 10)) - if ([red, green, blue].some(value => Number.isNaN(value))) { - return '#12141c' - } - - return `#${toHexChannel(red)}${toHexChannel(green)}${toHexChannel(blue)}` -} - -const applyPreviewBackgroundColor = color => { - if (!previewHost) { - return - } - - if (typeof color === 'string' && color.length > 0) { - previewHost.style.backgroundColor = color - return - } - - previewHost.style.removeProperty('background-color') -} - -const syncPreviewBackgroundPickerFromTheme = () => { - if (!previewBgColorInput || !previewHost || previewBackgroundCustomized) { - return - } - - previewBackgroundColor = null - applyPreviewBackgroundColor(null) - previewBgColorInput.value = normalizeColorToHex( - getComputedStyle(previewHost).backgroundColor, - ) -} - const initializePreviewBackgroundPicker = () => { - if (!previewBgColorInput || !previewHost) { - return - } - - const initialColor = normalizeColorToHex(getComputedStyle(previewHost).backgroundColor) - previewBackgroundColor = null - previewBackgroundCustomized = false - previewBgColorInput.value = initialColor - applyPreviewBackgroundColor(null) - - previewBgColorInput.addEventListener('input', () => { - previewBackgroundColor = previewBgColorInput.value - previewBackgroundCustomized = true - applyPreviewBackgroundColor(previewBackgroundColor) - }) -} - -const recreatePreviewHost = () => { - const nextHost = document.createElement('div') - nextHost.id = 'preview-host' - nextHost.className = previewHost.className - previewHost.replaceWith(nextHost) - previewHost = nextHost - - applyPreviewBackgroundColor(previewBackgroundColor) -} - -const getRenderTarget = () => { - if (!shadowToggle.checked && previewHost.shadowRoot) { - /* ShadowRoot cannot be detached, so recreate the host for light DOM mode. */ - if (reactRoot) { - reactRoot.unmount() - reactRoot = null - } - recreatePreviewHost() - } - - if (shadowToggle.checked) { - if (!previewHost.shadowRoot) { - previewHost.attachShadow({ mode: 'open' }) - } - return previewHost.shadowRoot - } - return previewHost -} - -const clearTarget = target => { - if (!target) return - if (reactRoot) { - reactRoot.unmount() - reactRoot = null - } - target.innerHTML = '' -} - -const updateStyleWarning = () => { - const mode = styleMode.value - if (mode === 'css') { - styleWarning.textContent = '' - return - } - if (mode === 'module') { - styleWarning.textContent = - 'CSS Modules are compiled in-browser and class names are remapped automatically.' - return - } - - styleWarning.textContent = `${styleLabels[mode]} is compiled in-browser via @knighted/css/browser.` -} - -const shadowPreviewBaseStyles = ` -:host { - all: initial; - display: var(--preview-host-display, block); - flex: var(--preview-host-flex, 1 1 auto); - min-height: var(--preview-host-min-height, 180px); - padding: var(--preview-host-padding, 18px); - overflow: var(--preview-host-overflow, auto); - position: var(--preview-host-position, relative); - background: var(--surface-preview); - color-scheme: var(--control-color-scheme, dark); - z-index: var(--preview-host-z-index, 1); - box-sizing: border-box; -} -` - -const applyStyles = (target, cssText) => { - if (!target) return - - const styleTag = document.createElement('style') - const isShadowTarget = target instanceof ShadowRoot - styleTag.textContent = isShadowTarget - ? `${shadowPreviewBaseStyles}\n${cssText}` - : `@scope (#preview-host) {\n${cssText}\n}` - target.append(styleTag) -} - -const normalizeCssModuleExport = value => { - if (Array.isArray(value)) { - return value.join(' ') - } - if (value && typeof value === 'object') { - const entry = value - const composed = Array.isArray(entry.composes) - ? entry.composes - : Array.isArray(entry.composes?.names) - ? entry.composes.names - : [] - - const names = [entry.name, ...composed.map(item => item?.name ?? item)].filter( - name => typeof name === 'string' && name.length > 0, - ) - - if (names.length > 0) { - return names.join(' ') - } - } - return typeof value === 'string' ? value : '' -} - -const escapeRegex = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - -const appendCssModuleLocalAliases = (cssText, moduleExports) => { - if (!cssText || !moduleExports) { - return cssText - } - - let output = cssText - - for (const [localClassName, exportedValue] of Object.entries(moduleExports)) { - if (typeof localClassName !== 'string' || !localClassName) { - continue - } - - const hashedTokens = normalizeCssModuleExport(exportedValue) - .split(/\s+/) - .filter(Boolean) - - for (const hashedClassName of hashedTokens) { - if (hashedClassName === localClassName) { - continue - } - const rx = new RegExp(`\\.${escapeRegex(hashedClassName)}(?![\\w-])`, 'g') - output = output.replace(rx, `.${hashedClassName}, .${localClassName}`) - } - } - - return output -} - -const remapClassTokens = (className, moduleExports) => { - if (!className || !moduleExports) return className - return className - .split(/\s+/) - .filter(Boolean) - .map(token => { - const mapped = normalizeCssModuleExport(moduleExports[token]) - return mapped || token - }) - .join(' ') -} - -const remapDomClassNames = (target, moduleExports) => { - if (!target || !moduleExports) return - const elements = [target, ...(target.querySelectorAll?.('*') ?? [])] - for (const node of elements) { - if (!(node instanceof Element)) continue - const className = node.getAttribute('class') - if (!className) continue - const remapped = remapClassTokens(className, moduleExports) - if (remapped !== className) { - node.setAttribute('class', remapped) - } - } -} - -const remapReactClassNames = (value, moduleExports, React) => { - if (!moduleExports || !React.isValidElement(value)) { - return value - } - - const nextProps = {} - let hasChanges = false - - if (typeof value.props.className === 'string') { - const remappedClassName = remapClassTokens(value.props.className, moduleExports) - if (remappedClassName !== value.props.className) { - nextProps.className = remappedClassName - hasChanges = true - } - } - - if (Object.prototype.hasOwnProperty.call(value.props, 'children')) { - const remappedChildren = React.Children.map(value.props.children, child => - remapReactClassNames(child, moduleExports, React), - ) - if (remappedChildren !== value.props.children) { - nextProps.children = remappedChildren - hasChanges = true - } - } - - if (!hasChanges) { - return value - } - - return React.cloneElement(value, nextProps) -} - -const shouldAttemptTranspileFallback = error => error instanceof SyntaxError - -const createUserModuleFactory = source => - new Function( - 'jsx', - 'reactJsx', - 'React', - `"use strict";\nlet __defaultExport;\n${source}\nconst __renderComponent = (Component, jsxTag) => {\n if (typeof Component !== 'function') return null;\n return jsxTag\`<\${Component} />\`;\n};\nconst __renderEntry = jsxTag => {\n if (typeof render === 'function') return render(jsxTag);\n if (typeof __defaultExport !== 'undefined') {\n return typeof __defaultExport === 'function'\n ? __renderComponent(__defaultExport, jsxTag)\n : __defaultExport;\n }\n const component = typeof App === 'function' ? App : typeof View === 'function' ? View : null;\n if (component) return __renderComponent(component, jsxTag);\n if (typeof View !== 'undefined') return View;\n if (typeof view !== 'undefined') return view;\n if (typeof output !== 'undefined') return output;\n return null;\n};\nreturn __renderEntry;`, - ) - -const isDomNode = value => typeof Node !== 'undefined' && value instanceof Node - -const isReactElementLike = value => - Boolean(value && typeof value === 'object' && '$$typeof' in value) - -const isSassCompiler = candidate => - Boolean( - candidate && - (typeof candidate.compileStringAsync === 'function' || - typeof candidate.compileString === 'function' || - typeof candidate.compile === 'function'), - ) - -const loadSassCompilerFrom = async (module, url) => { - const candidates = [module.default, module, module.Sass, module.default?.Sass].filter( - Boolean, - ) - - for (const candidate of candidates) { - if (isSassCompiler(candidate)) { - return candidate - } - } - - throw new Error(`No Sass compiler API found from ${url}`) -} - -const ensureSassCompiler = async () => { - if (sassCompiler) return sassCompiler - - try { - const loaded = await importFromCdnWithFallback(cdnImports.sass) - sassCompiler = await loadSassCompilerFrom(loaded.module, loaded.url) - return sassCompiler - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unknown Sass module loading failure' - throw new Error(`Unable to load Sass compiler for browser usage: ${message}`, { - cause: error, - }) - } -} - -const ensureLessCompiler = async () => { - if (lessCompiler) return lessCompiler - try { - const loaded = await importFromCdnWithFallback(cdnImports.less) - lessCompiler = loaded.module.default ?? loaded.module - return lessCompiler - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unknown Less module loading failure' - throw new Error(`Unable to load Less compiler for browser usage: ${message}`, { - cause: error, - }) - } -} - -const resolveLightningTransform = module => { - const candidates = [module, module.default].filter(Boolean) - - for (const candidate of candidates) { - if (candidate && typeof candidate.transform === 'function') { - return candidate.transform.bind(candidate) - } - } - - return null -} - -const tryLoadLightningCssWasm = async ({ module, url }) => { - const hasNamedInit = typeof module.init === 'function' - const hasNamedTransform = typeof module.transform === 'function' - - if (hasNamedInit) { - await module.init() - } else if (hasNamedTransform && typeof module.default === 'function') { - // @parcel/css-wasm exports default init + named transform. - await module.default() - } - - const transform = resolveLightningTransform(module) - if (!transform) { - throw new Error(`No transform() export available from ${url}`) - } - - return { transform } -} - -const ensureLightningCssWasm = async () => { - if (lightningCssWasm) return lightningCssWasm - - try { - const loaded = await importFromCdnWithFallback(cdnImports.lightningCssWasm) - lightningCssWasm = await tryLoadLightningCssWasm(loaded) - return lightningCssWasm - } catch (error) { - if (error instanceof Error) { - throw new Error(`Unable to load Lightning CSS WASM: ${error.message}`, { - cause: error, - }) - } - - throw new Error( - 'Unable to load Lightning CSS WASM: Unknown module loading failure.', - { - cause: error, - }, - ) - } -} - -const compileStyles = async () => { - const { cssFromSource } = await ensureCoreRuntime() - const dialect = styleMode.value - const cssSource = getCssSource() - const cacheKey = `${dialect}\u0000${cssSource}` - if (compiledStylesCache.key === cacheKey && compiledStylesCache.value) { - return compiledStylesCache.value - } - - const shouldShowSpinner = dialect !== 'css' - setStyleCompiling(shouldShowSpinner) - - if (!shouldShowSpinner) { - clearDiagnosticsScope('styles') - const output = { css: cssSource, moduleExports: null } - compiledStylesCache = { - key: cacheKey, - value: output, - } - return output - } - - try { - const options = { - dialect, - filename: - dialect === 'less' - ? 'playground.less' - : dialect === 'sass' - ? 'playground.scss' - : 'playground.module.css', - } - - if (dialect === 'sass') { - options.sass = await ensureSassCompiler() - } else if (dialect === 'less') { - options.less = await ensureLessCompiler() - } else if (dialect === 'module') { - options.lightningcss = await ensureLightningCssWasm() - } - - const result = await cssFromSource(cssSource, options) - if (!result.ok) { - throw new Error(result.error.message) - } - - const moduleExports = result.exports ?? null - const compiledCss = - dialect === 'module' - ? appendCssModuleLocalAliases(result.css, moduleExports) - : result.css - - const output = { - css: compiledCss, - moduleExports, - } - clearDiagnosticsScope('styles') - compiledStylesCache = { - key: cacheKey, - value: output, - } - return output - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const lines = message - .split('\n') - .map(line => line.trimEnd()) - .filter(line => line.trim().length > 0) - - setStyleDiagnosticsDetails({ - headline: 'Style compilation failed.', - lines, - level: 'error', - }) - throw error - } finally { - setStyleCompiling(false) - } -} - -const evaluateUserModule = async (helpers = {}) => { - const { jsx, transpileJsxSource } = await ensureCoreRuntime() - const userCode = getJsxSource() - .replace(/^\s*export\s+default\s+function\b/gm, '__defaultExport = function') - .replace(/^\s*export\s+default\s+class\b/gm, '__defaultExport = class') - .replace(/^\s*export\s+default\s+/gm, '__defaultExport = ') - .replace(/^\s*export\s+(?=function|const|let|var|class)/gm, '') - .replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '') - try { - const moduleFactory = createUserModuleFactory(userCode) - return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React) - } catch (error) { - if (!shouldAttemptTranspileFallback(error)) { - throw error - } - - const transpileMode = helpers.React && helpers.reactJsx ? 'react' : 'dom' - const transpileOptionsByMode = { - dom: { - sourceType: 'script', - createElement: 'jsx.createElement', - fragment: 'jsx.Fragment', - typescript: 'strip', - }, - react: { - sourceType: 'script', - createElement: 'React.createElement', - fragment: 'React.Fragment', - typescript: 'strip', - }, - } - const transpiledUserCode = transpileJsxSource( - userCode, - transpileOptionsByMode[transpileMode], - ).code - const moduleFactory = createUserModuleFactory(transpiledUserCode) - - if (helpers.React && helpers.reactJsx) { - return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React) - } - - if (transpileMode === 'dom') { - return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React) - } - - const { React, reactJsx } = await ensureReactRuntime() - return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx ?? reactJsx, React) - } -} - -const ensureReactRuntime = async () => { - if (reactRuntime) return reactRuntime - - try { - const [jsxReact, react, reactDomClient] = await Promise.all([ - importFromCdnWithFallback(cdnImports.jsxReact), - importFromCdnWithFallback(cdnImports.react), - importFromCdnWithFallback(cdnImports.reactDomClient), - ]) - - const reactJsx = jsxReact.module.reactJsx - const React = react.module.default ?? react.module - const createRoot = reactDomClient.module.createRoot - - if (typeof reactJsx !== 'function') { - throw new Error(`reactJsx export was not found from ${jsxReact.url}`) - } - if (!React || typeof React.isValidElement !== 'function') { - throw new Error(`React runtime export was not found from ${react.url}`) - } - if (typeof createRoot !== 'function') { - throw new Error(`createRoot export was not found from ${reactDomClient.url}`) - } - - reactRuntime = { reactJsx, React, createRoot } - return reactRuntime - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unknown React module loading failure' - throw new Error(`Unable to load React runtime from CDN: ${message}`, { - cause: error, - }) - } -} - -const renderDom = async () => { - const { jsx } = await ensureCoreRuntime() - const target = getRenderTarget() - clearTarget(target) - const compiledStyles = await compileStyles() - applyStyles(target, compiledStyles.css) - - const renderFn = await evaluateUserModule() - const output = renderFn ? renderFn(jsx) : null - if (isDomNode(output)) { - target.append(output) - remapDomClassNames(target, compiledStyles.moduleExports) - } else if (isReactElementLike(output)) { - const { createRoot, React } = await ensureReactRuntime() - const host = document.createElement('div') - target.append(host) - reactRoot = createRoot(host) - reactRoot.render(remapReactClassNames(output, compiledStyles.moduleExports, React)) - } else { - throw new Error('Expected a render() function or a component named App/View.') - } -} - -const renderReact = async () => { - const target = getRenderTarget() - clearTarget(target) - const compiledStyles = await compileStyles() - applyStyles(target, compiledStyles.css) - - const { reactJsx, createRoot, React } = await ensureReactRuntime() - const renderFn = await evaluateUserModule({ jsx: reactJsx, reactJsx, React }) - if (!renderFn) { - throw new Error('Expected a render() function or a component named App/View.') - } - - const host = document.createElement('div') - target.append(host) - reactRoot = createRoot(host) - const output = remapReactClassNames( - renderFn(reactJsx), - compiledStyles.moduleExports, - React, - ) - if (!output) { - throw new Error('Expected a render() function or a component named App/View.') - } - reactRoot.render(output) -} - -const renderPreview = async () => { - scheduled = null - updateStyleWarning() - setStatus(hasCompletedInitialRender ? 'Rendering…' : 'Loading CDN assets…', 'pending') - - try { - if (renderMode.value === 'react') { - await renderReact() - } else { - await renderDom() - } - setStatus('Rendered', 'neutral') - setRenderedStatus() - } catch (error) { - setStatus('Error', 'error') - const target = getRenderTarget() - clearTarget(target) - const message = document.createElement('pre') - message.textContent = error instanceof Error ? error.message : String(error) - message.style.color = '#ff9aa2' - target.append(message) - } finally { - if (!hasCompletedInitialRender) { - hasCompletedInitialRender = true - setCdnLoading(false) - } - } -} - -const maybeRender = () => { - if (autoRenderToggle.checked) { - debounceRender() - } + previewBackground.initializePreviewBackgroundPicker() } const updateRenderButtonVisibility = () => { @@ -1564,7 +362,7 @@ autoRenderToggle.addEventListener('change', () => { }) if (diagnosticsToggle) { diagnosticsToggle.addEventListener('click', () => { - setDiagnosticsDrawerOpen(!diagnosticsDrawerOpen) + setDiagnosticsDrawerOpen(!getDiagnosticsDrawerOpen()) }) } if (diagnosticsClose) { @@ -1575,9 +373,7 @@ if (diagnosticsClose) { if (diagnosticsClearComponent) { diagnosticsClearComponent.addEventListener('click', () => { clearDiagnosticsScope('component') - lastTypeErrorCount = 0 - hasUnresolvedTypeErrors = false - clearTypeRecheckTimer() + typeDiagnostics.clearTypeDiagnosticsState() if (statusNode.textContent.startsWith('Rendered (Type errors:')) { setStatus('Rendered', 'neutral') } @@ -1586,9 +382,7 @@ if (diagnosticsClearComponent) { if (diagnosticsClearAll) { diagnosticsClearAll.addEventListener('click', () => { clearAllDiagnostics() - lastTypeErrorCount = 0 - hasUnresolvedTypeErrors = false - clearTypeRecheckTimer() + typeDiagnostics.clearTypeDiagnosticsState() if (statusNode.textContent.startsWith('Rendered (Type errors:')) { setStatus('Rendered', 'neutral') } @@ -1596,8 +390,7 @@ if (diagnosticsClearAll) { } if (typecheckButton) { typecheckButton.addEventListener('click', () => { - typeCheckRunId += 1 - void runTypeDiagnostics(typeCheckRunId) + typeDiagnostics.triggerTypeDiagnostics() }) } renderButton.addEventListener('click', renderPreview) @@ -1668,7 +461,7 @@ updateDiagnosticsToggleLabel() updateUiIssueIndicators() setDiagnosticsDrawerOpen(false) setTypeDiagnosticsDetails({ headline: '' }) -setStyleCompiling(false) +renderRuntime.setStyleCompiling(false) setCdnLoading(true) initializePreviewBackgroundPicker() void initializeCodeEditors() diff --git a/src/diagnostics-ui.js b/src/diagnostics-ui.js new file mode 100644 index 0000000..5ad0885 --- /dev/null +++ b/src/diagnostics-ui.js @@ -0,0 +1,226 @@ +export const createDiagnosticsUiController = ({ + diagnosticsToggle, + diagnosticsDrawer, + diagnosticsComponent, + diagnosticsStyles, + statusNode, +}) => { + let statusLevel = 'neutral' + let activeTypeDiagnosticsRuns = 0 + let diagnosticsDrawerOpen = false + + const diagnosticsByScope = { + component: { + headline: '', + lines: [], + level: 'muted', + }, + styles: { + headline: '', + lines: [], + level: 'muted', + }, + } + + const getDiagnosticsScopeNode = scope => { + if (scope === 'component') { + return diagnosticsComponent + } + + if (scope === 'styles') { + return diagnosticsStyles + } + + return null + } + + const getDiagnosticsErrorCount = () => { + const componentErrors = + diagnosticsByScope.component.level === 'error' + ? diagnosticsByScope.component.lines.length + : 0 + const styleErrors = + diagnosticsByScope.styles.level === 'error' + ? diagnosticsByScope.styles.lines.length + : 0 + return componentErrors + styleErrors + } + + const getDiagnosticsIssueLevel = () => { + if (getDiagnosticsErrorCount() > 0) { + return 'error' + } + + if (activeTypeDiagnosticsRuns > 0) { + return 'pending' + } + + return 'neutral' + } + + const updateUiIssueIndicators = () => { + const diagnosticsLevel = getDiagnosticsIssueLevel() + + statusNode.classList.remove('status--neutral', 'status--pending', 'status--error') + statusNode.classList.add(`status--${statusLevel}`) + + if (diagnosticsToggle) { + diagnosticsToggle.classList.remove( + 'diagnostics-toggle--neutral', + 'diagnostics-toggle--pending', + 'diagnostics-toggle--error', + ) + diagnosticsToggle.classList.add(`diagnostics-toggle--${diagnosticsLevel}`) + } + } + + const setStatus = (text, level) => { + statusNode.textContent = text + statusLevel = level ?? 'neutral' + updateUiIssueIndicators() + } + + const renderDiagnosticsScope = scope => { + const root = getDiagnosticsScopeNode(scope) + const state = diagnosticsByScope[scope] + if (!root || !state) { + return + } + + root.classList.remove( + 'panel-footer--muted', + 'panel-footer--ok', + 'panel-footer--error', + ) + root.replaceChildren() + + const hasHeadline = typeof state.headline === 'string' && state.headline.length > 0 + const hasLines = Array.isArray(state.lines) && state.lines.length > 0 + + if (!hasHeadline && !hasLines) { + const emptyNode = document.createElement('div') + emptyNode.className = 'diagnostics-empty' + emptyNode.textContent = 'No diagnostics yet.' + root.append(emptyNode) + root.classList.add('panel-footer--muted') + return + } + + if (hasHeadline) { + const headingNode = document.createElement('div') + headingNode.className = 'type-diagnostics-heading' + headingNode.textContent = state.headline + root.append(headingNode) + } + + if (hasLines) { + const listNode = document.createElement('ol') + listNode.className = 'type-diagnostics-list' + for (const line of state.lines) { + const itemNode = document.createElement('li') + itemNode.textContent = line + listNode.append(itemNode) + } + root.append(listNode) + } + + if (state.level === 'ok') { + root.classList.add('panel-footer--ok') + return + } + + if (state.level === 'error') { + root.classList.add('panel-footer--error') + return + } + + root.classList.add('panel-footer--muted') + } + + const updateDiagnosticsToggleLabel = () => { + if (!diagnosticsToggle) { + return + } + + const totalErrors = getDiagnosticsErrorCount() + diagnosticsToggle.textContent = + totalErrors > 0 ? `Diagnostics (${totalErrors})` : 'Diagnostics' + } + + const setDiagnosticsDrawerOpen = isOpen => { + diagnosticsDrawerOpen = Boolean(isOpen) + + if (diagnosticsDrawer) { + diagnosticsDrawer.hidden = !diagnosticsDrawerOpen + } + + if (diagnosticsToggle) { + diagnosticsToggle.setAttribute( + 'aria-expanded', + diagnosticsDrawerOpen ? 'true' : 'false', + ) + } + } + + const setDiagnosticsScope = (scope, { headline = '', lines = [], level = 'muted' }) => { + if (!diagnosticsByScope[scope]) { + return + } + + diagnosticsByScope[scope] = { + headline, + lines, + level, + } + + renderDiagnosticsScope(scope) + updateDiagnosticsToggleLabel() + updateUiIssueIndicators() + } + + const clearDiagnosticsScope = scope => { + setDiagnosticsScope(scope, { headline: '', lines: [], level: 'muted' }) + } + + const clearAllDiagnostics = () => { + clearDiagnosticsScope('component') + clearDiagnosticsScope('styles') + } + + const setTypeDiagnosticsDetails = ({ headline, lines = [], level = 'muted' }) => { + setDiagnosticsScope('component', { headline, lines, level }) + } + + const setStyleDiagnosticsDetails = ({ headline, lines = [], level = 'muted' }) => { + setDiagnosticsScope('styles', { headline, lines, level }) + } + + const setActiveTypeDiagnosticsRuns = nextValue => { + activeTypeDiagnosticsRuns = Math.max(0, nextValue) + updateUiIssueIndicators() + } + + const incrementTypeDiagnosticsRuns = () => { + setActiveTypeDiagnosticsRuns(activeTypeDiagnosticsRuns + 1) + } + + const decrementTypeDiagnosticsRuns = () => { + setActiveTypeDiagnosticsRuns(activeTypeDiagnosticsRuns - 1) + } + + return { + clearAllDiagnostics, + clearDiagnosticsScope, + decrementTypeDiagnosticsRuns, + incrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns: () => activeTypeDiagnosticsRuns, + getDiagnosticsDrawerOpen: () => diagnosticsDrawerOpen, + renderDiagnosticsScope, + setDiagnosticsDrawerOpen, + setStatus, + setStyleDiagnosticsDetails, + setTypeDiagnosticsDetails, + updateDiagnosticsToggleLabel, + updateUiIssueIndicators, + } +} diff --git a/src/layout-theme.js b/src/layout-theme.js new file mode 100644 index 0000000..550b192 --- /dev/null +++ b/src/layout-theme.js @@ -0,0 +1,87 @@ +const appGridLayouts = ['default', 'preview-right', 'preview-left'] + +export const createLayoutThemeController = ({ + appGrid, + appGridLayoutButtons, + appThemeButtons, + syncPreviewBackgroundPickerFromTheme, + appGridLayoutStorageKey = 'knighted-develop:app-grid-layout', + appThemeStorageKey = 'knighted-develop:theme', +}) => { + const applyAppGridLayout = (layout, { persist = true } = {}) => { + if (!appGrid || !appGridLayouts.includes(layout)) { + return + } + + appGrid.classList.toggle('app-grid--preview-right', layout === 'preview-right') + appGrid.classList.toggle('app-grid--preview-left', layout === 'preview-left') + + for (const button of appGridLayoutButtons) { + const isActive = button.dataset.appGridLayout === layout + button.setAttribute('aria-pressed', isActive ? 'true' : 'false') + } + + if (persist) { + try { + localStorage.setItem(appGridLayoutStorageKey, layout) + } catch { + /* Ignore storage write errors in restricted browsing modes. */ + } + } + } + + const getInitialAppGridLayout = () => { + try { + const value = localStorage.getItem(appGridLayoutStorageKey) + if (appGridLayouts.includes(value)) { + return value + } + } catch { + /* Ignore storage read errors in restricted browsing modes. */ + } + + return 'default' + } + + const applyTheme = (theme, { persist = true } = {}) => { + if (!['dark', 'light'].includes(theme)) { + return + } + + document.documentElement.dataset.theme = theme + syncPreviewBackgroundPickerFromTheme() + + for (const button of appThemeButtons) { + const isActive = button.dataset.appTheme === theme + button.setAttribute('aria-pressed', isActive ? 'true' : 'false') + } + + if (persist) { + try { + localStorage.setItem(appThemeStorageKey, theme) + } catch { + /* Ignore storage write errors in restricted browsing modes. */ + } + } + } + + const getInitialTheme = () => { + try { + const value = localStorage.getItem(appThemeStorageKey) + if (value === 'dark' || value === 'light') { + return value + } + } catch { + /* Ignore storage read errors in restricted browsing modes. */ + } + + return 'dark' + } + + return { + applyAppGridLayout, + applyTheme, + getInitialAppGridLayout, + getInitialTheme, + } +} diff --git a/src/preview-background.js b/src/preview-background.js new file mode 100644 index 0000000..a4819c3 --- /dev/null +++ b/src/preview-background.js @@ -0,0 +1,96 @@ +const toHexChannel = value => value.toString(16).padStart(2, '0') + +const normalizeColorToHex = colorValue => { + if (typeof colorValue !== 'string' || colorValue.length === 0) { + return '#12141c' + } + + if (/^#[\da-f]{6}$/i.test(colorValue)) { + return colorValue.toLowerCase() + } + + if (/^#[\da-f]{3}$/i.test(colorValue)) { + return colorValue + .slice(1) + .split('') + .map(channel => channel + channel) + .join('') + .replace(/^/, '#') + .toLowerCase() + } + + const channels = colorValue.match(/\d+/g) + if (!channels || channels.length < 3) { + return '#12141c' + } + + const [red, green, blue] = channels.slice(0, 3).map(value => Number.parseInt(value, 10)) + if ([red, green, blue].some(value => Number.isNaN(value))) { + return '#12141c' + } + + return `#${toHexChannel(red)}${toHexChannel(green)}${toHexChannel(blue)}` +} + +export const createPreviewBackgroundController = ({ + previewBgColorInput, + getPreviewHost, +}) => { + let previewBackgroundColor = null + let previewBackgroundCustomized = false + + const applyPreviewBackgroundColor = color => { + const previewHost = getPreviewHost() + if (!previewHost) { + return + } + + if (typeof color === 'string' && color.length > 0) { + previewHost.style.backgroundColor = color + return + } + + previewHost.style.removeProperty('background-color') + } + + const syncPreviewBackgroundPickerFromTheme = () => { + const previewHost = getPreviewHost() + if (!previewBgColorInput || !previewHost || previewBackgroundCustomized) { + return + } + + previewBackgroundColor = null + applyPreviewBackgroundColor(null) + previewBgColorInput.value = normalizeColorToHex( + getComputedStyle(previewHost).backgroundColor, + ) + } + + const initializePreviewBackgroundPicker = () => { + const previewHost = getPreviewHost() + if (!previewBgColorInput || !previewHost) { + return + } + + const initialColor = normalizeColorToHex( + getComputedStyle(previewHost).backgroundColor, + ) + previewBackgroundColor = null + previewBackgroundCustomized = false + previewBgColorInput.value = initialColor + applyPreviewBackgroundColor(null) + + previewBgColorInput.addEventListener('input', () => { + previewBackgroundColor = previewBgColorInput.value + previewBackgroundCustomized = true + applyPreviewBackgroundColor(previewBackgroundColor) + }) + } + + return { + applyPreviewBackgroundColor, + getPreviewBackgroundColor: () => previewBackgroundColor, + initializePreviewBackgroundPicker, + syncPreviewBackgroundPickerFromTheme, + } +} diff --git a/src/render-runtime.js b/src/render-runtime.js new file mode 100644 index 0000000..338df99 --- /dev/null +++ b/src/render-runtime.js @@ -0,0 +1,667 @@ +export const createRenderRuntimeController = ({ + cdnImports, + importFromCdnWithFallback, + renderMode, + styleMode, + shadowToggle, + styleWarning, + getCssSource, + getJsxSource, + getPreviewHost, + setPreviewHost, + applyPreviewBackgroundColor, + getPreviewBackgroundColor, + clearStyleDiagnostics, + setStyleDiagnosticsDetails, + setStatus, + setRenderedStatus, + onFirstRenderComplete, + setCdnLoading, +}) => { + let scheduled = null + let reactRoot = null + let reactRuntime = null + let sassCompiler = null + let lessCompiler = null + let lightningCssWasm = null + let coreRuntime = null + let compiledStylesCache = { + key: null, + value: null, + } + let hasCompletedInitialRender = false + + const styleLabels = { + css: 'Native CSS', + module: 'CSS Modules', + less: 'Less', + sass: 'Sass', + } + + const setStyleCompiling = isCompiling => { + const previewHost = getPreviewHost() + if (!previewHost) { + return + } + + previewHost.dataset.styleCompiling = isCompiling ? 'true' : 'false' + } + + const ensureCoreRuntime = async () => { + if (coreRuntime) return coreRuntime + + try { + const [cssBrowser, jsxDom, jsxTranspile] = await Promise.all([ + importFromCdnWithFallback(cdnImports.cssBrowser), + importFromCdnWithFallback(cdnImports.jsxDom), + importFromCdnWithFallback(cdnImports.jsxTranspile), + ]) + + if (typeof cssBrowser.module.cssFromSource !== 'function') { + throw new Error(`cssFromSource export was not found from ${cssBrowser.url}`) + } + + if (typeof jsxDom.module.jsx !== 'function') { + throw new Error(`jsx export was not found from ${jsxDom.url}`) + } + + if (typeof jsxTranspile.module.transpileJsxSource !== 'function') { + throw new Error( + `transpileJsxSource export was not found from ${jsxTranspile.url}`, + ) + } + + coreRuntime = { + cssFromSource: cssBrowser.module.cssFromSource, + jsx: jsxDom.module.jsx, + transpileJsxSource: jsxTranspile.module.transpileJsxSource, + } + + return coreRuntime + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown runtime module loading failure' + throw new Error(`Unable to load core runtime from CDN: ${message}`, { + cause: error, + }) + } + } + + const recreatePreviewHost = () => { + const previewHost = getPreviewHost() + const nextHost = document.createElement('div') + nextHost.id = 'preview-host' + nextHost.className = previewHost.className + previewHost.replaceWith(nextHost) + setPreviewHost(nextHost) + + applyPreviewBackgroundColor(getPreviewBackgroundColor()) + } + + const getRenderTarget = () => { + const previewHost = getPreviewHost() + + if (!shadowToggle.checked && previewHost.shadowRoot) { + /* ShadowRoot cannot be detached, so recreate the host for light DOM mode. */ + if (reactRoot) { + reactRoot.unmount() + reactRoot = null + } + recreatePreviewHost() + } + + const currentHost = getPreviewHost() + if (shadowToggle.checked) { + if (!currentHost.shadowRoot) { + currentHost.attachShadow({ mode: 'open' }) + } + return currentHost.shadowRoot + } + + return currentHost + } + + const clearTarget = target => { + if (!target) return + if (reactRoot) { + reactRoot.unmount() + reactRoot = null + } + target.innerHTML = '' + } + + const updateStyleWarning = () => { + const mode = styleMode.value + if (mode === 'css') { + styleWarning.textContent = '' + return + } + if (mode === 'module') { + styleWarning.textContent = + 'CSS Modules are compiled in-browser and class names are remapped automatically.' + return + } + + styleWarning.textContent = `${styleLabels[mode]} is compiled in-browser via @knighted/css/browser.` + } + + const shadowPreviewBaseStyles = ` +:host { + all: initial; + display: var(--preview-host-display, block); + flex: var(--preview-host-flex, 1 1 auto); + min-height: var(--preview-host-min-height, 180px); + padding: var(--preview-host-padding, 18px); + overflow: var(--preview-host-overflow, auto); + position: var(--preview-host-position, relative); + background: var(--surface-preview); + color-scheme: var(--control-color-scheme, dark); + z-index: var(--preview-host-z-index, 1); + box-sizing: border-box; +} +` + + const applyStyles = (target, cssText) => { + if (!target) return + + const styleTag = document.createElement('style') + const isShadowTarget = target instanceof ShadowRoot + styleTag.textContent = isShadowTarget + ? `${shadowPreviewBaseStyles}\n${cssText}` + : `@scope (#preview-host) {\n${cssText}\n}` + target.append(styleTag) + } + + const normalizeCssModuleExport = value => { + if (Array.isArray(value)) { + return value.join(' ') + } + if (value && typeof value === 'object') { + const entry = value + const composed = Array.isArray(entry.composes) + ? entry.composes + : Array.isArray(entry.composes?.names) + ? entry.composes.names + : [] + + const names = [entry.name, ...composed.map(item => item?.name ?? item)].filter( + name => typeof name === 'string' && name.length > 0, + ) + + if (names.length > 0) { + return names.join(' ') + } + } + return typeof value === 'string' ? value : '' + } + + const escapeRegex = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + const appendCssModuleLocalAliases = (cssText, moduleExports) => { + if (!cssText || !moduleExports) { + return cssText + } + + let output = cssText + + for (const [localClassName, exportedValue] of Object.entries(moduleExports)) { + if (typeof localClassName !== 'string' || !localClassName) { + continue + } + + const hashedTokens = normalizeCssModuleExport(exportedValue) + .split(/\s+/) + .filter(Boolean) + + for (const hashedClassName of hashedTokens) { + if (hashedClassName === localClassName) { + continue + } + const rx = new RegExp(`\\.${escapeRegex(hashedClassName)}(?![\\w-])`, 'g') + output = output.replace(rx, `.${hashedClassName}, .${localClassName}`) + } + } + + return output + } + + const remapClassTokens = (className, moduleExports) => { + if (!className || !moduleExports) return className + return className + .split(/\s+/) + .filter(Boolean) + .map(token => { + const mapped = normalizeCssModuleExport(moduleExports[token]) + return mapped || token + }) + .join(' ') + } + + const remapDomClassNames = (target, moduleExports) => { + if (!target || !moduleExports) return + const elements = [target, ...(target.querySelectorAll?.('*') ?? [])] + for (const node of elements) { + if (!(node instanceof Element)) continue + const className = node.getAttribute('class') + if (!className) continue + const remapped = remapClassTokens(className, moduleExports) + if (remapped !== className) { + node.setAttribute('class', remapped) + } + } + } + + const remapReactClassNames = (value, moduleExports, React) => { + if (!moduleExports || !React.isValidElement(value)) { + return value + } + + const nextProps = {} + let hasChanges = false + + if (typeof value.props.className === 'string') { + const remappedClassName = remapClassTokens(value.props.className, moduleExports) + if (remappedClassName !== value.props.className) { + nextProps.className = remappedClassName + hasChanges = true + } + } + + if (Object.prototype.hasOwnProperty.call(value.props, 'children')) { + const remappedChildren = React.Children.map(value.props.children, child => + remapReactClassNames(child, moduleExports, React), + ) + if (remappedChildren !== value.props.children) { + nextProps.children = remappedChildren + hasChanges = true + } + } + + if (!hasChanges) { + return value + } + + return React.cloneElement(value, nextProps) + } + + const shouldAttemptTranspileFallback = error => error instanceof SyntaxError + + const createUserModuleFactory = source => + new Function( + 'jsx', + 'reactJsx', + 'React', + `"use strict";\nlet __defaultExport;\n${source}\nconst __renderComponent = (Component, jsxTag) => {\n if (typeof Component !== 'function') return null;\n return jsxTag\`<\${Component} />\`;\n};\nconst __renderEntry = jsxTag => {\n if (typeof render === 'function') return render(jsxTag);\n if (typeof __defaultExport !== 'undefined') {\n return typeof __defaultExport === 'function'\n ? __renderComponent(__defaultExport, jsxTag)\n : __defaultExport;\n }\n const component = typeof App === 'function' ? App : typeof View === 'function' ? View : null;\n if (component) return __renderComponent(component, jsxTag);\n if (typeof View !== 'undefined') return View;\n if (typeof view !== 'undefined') return view;\n if (typeof output !== 'undefined') return output;\n return null;\n};\nreturn __renderEntry;`, + ) + + const isDomNode = value => typeof Node !== 'undefined' && value instanceof Node + + const isReactElementLike = value => + Boolean(value && typeof value === 'object' && '$$typeof' in value) + + const isSassCompiler = candidate => + Boolean( + candidate && + (typeof candidate.compileStringAsync === 'function' || + typeof candidate.compileString === 'function' || + typeof candidate.compile === 'function'), + ) + + const loadSassCompilerFrom = async (module, url) => { + const candidates = [module.default, module, module.Sass, module.default?.Sass].filter( + Boolean, + ) + + for (const candidate of candidates) { + if (isSassCompiler(candidate)) { + return candidate + } + } + + throw new Error(`No Sass compiler API found from ${url}`) + } + + const ensureSassCompiler = async () => { + if (sassCompiler) return sassCompiler + + try { + const loaded = await importFromCdnWithFallback(cdnImports.sass) + sassCompiler = await loadSassCompilerFrom(loaded.module, loaded.url) + return sassCompiler + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown Sass module loading failure' + throw new Error(`Unable to load Sass compiler for browser usage: ${message}`, { + cause: error, + }) + } + } + + const ensureLessCompiler = async () => { + if (lessCompiler) return lessCompiler + try { + const loaded = await importFromCdnWithFallback(cdnImports.less) + lessCompiler = loaded.module.default ?? loaded.module + return lessCompiler + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown Less module loading failure' + throw new Error(`Unable to load Less compiler for browser usage: ${message}`, { + cause: error, + }) + } + } + + const resolveLightningTransform = module => { + const candidates = [module, module.default].filter(Boolean) + + for (const candidate of candidates) { + if (candidate && typeof candidate.transform === 'function') { + return candidate.transform.bind(candidate) + } + } + + return null + } + + const tryLoadLightningCssWasm = async ({ module, url }) => { + const hasNamedInit = typeof module.init === 'function' + const hasNamedTransform = typeof module.transform === 'function' + + if (hasNamedInit) { + await module.init() + } else if (hasNamedTransform && typeof module.default === 'function') { + // @parcel/css-wasm exports default init + named transform. + await module.default() + } + + const transform = resolveLightningTransform(module) + if (!transform) { + throw new Error(`No transform() export available from ${url}`) + } + + return { transform } + } + + const ensureLightningCssWasm = async () => { + if (lightningCssWasm) return lightningCssWasm + + try { + const loaded = await importFromCdnWithFallback(cdnImports.lightningCssWasm) + lightningCssWasm = await tryLoadLightningCssWasm(loaded) + return lightningCssWasm + } catch (error) { + if (error instanceof Error) { + throw new Error(`Unable to load Lightning CSS WASM: ${error.message}`, { + cause: error, + }) + } + + throw new Error( + 'Unable to load Lightning CSS WASM: Unknown module loading failure.', + { + cause: error, + }, + ) + } + } + + const compileStyles = async () => { + const { cssFromSource } = await ensureCoreRuntime() + const dialect = styleMode.value + const cssSource = getCssSource() + const cacheKey = `${dialect}\u0000${cssSource}` + if (compiledStylesCache.key === cacheKey && compiledStylesCache.value) { + return compiledStylesCache.value + } + + const shouldShowSpinner = dialect !== 'css' + setStyleCompiling(shouldShowSpinner) + + if (!shouldShowSpinner) { + clearStyleDiagnostics() + const output = { css: cssSource, moduleExports: null } + compiledStylesCache = { + key: cacheKey, + value: output, + } + return output + } + + try { + const options = { + dialect, + filename: + dialect === 'less' + ? 'playground.less' + : dialect === 'sass' + ? 'playground.scss' + : 'playground.module.css', + } + + if (dialect === 'sass') { + options.sass = await ensureSassCompiler() + } else if (dialect === 'less') { + options.less = await ensureLessCompiler() + } else if (dialect === 'module') { + options.lightningcss = await ensureLightningCssWasm() + } + + const result = await cssFromSource(cssSource, options) + if (!result.ok) { + throw new Error(result.error.message) + } + + const moduleExports = result.exports ?? null + const compiledCss = + dialect === 'module' + ? appendCssModuleLocalAliases(result.css, moduleExports) + : result.css + + const output = { + css: compiledCss, + moduleExports, + } + clearStyleDiagnostics() + compiledStylesCache = { + key: cacheKey, + value: output, + } + return output + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const lines = message + .split('\n') + .map(line => line.trimEnd()) + .filter(line => line.trim().length > 0) + + setStyleDiagnosticsDetails({ + headline: 'Style compilation failed.', + lines, + level: 'error', + }) + throw error + } finally { + setStyleCompiling(false) + } + } + + const evaluateUserModule = async (helpers = {}) => { + const { jsx, transpileJsxSource } = await ensureCoreRuntime() + const userCode = getJsxSource() + .replace(/^\s*export\s+default\s+function\b/gm, '__defaultExport = function') + .replace(/^\s*export\s+default\s+class\b/gm, '__defaultExport = class') + .replace(/^\s*export\s+default\s+/gm, '__defaultExport = ') + .replace(/^\s*export\s+(?=function|const|let|var|class)/gm, '') + .replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '') + try { + const moduleFactory = createUserModuleFactory(userCode) + return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React) + } catch (error) { + if (!shouldAttemptTranspileFallback(error)) { + throw error + } + + const transpileMode = helpers.React && helpers.reactJsx ? 'react' : 'dom' + const transpileOptionsByMode = { + dom: { + sourceType: 'script', + createElement: 'jsx.createElement', + fragment: 'jsx.Fragment', + typescript: 'strip', + }, + react: { + sourceType: 'script', + createElement: 'React.createElement', + fragment: 'React.Fragment', + typescript: 'strip', + }, + } + const transpiledUserCode = transpileJsxSource( + userCode, + transpileOptionsByMode[transpileMode], + ).code + const moduleFactory = createUserModuleFactory(transpiledUserCode) + + if (helpers.React && helpers.reactJsx) { + return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React) + } + + if (transpileMode === 'dom') { + return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React) + } + + const { React, reactJsx } = await ensureReactRuntime() + return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx ?? reactJsx, React) + } + } + + const ensureReactRuntime = async () => { + if (reactRuntime) return reactRuntime + + try { + const [jsxReact, react, reactDomClient] = await Promise.all([ + importFromCdnWithFallback(cdnImports.jsxReact), + importFromCdnWithFallback(cdnImports.react), + importFromCdnWithFallback(cdnImports.reactDomClient), + ]) + + const reactJsx = jsxReact.module.reactJsx + const React = react.module.default ?? react.module + const createRoot = reactDomClient.module.createRoot + + if (typeof reactJsx !== 'function') { + throw new Error(`reactJsx export was not found from ${jsxReact.url}`) + } + if (!React || typeof React.isValidElement !== 'function') { + throw new Error(`React runtime export was not found from ${react.url}`) + } + if (typeof createRoot !== 'function') { + throw new Error(`createRoot export was not found from ${reactDomClient.url}`) + } + + reactRuntime = { reactJsx, React, createRoot } + return reactRuntime + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown React module loading failure' + throw new Error(`Unable to load React runtime from CDN: ${message}`, { + cause: error, + }) + } + } + + const renderDom = async () => { + const { jsx } = await ensureCoreRuntime() + const target = getRenderTarget() + clearTarget(target) + const compiledStyles = await compileStyles() + applyStyles(target, compiledStyles.css) + + const renderFn = await evaluateUserModule() + const output = renderFn ? renderFn(jsx) : null + if (isDomNode(output)) { + target.append(output) + remapDomClassNames(target, compiledStyles.moduleExports) + } else if (isReactElementLike(output)) { + const { createRoot, React } = await ensureReactRuntime() + const host = document.createElement('div') + target.append(host) + reactRoot = createRoot(host) + reactRoot.render(remapReactClassNames(output, compiledStyles.moduleExports, React)) + } else { + throw new Error('Expected a render() function or a component named App/View.') + } + } + + const renderReact = async () => { + const target = getRenderTarget() + clearTarget(target) + const compiledStyles = await compileStyles() + applyStyles(target, compiledStyles.css) + + const { reactJsx, createRoot, React } = await ensureReactRuntime() + const renderFn = await evaluateUserModule({ jsx: reactJsx, reactJsx, React }) + if (!renderFn) { + throw new Error('Expected a render() function or a component named App/View.') + } + + const host = document.createElement('div') + target.append(host) + reactRoot = createRoot(host) + const output = remapReactClassNames( + renderFn(reactJsx), + compiledStyles.moduleExports, + React, + ) + if (!output) { + throw new Error('Expected a render() function or a component named App/View.') + } + reactRoot.render(output) + } + + const renderPreview = async () => { + scheduled = null + updateStyleWarning() + setStatus(hasCompletedInitialRender ? 'Rendering…' : 'Loading CDN assets…', 'pending') + + try { + if (renderMode.value === 'react') { + await renderReact() + } else { + await renderDom() + } + setStatus('Rendered', 'neutral') + setRenderedStatus() + } catch (error) { + setStatus('Error', 'error') + const target = getRenderTarget() + clearTarget(target) + const message = document.createElement('pre') + message.textContent = error instanceof Error ? error.message : String(error) + message.style.color = '#ff9aa2' + target.append(message) + } finally { + if (!hasCompletedInitialRender) { + hasCompletedInitialRender = true + onFirstRenderComplete() + setCdnLoading(false) + } + } + } + + const scheduleRender = () => { + if (scheduled) { + clearTimeout(scheduled) + } + + scheduled = setTimeout(renderPreview, 200) + } + + return { + renderPreview, + scheduleRender, + updateStyleWarning, + setStyleCompiling, + } +} diff --git a/src/styles.css b/src/styles.css index eb1b80d..2e03fee 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,1040 +1,6 @@ -:root { - color-scheme: dark; - --app-bg-start: #0f1115; - --app-bg-end: #12141b; - --shell-text: #e6e7eb; - --text-muted: #9aa3b2; - --text-subtle: #cdd4df; - --text-controls: #b9c1cf; - --panel-text: #e8ebf3; - --surface-app-header: rgba(16, 18, 24, 0.9); - --surface-panel: rgba(18, 20, 28, 0.9); - --surface-panel-header: rgba(15, 17, 23, 0.9); - --surface-chip: rgba(255, 255, 255, 0.08); - --surface-control: rgba(255, 255, 255, 0.06); - --surface-control-hover: rgba(255, 255, 255, 0.12); - --surface-select: rgba(255, 255, 255, 0.08); - --surface-select-option: #151a25; - --surface-preview: #12141c; - --surface-overlay: rgba(10, 12, 18, 0.72); - --surface-tooltip: rgba(9, 12, 20, 0.96); - --surface-dialog: rgba(13, 16, 24, 0.97); - --surface-loading: rgba(7, 10, 16, 0.82); - --surface-loading-card: rgba(13, 16, 24, 0.96); - --border-subtle: rgba(255, 255, 255, 0.08); - --border-control: rgba(255, 255, 255, 0.16); - --border-strong: rgba(255, 255, 255, 0.18); - --border-tooltip: rgba(255, 255, 255, 0.12); - --border-loading-card: rgba(255, 255, 255, 0.12); - --accent: #7a6bff; - --accent-rgb: 122, 107, 255; - --accent-soft: rgba(122, 107, 255, 0.2); - --accent-soft-hover: rgba(122, 107, 255, 0.35); - --accent-strong: rgba(122, 107, 255, 0.8); - --focus-ring: rgba(122, 107, 255, 0.85); - --danger-rgb: 250, 126, 138; - --danger-text: #ffe7ea; - --warning: #f6b56a; - --shadow-elev-1: rgba(0, 0, 0, 0.45); - --shadow-elev-2: rgba(0, 0, 0, 0.35); - --loading-spinner-border: rgba(255, 255, 255, 0.24); - --select-text: #f1f5ff; - --select-option-text: #eef3ff; - --select-option-disabled: #95a1b9; - --tooltip-text: #dfe6f7; - --dialog-text: #e7edf9; - --dialog-muted: #b8c3d9; - --loading-title: #edf1ff; - --loading-copy: #b7c1d4; - --icon-color: #d8e0ef; - --hint-icon: #dbe5ff; - --preview-spinner: #8f83ff; - --cm-keyword: #ff7fb3; - --cm-name: #e7ecf9; - --cm-property: #3fd6a6; - --cm-function: #8dc8ff; - --cm-constant: #7fd7ff; - --cm-definition: #dce4f6; - --cm-type: #8eb8ff; - --cm-number: #ffcb82; - --cm-operator: #d5def0; - --cm-string: #ffd38e; - --cm-comment: #94a2bb; - --cm-link: #88b6ff; - --cm-heading: #f2f5ff; - --cm-atom: #b8a8ff; - --cm-invalid: #ff8fa1; - --cm-text: #edf2ff; - --cm-caret: #f1f5ff; - --cm-gutter-bg: rgba(255, 255, 255, 0.045); - --cm-gutter-border: rgba(255, 255, 255, 0.13); - --cm-gutter-text: #98a8c4; - --cm-selection: rgba(122, 107, 255, 0.36); - --cm-active-line: rgba(255, 255, 255, 0.08); - --cm-focus-ring: rgba(122, 107, 255, 0.62); - --cm-tooltip-bg: #1b2233; - --cm-tooltip-text: #edf2ff; - --cm-tooltip-border: rgba(152, 168, 196, 0.32); - --cm-tooltip-item: #dce6fa; - --cm-tooltip-item-selected-bg: rgba(122, 107, 255, 0.34); - --cm-tooltip-item-selected-text: #f4f7ff; - --control-color-scheme: dark; - font-family: - 'Inter', - system-ui, - -apple-system, - sans-serif; - line-height: 1.4; - background: var(--app-bg-start); - color: var(--shell-text); -} - -:root[data-theme='light'] { - color-scheme: light; - --app-bg-start: #f3f6fc; - --app-bg-end: #e8eef8; - --shell-text: #1f2937; - --text-muted: #475569; - --text-subtle: #334155; - --text-controls: #4b5563; - --panel-text: #1f2937; - --surface-app-header: rgba(245, 248, 255, 0.92); - --surface-panel: rgba(255, 255, 255, 0.92); - --surface-panel-header: rgba(248, 251, 255, 0.95); - --surface-chip: rgba(15, 23, 42, 0.08); - --surface-control: rgba(15, 23, 42, 0.06); - --surface-control-hover: rgba(15, 23, 42, 0.12); - --surface-select: rgba(15, 23, 42, 0.06); - --surface-select-option: #ffffff; - --surface-preview: #f6f9ff; - --surface-overlay: rgba(240, 245, 255, 0.84); - --surface-tooltip: rgba(22, 34, 52, 0.96); - --surface-dialog: rgba(255, 255, 255, 0.98); - --surface-loading: rgba(234, 241, 252, 0.78); - --surface-loading-card: rgba(255, 255, 255, 0.97); - --border-subtle: rgba(15, 23, 42, 0.12); - --border-control: rgba(15, 23, 42, 0.22); - --border-strong: rgba(15, 23, 42, 0.24); - --border-tooltip: rgba(203, 213, 225, 0.7); - --border-loading-card: rgba(148, 163, 184, 0.42); - --accent: #5f57f0; - --accent-rgb: 95, 87, 240; - --accent-soft: rgba(95, 87, 240, 0.16); - --accent-soft-hover: rgba(95, 87, 240, 0.26); - --accent-strong: rgba(95, 87, 240, 0.72); - --focus-ring: rgba(95, 87, 240, 0.7); - --danger-rgb: 225, 66, 86; - --danger-text: #7f1d1d; - --warning: #b45309; - --shadow-elev-1: rgba(15, 23, 42, 0.2); - --shadow-elev-2: rgba(15, 23, 42, 0.16); - --loading-spinner-border: rgba(15, 23, 42, 0.24); - --select-text: #111827; - --select-option-text: #111827; - --select-option-disabled: #64748b; - --tooltip-text: #f8fafc; - --dialog-text: #1e293b; - --dialog-muted: #475569; - --loading-title: #0f172a; - --loading-copy: #334155; - --icon-color: #334155; - --hint-icon: #334155; - --preview-spinner: #5f57f0; - --cm-keyword: #b42364; - --cm-name: #0f172a; - --cm-property: #047857; - --cm-function: #1d4ed8; - --cm-constant: #0e7490; - --cm-definition: #1e293b; - --cm-type: #4338ca; - --cm-number: #b45309; - --cm-operator: #334155; - --cm-string: #92400e; - --cm-comment: #64748b; - --cm-link: #1d4ed8; - --cm-heading: #0f172a; - --cm-atom: #7c3aed; - --cm-invalid: #be123c; - --cm-text: #0f172a; - --cm-caret: #0f172a; - --cm-gutter-bg: rgba(15, 23, 42, 0.05); - --cm-gutter-border: rgba(15, 23, 42, 0.16); - --cm-gutter-text: #64748b; - --cm-selection: rgba(95, 87, 240, 0.2); - --cm-active-line: rgba(15, 23, 42, 0.08); - --cm-focus-ring: rgba(95, 87, 240, 0.5); - --cm-tooltip-bg: #f8fafc; - --cm-tooltip-text: #0f172a; - --cm-tooltip-border: rgba(15, 23, 42, 0.18); - --cm-tooltip-item: #1e293b; - --cm-tooltip-item-selected-bg: rgba(95, 87, 240, 0.2); - --cm-tooltip-item-selected-text: #0f172a; - --control-color-scheme: light; -} - -* { - box-sizing: border-box; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -body { - margin: 0; - padding: 0; - min-height: 100vh; - background: linear-gradient(180deg, var(--app-bg-start) 0%, var(--app-bg-end) 100%); - color: var(--shell-text); -} - -.app-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 28px; - border-bottom: 1px solid var(--border-subtle); - background: var(--surface-app-header); - position: sticky; - top: 0; - z-index: 10; - backdrop-filter: blur(8px); -} - -.app-header h1 { - margin: 0 0 6px; - font-size: 1.4rem; -} - -.brand-link { - color: inherit; - text-decoration: none; -} - -.brand-link:hover { - text-decoration: underline; -} - -.app-header p { - margin: 0; - color: var(--text-muted); - font-size: 0.95rem; -} - -.status { - font-size: 0.9rem; - padding: 8px 12px; - border-radius: 999px; - background: var(--surface-chip); - color: var(--text-subtle); -} - -.status--neutral { - background: var(--surface-chip); - color: var(--text-subtle); -} - -.status--pending { - background: color-mix(in srgb, var(--accent) 18%, transparent); - color: color-mix(in srgb, var(--panel-text) 72%, var(--accent)); -} - -.status--error { - background: color-mix(in srgb, rgb(var(--danger-rgb)) 20%, transparent); - color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); -} - -.app-grid { - display: grid; - grid-template-columns: repeat(2, minmax(320px, 1fr)); - grid-template-areas: - 'layout-controls layout-controls' - 'component styles' - 'preview preview'; - gap: 18px; - padding: 24px; -} - -.app-grid--preview-right { - grid-template-areas: - 'layout-controls layout-controls' - 'component preview' - 'styles preview'; -} - -.app-grid--preview-left { - grid-template-areas: - 'layout-controls layout-controls' - 'preview component' - 'preview styles'; -} - -.app-grid-layout-controls { - grid-area: layout-controls; - display: inline-flex; - align-items: center; - justify-self: end; - gap: 10px; -} - -.app-grid-theme-controls { - display: inline-flex; - gap: 10px; - margin-left: 8px; - padding-left: 10px; - border-left: 1px solid var(--border-subtle); -} - -.app-grid-diagnostics-controls { - display: inline-flex; - align-items: center; - margin-right: 8px; - padding-right: 10px; - border-right: 1px solid var(--border-subtle); -} - -.layout-toggle { - display: inline-grid; - place-content: center; - width: 36px; - height: 36px; - border: 1px solid var(--border-control); - border-radius: 10px; - background: var(--surface-control); - color: var(--icon-color); - cursor: pointer; -} - -.layout-toggle svg { - width: 18px; - height: 18px; - fill: none; - stroke: currentColor; - stroke-width: 1.5; -} - -.layout-toggle:hover { - background: var(--surface-control-hover); -} - -.layout-toggle[aria-pressed='true'] { - border-color: color-mix(in srgb, var(--accent) 65%, transparent); - background: var(--accent-soft); - color: var(--select-text); -} - -.layout-toggle:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; -} - -.component-panel { - grid-area: component; - max-height: min(64vh, 620px); - min-height: 0; -} - -.styles-panel { - grid-area: styles; - max-height: min(64vh, 620px); - min-height: 0; -} - -.preview-panel { - grid-area: preview; - position: relative; -} - -@media (max-width: 900px) { - .app-grid { - grid-template-columns: minmax(0, 1fr); - grid-template-areas: - 'layout-controls' - 'component' - 'styles' - 'preview'; - } - - .app-grid-layout-controls { - justify-self: start; - } - - .app-grid-theme-controls { - margin-left: 0; - padding-left: 0; - border-left: none; - } - - .app-grid-diagnostics-controls { - margin-right: 0; - padding-right: 0; - border-right: none; - } -} - -.panel { - background: var(--surface-panel); - border: 1px solid var(--border-subtle); - border-radius: 14px; - display: flex; - flex-direction: column; - min-height: 360px; - overflow: hidden; -} - -.panel.preview { - min-height: 280px; -} - -.panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 18px; - border-bottom: 1px solid var(--border-subtle); - background: var(--surface-panel-header); - position: relative; - z-index: 2; -} - -.panel-header h2 { - margin: 0; - font-size: 1rem; -} - -.panel-header--grid { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - grid-template-areas: - 'title quick' - 'actions actions'; - align-items: start; - column-gap: 12px; - row-gap: 10px; -} - -.panel-header--grid h2 { - grid-area: title; -} - -.panel-header-quick-actions { - grid-area: quick; - justify-self: center; -} - -.panel-header-main-actions { - grid-area: actions; - justify-self: start; -} - -.panel-header-main-actions .controls { - justify-content: flex-start; -} - -.controls { - display: flex; - gap: 12px; - align-items: center; - font-size: 0.85rem; - color: var(--text-controls); - flex-wrap: wrap; -} - -.controls--actions { - justify-content: flex-start; -} - -.controls--quick-actions { - gap: 10px; -} - -.controls label { - display: flex; - flex-direction: column; - gap: 6px; -} - -.controls select { - appearance: none; - -webkit-appearance: none; - background-color: var(--surface-select); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='currentColor' d='M1.2 0 5 3.8 8.8 0 10 1.2 5 6 0 1.2z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; - color: var(--select-text); - border: 1px solid var(--border-control); - border-radius: 8px; - padding: 6px 30px 6px 10px; - color-scheme: var(--control-color-scheme); -} - -.controls select:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 1px; -} - -.controls select option, -.controls select optgroup { - background: var(--surface-select-option); - color: var(--select-option-text); -} - -.controls select option:disabled { - color: var(--select-option-disabled); -} - -textarea { - flex: 1; - background: transparent; - color: var(--panel-text); - border: none; - padding: 16px 18px; - font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: 0.9rem; - resize: none; -} - -textarea:focus { - outline: none; -} - -.source-textarea--hidden { - display: none; -} - -.editor-host { - flex: 1; - min-height: 0; - overflow: hidden; -} - -.editor-host .cm-editor { - height: 100%; -} - -.editor-host .cm-scroller { - font-family: 'JetBrains Mono', 'Fira Code', monospace; - overflow: auto; -} - -@media (max-width: 900px) { - .component-panel, - .styles-panel { - max-height: none; - min-height: 360px; - } -} - -.panel-footer { - padding: 10px 18px 16px; - font-size: 0.85rem; - color: var(--warning); -} - -.panel-footer--muted { - color: var(--text-muted); -} - -.panel-footer--ok { - color: color-mix(in srgb, var(--panel-text) 70%, #4ade80); -} - -.panel-footer--error { - color: rgb(var(--danger-rgb)); -} - -.type-diagnostics-heading { - margin: 0; -} - -.type-diagnostics-list { - margin: 8px 0 0; - padding-left: 0; - list-style-position: inside; -} - -.type-diagnostics-list li { - margin: 0 0 4px; - padding-left: 2px; - font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: 0.8rem; - line-height: 1.4; -} - -.diagnostics-toggle { - border: 1px solid var(--border-control); - background: var(--surface-control); - color: var(--shell-text); - padding: 7px 14px; - border-radius: 999px; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; -} - -.diagnostics-toggle:hover { - background: var(--surface-control-hover); -} - -.diagnostics-toggle:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; -} - -.diagnostics-toggle--neutral { - border-color: var(--border-control); - background: var(--surface-control); - color: var(--shell-text); -} - -.diagnostics-toggle--pending { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border-control)); - background: color-mix(in srgb, var(--accent) 18%, transparent); - color: color-mix(in srgb, var(--panel-text) 72%, var(--accent)); -} - -.diagnostics-toggle--error { - border-color: color-mix(in srgb, rgb(var(--danger-rgb)) 60%, var(--border-control)); - background: color-mix(in srgb, rgb(var(--danger-rgb)) 18%, transparent); - color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); -} - -.diagnostics-drawer { - position: fixed; - top: 82px; - right: 18px; - width: min(480px, calc(100vw - 24px)); - max-height: min(72vh, 760px); - padding: 14px 14px 12px; - border: 1px solid var(--border-subtle); - border-radius: 14px; - background: color-mix(in srgb, var(--surface-panel) 92%, transparent); - box-shadow: 0 20px 40px var(--shadow-elev-1); - backdrop-filter: blur(8px); - overflow: auto; - z-index: 25; -} - -.diagnostics-drawer__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.diagnostics-drawer__header h2 { - margin: 0; - font-size: 1rem; -} - -.diagnostics-drawer__actions { - margin-top: 10px; - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.diagnostics-group { - margin-top: 14px; - padding-top: 12px; - border-top: 1px solid var(--border-subtle); -} - -.diagnostics-group h3 { - margin: 0 0 8px; - font-size: 0.86rem; - color: var(--text-muted); -} - -.diagnostics-scope { - max-height: 220px; - overflow: auto; - padding-right: 4px; - font-size: 0.84rem; -} - -.diagnostics-empty { - color: var(--text-muted); -} - -@media (max-width: 900px) { - .diagnostics-drawer { - top: auto; - right: 12px; - left: 12px; - bottom: 68px; - width: auto; - max-height: 58vh; - } -} - -.preview-host { - --preview-host-display: block; - --preview-host-flex: 1 1 auto; - --preview-host-min-height: 180px; - --preview-host-padding: 18px; - --preview-host-overflow: auto; - --preview-host-position: relative; - --preview-host-z-index: 1; - display: var(--preview-host-display); - flex: var(--preview-host-flex); - min-height: var(--preview-host-min-height); - padding: var(--preview-host-padding); - overflow: var(--preview-host-overflow); - position: var(--preview-host-position); - background: var(--surface-preview); - z-index: var(--preview-host-z-index); -} - -.preview-host[data-style-compiling='true']::before { - content: 'Compiling styles…'; - position: absolute; - inset: 0; - display: grid; - place-content: center; - padding-top: 34px; - background: var(--surface-overlay); - backdrop-filter: blur(2px); - color: var(--panel-text); - font-size: 0.88rem; - letter-spacing: 0.01em; - z-index: 2; -} - -.preview-host[data-style-compiling='true']::after { - content: ''; - position: absolute; - left: 50%; - top: calc(50% - 18px); - width: 20px; - height: 20px; - margin-left: -10px; - border-radius: 50%; - border: 2px solid var(--loading-spinner-border); - border-top-color: var(--preview-spinner); - animation: preview-spin 0.7s linear infinite; - z-index: 3; -} - -@keyframes preview-spin { - to { - transform: rotate(360deg); - } -} - -.controls label.toggle { - flex-direction: row; - align-items: center; - gap: 8px; - cursor: pointer; -} - -.controls label.color-control { - flex-direction: row; - align-items: center; - gap: 8px; -} - -.controls input[type='color'] { - width: 34px; - height: 24px; - padding: 0; - border: 1px solid var(--border-strong); - border-radius: 8px; - background: transparent; - cursor: pointer; -} - -.controls input[type='color']::-webkit-color-swatch-wrapper { - padding: 0; -} - -.controls input[type='color']::-webkit-color-swatch { - border: none; - border-radius: 6px; -} - -.toggle input { - accent-color: var(--accent); -} - -.hint-icon { - display: inline-grid; - place-content: center; - width: 16px; - height: 16px; - border-radius: 999px; - border: 1px solid var(--border-strong); - color: var(--hint-icon); - font-size: 0.68rem; - font-weight: 700; - line-height: 1; - opacity: 0.9; - background: transparent; -} - -.shadow-hint { - position: relative; - cursor: help; - border-width: 1px; - padding: 0; -} - -.shadow-hint::after { - opacity: 0; - visibility: hidden; - pointer-events: none; - transition: - opacity 120ms ease, - transform 120ms ease, - visibility 120ms ease; -} - -.shadow-hint::after { - content: attr(data-tooltip); - position: absolute; - top: calc(100% + 10px); - right: 0; - width: min(320px, calc(100vw - 36px)); - padding: 10px 12px; - border-radius: 10px; - border: 1px solid var(--border-tooltip); - background: var(--surface-tooltip); - color: var(--tooltip-text); - font-size: 0.78rem; - font-weight: 500; - line-height: 1.35; - text-align: left; - box-shadow: 0 12px 24px var(--shadow-elev-1); - transform: translateY(-4px); - z-index: 30; -} - -.shadow-hint:hover::after, -.shadow-hint:focus-visible::after { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.shadow-hint:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; -} - -.render-button { - border: 1px solid var(--border-subtle); - background: var(--accent-soft); - color: var(--select-text); - padding: 6px 14px; - border-radius: 999px; - cursor: pointer; - font-weight: 600; -} - -.render-button:hover { - background: var(--accent-soft-hover); -} - -.render-button--loading { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.render-button--loading::before { - content: ''; - width: 12px; - height: 12px; - border-radius: 999px; - border: 2px solid var(--loading-spinner-border); - border-top-color: var(--preview-spinner); - animation: preview-spin 0.7s linear infinite; -} - -.render-button:disabled { - opacity: 0.78; - cursor: wait; -} - -.icon-button { - border: 1px solid var(--border-control); - background: var(--surface-control); - color: var(--icon-color); - width: 32px; - height: 32px; - padding: 0; - border-radius: 999px; - cursor: pointer; - display: inline-grid; - place-content: center; -} - -.icon-button:hover { - background: var(--surface-control-hover); -} - -.icon-button:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 1px; -} - -.icon-button svg { - width: 16px; - height: 16px; - stroke: currentColor; - fill: none; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.confirm-dialog { - border: none; - padding: 0; - background: transparent; - color: inherit; -} - -.confirm-dialog:modal { - width: min(460px, calc(100vw - 32px)); - border: 1px solid var(--border-tooltip); - border-radius: 14px; - background: var(--surface-dialog); - color: var(--dialog-text); - box-shadow: 0 18px 42px var(--shadow-elev-1); -} - -.confirm-dialog::backdrop { - background: var(--surface-loading); - backdrop-filter: blur(2px); -} - -.confirm-dialog__form { - margin: 0; - padding: 18px; -} - -.confirm-dialog__form h3 { - margin: 0; - font-size: 1.04rem; -} - -.confirm-dialog__form p { - margin: 10px 0 0; - color: var(--dialog-muted); - font-size: 0.9rem; -} - -.confirm-dialog__actions { - margin: 18px 0 0; - padding: 0; - list-style: none; - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.confirm-dialog__button { - border-radius: 10px; - border: 1px solid transparent; - min-height: 34px; - padding: 6px 14px; - cursor: pointer; - font-weight: 600; -} - -.confirm-dialog__button--secondary { - border-color: var(--border-control); - background: var(--surface-control); - color: var(--select-option-text); -} - -.confirm-dialog__button--secondary:hover { - background: var(--surface-control-hover); -} - -.confirm-dialog__button--danger { - border-color: rgba(var(--danger-rgb), 0.48); - background: rgba(var(--danger-rgb), 0.2); - color: var(--danger-text); -} - -.confirm-dialog__button--danger:hover { - background: rgba(var(--danger-rgb), 0.32); -} - -.confirm-dialog__button:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 1px; -} - -@media (max-width: 900px) { - .panel-header-main-actions .controls, - .controls--actions { - justify-content: flex-start; - } -} - -.app-footer { - padding: 12px 24px 24px; - color: var(--text-muted); - font-size: 0.85rem; -} - -.cdn-loading { - position: fixed; - inset: 0; - z-index: 100; - display: grid; - place-items: center; - background: var(--surface-loading); - backdrop-filter: blur(6px); - transition: opacity 160ms ease; -} - -.cdn-loading[hidden] { - opacity: 0; - pointer-events: none; -} - -.cdn-loading-card { - width: min(560px, calc(100% - 40px)); - border-radius: 14px; - border: 1px solid var(--border-loading-card); - background: var(--surface-loading-card); - box-shadow: 0 20px 42px var(--shadow-elev-2); - padding: 22px 20px; - text-align: center; -} - -.cdn-loading-spinner { - width: 28px; - height: 28px; - margin: 0 auto 12px; - border-radius: 50%; - border: 2px solid var(--loading-spinner-border); - border-top-color: var(--preview-spinner); - animation: preview-spin 0.7s linear infinite; -} - -.cdn-loading-title { - margin: 0; - font-size: 1rem; - color: var(--loading-title); - font-weight: 700; -} - -.cdn-loading-copy { - margin: 8px 0 0; - font-size: 0.9rem; - color: var(--loading-copy); -} +@import './styles/base.css'; +@import './styles/layout-shell.css'; +@import './styles/panels-editor.css'; +@import './styles/diagnostics.css'; +@import './styles/preview-controls.css'; +@import './styles/dialogs-overlays.css'; diff --git a/src/styles/base.css b/src/styles/base.css new file mode 100644 index 0000000..6dac28d --- /dev/null +++ b/src/styles/base.css @@ -0,0 +1,197 @@ +:root { + color-scheme: dark; + --app-bg-start: #0f1115; + --app-bg-end: #12141b; + --shell-text: #e6e7eb; + --text-muted: #9aa3b2; + --text-subtle: #cdd4df; + --text-controls: #b9c1cf; + --panel-text: #e8ebf3; + --surface-app-header: rgba(16, 18, 24, 0.9); + --surface-panel: rgba(18, 20, 28, 0.9); + --surface-panel-header: rgba(15, 17, 23, 0.9); + --surface-chip: rgba(255, 255, 255, 0.08); + --surface-control: rgba(255, 255, 255, 0.06); + --surface-control-hover: rgba(255, 255, 255, 0.12); + --surface-select: rgba(255, 255, 255, 0.08); + --surface-select-option: #151a25; + --surface-preview: #12141c; + --surface-overlay: rgba(10, 12, 18, 0.72); + --surface-tooltip: rgba(9, 12, 20, 0.96); + --surface-dialog: rgba(13, 16, 24, 0.97); + --surface-loading: rgba(7, 10, 16, 0.82); + --surface-loading-card: rgba(13, 16, 24, 0.96); + --border-subtle: rgba(255, 255, 255, 0.08); + --border-control: rgba(255, 255, 255, 0.16); + --border-strong: rgba(255, 255, 255, 0.18); + --border-tooltip: rgba(255, 255, 255, 0.12); + --border-loading-card: rgba(255, 255, 255, 0.12); + --accent: #7a6bff; + --accent-rgb: 122, 107, 255; + --accent-soft: rgba(122, 107, 255, 0.2); + --accent-soft-hover: rgba(122, 107, 255, 0.35); + --accent-strong: rgba(122, 107, 255, 0.8); + --focus-ring: rgba(122, 107, 255, 0.85); + --danger-rgb: 250, 126, 138; + --danger-text: #ffe7ea; + --warning: #f6b56a; + --shadow-elev-1: rgba(0, 0, 0, 0.45); + --shadow-elev-2: rgba(0, 0, 0, 0.35); + --loading-spinner-border: rgba(255, 255, 255, 0.24); + --select-text: #f1f5ff; + --select-option-text: #eef3ff; + --select-option-disabled: #95a1b9; + --tooltip-text: #dfe6f7; + --dialog-text: #e7edf9; + --dialog-muted: #b8c3d9; + --loading-title: #edf1ff; + --loading-copy: #b7c1d4; + --icon-color: #d8e0ef; + --hint-icon: #dbe5ff; + --preview-spinner: #8f83ff; + --cm-keyword: #ff7fb3; + --cm-name: #e7ecf9; + --cm-property: #3fd6a6; + --cm-function: #8dc8ff; + --cm-constant: #7fd7ff; + --cm-definition: #dce4f6; + --cm-type: #8eb8ff; + --cm-number: #ffcb82; + --cm-operator: #d5def0; + --cm-string: #ffd38e; + --cm-comment: #94a2bb; + --cm-link: #88b6ff; + --cm-heading: #f2f5ff; + --cm-atom: #b8a8ff; + --cm-invalid: #ff8fa1; + --cm-text: #edf2ff; + --cm-caret: #f1f5ff; + --cm-gutter-bg: rgba(255, 255, 255, 0.045); + --cm-gutter-border: rgba(255, 255, 255, 0.13); + --cm-gutter-text: #98a8c4; + --cm-selection: rgba(122, 107, 255, 0.36); + --cm-active-line: rgba(255, 255, 255, 0.08); + --cm-focus-ring: rgba(122, 107, 255, 0.62); + --cm-tooltip-bg: #1b2233; + --cm-tooltip-text: #edf2ff; + --cm-tooltip-border: rgba(152, 168, 196, 0.32); + --cm-tooltip-item: #dce6fa; + --cm-tooltip-item-selected-bg: rgba(122, 107, 255, 0.34); + --cm-tooltip-item-selected-text: #f4f7ff; + --control-color-scheme: dark; + font-family: + 'Inter', + system-ui, + -apple-system, + sans-serif; + line-height: 1.4; + background: var(--app-bg-start); + color: var(--shell-text); +} + +:root[data-theme='light'] { + color-scheme: light; + --app-bg-start: #f3f6fc; + --app-bg-end: #e8eef8; + --shell-text: #1f2937; + --text-muted: #475569; + --text-subtle: #334155; + --text-controls: #4b5563; + --panel-text: #1f2937; + --surface-app-header: rgba(245, 248, 255, 0.92); + --surface-panel: rgba(255, 255, 255, 0.92); + --surface-panel-header: rgba(248, 251, 255, 0.95); + --surface-chip: rgba(15, 23, 42, 0.08); + --surface-control: rgba(15, 23, 42, 0.06); + --surface-control-hover: rgba(15, 23, 42, 0.12); + --surface-select: rgba(15, 23, 42, 0.06); + --surface-select-option: #ffffff; + --surface-preview: #f6f9ff; + --surface-overlay: rgba(240, 245, 255, 0.84); + --surface-tooltip: rgba(22, 34, 52, 0.96); + --surface-dialog: rgba(255, 255, 255, 0.98); + --surface-loading: rgba(234, 241, 252, 0.78); + --surface-loading-card: rgba(255, 255, 255, 0.97); + --border-subtle: rgba(15, 23, 42, 0.12); + --border-control: rgba(15, 23, 42, 0.22); + --border-strong: rgba(15, 23, 42, 0.24); + --border-tooltip: rgba(203, 213, 225, 0.7); + --border-loading-card: rgba(148, 163, 184, 0.42); + --accent: #5f57f0; + --accent-rgb: 95, 87, 240; + --accent-soft: rgba(95, 87, 240, 0.16); + --accent-soft-hover: rgba(95, 87, 240, 0.26); + --accent-strong: rgba(95, 87, 240, 0.72); + --focus-ring: rgba(95, 87, 240, 0.7); + --danger-rgb: 225, 66, 86; + --danger-text: #7f1d1d; + --warning: #b45309; + --shadow-elev-1: rgba(15, 23, 42, 0.2); + --shadow-elev-2: rgba(15, 23, 42, 0.16); + --loading-spinner-border: rgba(15, 23, 42, 0.24); + --select-text: #111827; + --select-option-text: #111827; + --select-option-disabled: #64748b; + --tooltip-text: #f8fafc; + --dialog-text: #1e293b; + --dialog-muted: #475569; + --loading-title: #0f172a; + --loading-copy: #334155; + --icon-color: #334155; + --hint-icon: #334155; + --preview-spinner: #5f57f0; + --cm-keyword: #b42364; + --cm-name: #0f172a; + --cm-property: #047857; + --cm-function: #1d4ed8; + --cm-constant: #0e7490; + --cm-definition: #1e293b; + --cm-type: #4338ca; + --cm-number: #b45309; + --cm-operator: #334155; + --cm-string: #92400e; + --cm-comment: #64748b; + --cm-link: #1d4ed8; + --cm-heading: #0f172a; + --cm-atom: #7c3aed; + --cm-invalid: #be123c; + --cm-text: #0f172a; + --cm-caret: #0f172a; + --cm-gutter-bg: rgba(15, 23, 42, 0.05); + --cm-gutter-border: rgba(15, 23, 42, 0.16); + --cm-gutter-text: #64748b; + --cm-selection: rgba(95, 87, 240, 0.2); + --cm-active-line: rgba(15, 23, 42, 0.08); + --cm-focus-ring: rgba(95, 87, 240, 0.5); + --cm-tooltip-bg: #f8fafc; + --cm-tooltip-text: #0f172a; + --cm-tooltip-border: rgba(15, 23, 42, 0.18); + --cm-tooltip-item: #1e293b; + --cm-tooltip-item-selected-bg: rgba(95, 87, 240, 0.2); + --cm-tooltip-item-selected-text: #0f172a; + --control-color-scheme: light; +} + +* { + box-sizing: border-box; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +body { + margin: 0; + padding: 0; + min-height: 100vh; + background: linear-gradient(180deg, var(--app-bg-start) 0%, var(--app-bg-end) 100%); + color: var(--shell-text); +} diff --git a/src/styles/diagnostics.css b/src/styles/diagnostics.css new file mode 100644 index 0000000..35aaf37 --- /dev/null +++ b/src/styles/diagnostics.css @@ -0,0 +1,142 @@ +.panel-footer { + padding: 10px 18px 16px; + font-size: 0.85rem; + color: var(--warning); +} + +.panel-footer--muted { + color: var(--text-muted); +} + +.panel-footer--ok { + color: color-mix(in srgb, var(--panel-text) 70%, #4ade80); +} + +.panel-footer--error { + color: rgb(var(--danger-rgb)); +} + +.type-diagnostics-heading { + margin: 0; +} + +.type-diagnostics-list { + margin: 8px 0 0; + padding-left: 0; + list-style-position: inside; +} + +.type-diagnostics-list li { + margin: 0 0 4px; + padding-left: 2px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.8rem; + line-height: 1.4; +} + +.diagnostics-toggle { + border: 1px solid var(--border-control); + background: var(--surface-control); + color: var(--shell-text); + padding: 7px 14px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.diagnostics-toggle:hover { + background: var(--surface-control-hover); +} + +.diagnostics-toggle:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.diagnostics-toggle--neutral { + border-color: var(--border-control); + background: var(--surface-control); + color: var(--shell-text); +} + +.diagnostics-toggle--pending { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border-control)); + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: color-mix(in srgb, var(--panel-text) 72%, var(--accent)); +} + +.diagnostics-toggle--error { + border-color: color-mix(in srgb, rgb(var(--danger-rgb)) 60%, var(--border-control)); + background: color-mix(in srgb, rgb(var(--danger-rgb)) 18%, transparent); + color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); +} + +.diagnostics-drawer { + position: fixed; + top: 82px; + right: 18px; + width: min(480px, calc(100vw - 24px)); + max-height: min(72vh, 760px); + padding: 14px 14px 12px; + border: 1px solid var(--border-subtle); + border-radius: 14px; + background: color-mix(in srgb, var(--surface-panel) 92%, transparent); + box-shadow: 0 20px 40px var(--shadow-elev-1); + backdrop-filter: blur(8px); + overflow: auto; + z-index: 25; +} + +.diagnostics-drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.diagnostics-drawer__header h2 { + margin: 0; + font-size: 1rem; +} + +.diagnostics-drawer__actions { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.diagnostics-group { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} + +.diagnostics-group h3 { + margin: 0 0 8px; + font-size: 0.86rem; + color: var(--text-muted); +} + +.diagnostics-scope { + max-height: 220px; + overflow: auto; + padding-right: 4px; + font-size: 0.84rem; +} + +.diagnostics-empty { + color: var(--text-muted); +} + +@media (max-width: 900px) { + .diagnostics-drawer { + top: auto; + right: 12px; + left: 12px; + bottom: 68px; + width: auto; + max-height: 58vh; + } +} diff --git a/src/styles/dialogs-overlays.css b/src/styles/dialogs-overlays.css new file mode 100644 index 0000000..66f1ad4 --- /dev/null +++ b/src/styles/dialogs-overlays.css @@ -0,0 +1,141 @@ +.confirm-dialog { + border: none; + padding: 0; + background: transparent; + color: inherit; +} + +.confirm-dialog:modal { + width: min(460px, calc(100vw - 32px)); + border: 1px solid var(--border-tooltip); + border-radius: 14px; + background: var(--surface-dialog); + color: var(--dialog-text); + box-shadow: 0 18px 42px var(--shadow-elev-1); +} + +.confirm-dialog::backdrop { + background: var(--surface-loading); + backdrop-filter: blur(2px); +} + +.confirm-dialog__form { + margin: 0; + padding: 18px; +} + +.confirm-dialog__form h3 { + margin: 0; + font-size: 1.04rem; +} + +.confirm-dialog__form p { + margin: 10px 0 0; + color: var(--dialog-muted); + font-size: 0.9rem; +} + +.confirm-dialog__actions { + margin: 18px 0 0; + padding: 0; + list-style: none; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.confirm-dialog__button { + border-radius: 10px; + border: 1px solid transparent; + min-height: 34px; + padding: 6px 14px; + cursor: pointer; + font-weight: 600; +} + +.confirm-dialog__button--secondary { + border-color: var(--border-control); + background: var(--surface-control); + color: var(--select-option-text); +} + +.confirm-dialog__button--secondary:hover { + background: var(--surface-control-hover); +} + +.confirm-dialog__button--danger { + border-color: rgba(var(--danger-rgb), 0.48); + background: rgba(var(--danger-rgb), 0.2); + color: var(--danger-text); +} + +.confirm-dialog__button--danger:hover { + background: rgba(var(--danger-rgb), 0.32); +} + +.confirm-dialog__button:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +@media (max-width: 900px) { + .panel-header-main-actions .controls, + .controls--actions { + justify-content: flex-start; + } +} + +.app-footer { + padding: 12px 24px 24px; + color: var(--text-muted); + font-size: 0.85rem; +} + +.cdn-loading { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + background: var(--surface-loading); + backdrop-filter: blur(6px); + transition: opacity 160ms ease; +} + +.cdn-loading[hidden] { + opacity: 0; + pointer-events: none; +} + +.cdn-loading-card { + width: min(560px, calc(100% - 40px)); + border-radius: 14px; + border: 1px solid var(--border-loading-card); + background: var(--surface-loading-card); + box-shadow: 0 20px 42px var(--shadow-elev-2); + padding: 22px 20px; + text-align: center; +} + +.cdn-loading-spinner { + width: 28px; + height: 28px; + margin: 0 auto 12px; + border-radius: 50%; + border: 2px solid var(--loading-spinner-border); + border-top-color: var(--preview-spinner); + animation: preview-spin 0.7s linear infinite; +} + +.cdn-loading-title { + margin: 0; + font-size: 1rem; + color: var(--loading-title); + font-weight: 700; +} + +.cdn-loading-copy { + margin: 8px 0 0; + font-size: 0.9rem; + color: var(--loading-copy); +} diff --git a/src/styles/layout-shell.css b/src/styles/layout-shell.css new file mode 100644 index 0000000..4480f64 --- /dev/null +++ b/src/styles/layout-shell.css @@ -0,0 +1,183 @@ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 28px; + border-bottom: 1px solid var(--border-subtle); + background: var(--surface-app-header); + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(8px); +} + +.app-header h1 { + margin: 0 0 6px; + font-size: 1.4rem; +} + +.brand-link { + color: inherit; + text-decoration: none; +} + +.brand-link:hover { + text-decoration: underline; +} + +.app-header p { + margin: 0; + color: var(--text-muted); + font-size: 0.95rem; +} + +.status { + font-size: 0.9rem; + padding: 8px 12px; + border-radius: 999px; + background: var(--surface-chip); + color: var(--text-subtle); +} + +.status--neutral { + background: var(--surface-chip); + color: var(--text-subtle); +} + +.status--pending { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: color-mix(in srgb, var(--panel-text) 72%, var(--accent)); +} + +.status--error { + background: color-mix(in srgb, rgb(var(--danger-rgb)) 20%, transparent); + color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); +} + +.app-grid { + display: grid; + grid-template-columns: repeat(2, minmax(320px, 1fr)); + grid-template-areas: + 'layout-controls layout-controls' + 'component styles' + 'preview preview'; + gap: 18px; + padding: 24px; +} + +.app-grid--preview-right { + grid-template-areas: + 'layout-controls layout-controls' + 'component preview' + 'styles preview'; +} + +.app-grid--preview-left { + grid-template-areas: + 'layout-controls layout-controls' + 'preview component' + 'preview styles'; +} + +.app-grid-layout-controls { + grid-area: layout-controls; + display: inline-flex; + align-items: center; + justify-self: end; + gap: 10px; +} + +.app-grid-theme-controls { + display: inline-flex; + gap: 10px; + margin-left: 8px; + padding-left: 10px; + border-left: 1px solid var(--border-subtle); +} + +.app-grid-diagnostics-controls { + display: inline-flex; + align-items: center; + margin-right: 8px; + padding-right: 10px; + border-right: 1px solid var(--border-subtle); +} + +.layout-toggle { + display: inline-grid; + place-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--border-control); + border-radius: 10px; + background: var(--surface-control); + color: var(--icon-color); + cursor: pointer; +} + +.layout-toggle svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; +} + +.layout-toggle:hover { + background: var(--surface-control-hover); +} + +.layout-toggle[aria-pressed='true'] { + border-color: color-mix(in srgb, var(--accent) 65%, transparent); + background: var(--accent-soft); + color: var(--select-text); +} + +.layout-toggle:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.component-panel { + grid-area: component; + max-height: min(64vh, 620px); + min-height: 0; +} + +.styles-panel { + grid-area: styles; + max-height: min(64vh, 620px); + min-height: 0; +} + +.preview-panel { + grid-area: preview; + position: relative; +} + +@media (max-width: 900px) { + .app-grid { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + 'layout-controls' + 'component' + 'styles' + 'preview'; + } + + .app-grid-layout-controls { + justify-self: start; + } + + .app-grid-theme-controls { + margin-left: 0; + padding-left: 0; + border-left: none; + } + + .app-grid-diagnostics-controls { + margin-right: 0; + padding-right: 0; + border-right: none; + } +} diff --git a/src/styles/panels-editor.css b/src/styles/panels-editor.css new file mode 100644 index 0000000..21c7e49 --- /dev/null +++ b/src/styles/panels-editor.css @@ -0,0 +1,152 @@ +.panel { + background: var(--surface-panel); + border: 1px solid var(--border-subtle); + border-radius: 14px; + display: flex; + flex-direction: column; + min-height: 360px; + overflow: hidden; +} + +.panel.preview { + min-height: 280px; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--border-subtle); + background: var(--surface-panel-header); + position: relative; + z-index: 2; +} + +.panel-header h2 { + margin: 0; + font-size: 1rem; +} + +.panel-header--grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'title quick' + 'actions actions'; + align-items: start; + column-gap: 12px; + row-gap: 10px; +} + +.panel-header--grid h2 { + grid-area: title; +} + +.panel-header-quick-actions { + grid-area: quick; + justify-self: center; +} + +.panel-header-main-actions { + grid-area: actions; + justify-self: start; +} + +.panel-header-main-actions .controls { + justify-content: flex-start; +} + +.controls { + display: flex; + gap: 12px; + align-items: center; + font-size: 0.85rem; + color: var(--text-controls); + flex-wrap: wrap; +} + +.controls--actions { + justify-content: flex-start; +} + +.controls--quick-actions { + gap: 10px; +} + +.controls label { + display: flex; + flex-direction: column; + gap: 6px; +} + +.controls select { + appearance: none; + -webkit-appearance: none; + background-color: var(--surface-select); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='currentColor' d='M1.2 0 5 3.8 8.8 0 10 1.2 5 6 0 1.2z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + color: var(--select-text); + border: 1px solid var(--border-control); + border-radius: 8px; + padding: 6px 30px 6px 10px; + color-scheme: var(--control-color-scheme); +} + +.controls select:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.controls select option, +.controls select optgroup { + background: var(--surface-select-option); + color: var(--select-option-text); +} + +.controls select option:disabled { + color: var(--select-option-disabled); +} + +textarea { + flex: 1; + background: transparent; + color: var(--panel-text); + border: none; + padding: 16px 18px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.9rem; + resize: none; +} + +textarea:focus { + outline: none; +} + +.source-textarea--hidden { + display: none; +} + +.editor-host { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.editor-host .cm-editor { + height: 100%; +} + +.editor-host .cm-scroller { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + overflow: auto; +} + +@media (max-width: 900px) { + .component-panel, + .styles-panel { + max-height: none; + min-height: 360px; + } +} diff --git a/src/styles/preview-controls.css b/src/styles/preview-controls.css new file mode 100644 index 0000000..15e9159 --- /dev/null +++ b/src/styles/preview-controls.css @@ -0,0 +1,220 @@ +.preview-host { + --preview-host-display: block; + --preview-host-flex: 1 1 auto; + --preview-host-min-height: 180px; + --preview-host-padding: 18px; + --preview-host-overflow: auto; + --preview-host-position: relative; + --preview-host-z-index: 1; + display: var(--preview-host-display); + flex: var(--preview-host-flex); + min-height: var(--preview-host-min-height); + padding: var(--preview-host-padding); + overflow: var(--preview-host-overflow); + position: var(--preview-host-position); + background: var(--surface-preview); + z-index: var(--preview-host-z-index); +} + +.preview-host[data-style-compiling='true']::before { + content: 'Compiling styles…'; + position: absolute; + inset: 0; + display: grid; + place-content: center; + padding-top: 34px; + background: var(--surface-overlay); + backdrop-filter: blur(2px); + color: var(--panel-text); + font-size: 0.88rem; + letter-spacing: 0.01em; + z-index: 2; +} + +.preview-host[data-style-compiling='true']::after { + content: ''; + position: absolute; + left: 50%; + top: calc(50% - 18px); + width: 20px; + height: 20px; + margin-left: -10px; + border-radius: 50%; + border: 2px solid var(--loading-spinner-border); + border-top-color: var(--preview-spinner); + animation: preview-spin 0.7s linear infinite; + z-index: 3; +} + +@keyframes preview-spin { + to { + transform: rotate(360deg); + } +} + +.controls label.toggle { + flex-direction: row; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.controls label.color-control { + flex-direction: row; + align-items: center; + gap: 8px; +} + +.controls input[type='color'] { + width: 34px; + height: 24px; + padding: 0; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: transparent; + cursor: pointer; +} + +.controls input[type='color']::-webkit-color-swatch-wrapper { + padding: 0; +} + +.controls input[type='color']::-webkit-color-swatch { + border: none; + border-radius: 6px; +} + +.toggle input { + accent-color: var(--accent); +} + +.hint-icon { + display: inline-grid; + place-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + border: 1px solid var(--border-strong); + color: var(--hint-icon); + font-size: 0.68rem; + font-weight: 700; + line-height: 1; + opacity: 0.9; + background: transparent; +} + +.shadow-hint { + position: relative; + cursor: help; + border-width: 1px; + padding: 0; +} + +.shadow-hint::after { + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity 120ms ease, + transform 120ms ease, + visibility 120ms ease; +} + +.shadow-hint::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 10px); + right: 0; + width: min(320px, calc(100vw - 36px)); + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-tooltip); + background: var(--surface-tooltip); + color: var(--tooltip-text); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.35; + text-align: left; + box-shadow: 0 12px 24px var(--shadow-elev-1); + transform: translateY(-4px); + z-index: 30; +} + +.shadow-hint:hover::after, +.shadow-hint:focus-visible::after { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.shadow-hint:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.render-button { + border: 1px solid var(--border-subtle); + background: var(--accent-soft); + color: var(--select-text); + padding: 6px 14px; + border-radius: 999px; + cursor: pointer; + font-weight: 600; +} + +.render-button:hover { + background: var(--accent-soft-hover); +} + +.render-button--loading { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.render-button--loading::before { + content: ''; + width: 12px; + height: 12px; + border-radius: 999px; + border: 2px solid var(--loading-spinner-border); + border-top-color: var(--preview-spinner); + animation: preview-spin 0.7s linear infinite; +} + +.render-button:disabled { + opacity: 0.78; + cursor: wait; +} + +.icon-button { + border: 1px solid var(--border-control); + background: var(--surface-control); + color: var(--icon-color); + width: 32px; + height: 32px; + padding: 0; + border-radius: 999px; + cursor: pointer; + display: inline-grid; + place-content: center; +} + +.icon-button:hover { + background: var(--surface-control-hover); +} + +.icon-button:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.icon-button svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/src/type-diagnostics.js b/src/type-diagnostics.js new file mode 100644 index 0000000..46f038f --- /dev/null +++ b/src/type-diagnostics.js @@ -0,0 +1,379 @@ +const ignoredTypeDiagnosticCodes = new Set([2318, 6053]) + +export const createTypeDiagnosticsController = ({ + cdnImports, + importFromCdnWithFallback, + getTypeScriptLibUrls, + getJsxSource, + defaultTypeScriptLibFileName = 'lib.esnext.full.d.ts', + setTypecheckButtonLoading, + setTypeDiagnosticsDetails, + setStatus, + setRenderedStatus, + isRenderedStatus, + isRenderedTypeErrorStatus, + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, +}) => { + let typeCheckRunId = 0 + let typeScriptCompiler = null + let typeScriptCompilerProvider = null + let typeScriptLibFiles = null + let lastTypeErrorCount = 0 + let hasUnresolvedTypeErrors = false + let scheduledTypeRecheck = null + + const clearTypeRecheckTimer = () => { + if (!scheduledTypeRecheck) { + return + } + + clearTimeout(scheduledTypeRecheck) + scheduledTypeRecheck = null + } + + const flattenTypeDiagnosticMessage = (compiler, messageText) => { + if (typeof compiler.flattenDiagnosticMessageText === 'function') { + return compiler.flattenDiagnosticMessageText(messageText, '\n') + } + + if (typeof messageText === 'string') { + return messageText + } + + if (messageText && typeof messageText.messageText === 'string') { + return messageText.messageText + } + + return 'Unknown TypeScript diagnostic' + } + + const formatTypeDiagnostic = (compiler, diagnostic) => { + const message = flattenTypeDiagnosticMessage(compiler, diagnostic.messageText) + + if (!diagnostic.file || typeof diagnostic.start !== 'number') { + return `TS${diagnostic.code}: ${message}` + } + + const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + return `L${position.line + 1}:${position.character + 1} TS${diagnostic.code}: ${message}` + } + + const ensureTypeScriptCompiler = async () => { + if (typeScriptCompiler) { + return typeScriptCompiler + } + + try { + const loaded = await importFromCdnWithFallback(cdnImports.typescript) + typeScriptCompiler = loaded.module.default ?? loaded.module + typeScriptCompilerProvider = loaded.provider ?? null + + if (typeof typeScriptCompiler.transpileModule !== 'function') { + throw new Error(`transpileModule export was not found from ${loaded.url}`) + } + + return typeScriptCompiler + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unknown TypeScript module loading failure' + throw new Error( + `Unable to load TypeScript diagnostics runtime from CDN: ${message}`, + { + cause: error, + }, + ) + } + } + + const shouldIgnoreTypeDiagnostic = diagnostic => { + return ignoredTypeDiagnosticCodes.has(diagnostic.code) + } + + const normalizeVirtualFileName = fileName => + typeof fileName === 'string' && fileName.startsWith('/') + ? fileName.slice(1) + : fileName + + const fetchTypeScriptLibText = async fileName => { + const urls = getTypeScriptLibUrls(fileName, { + typeScriptProvider: typeScriptCompilerProvider, + }) + + const attempts = urls.map(async url => { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${url}`) + } + + return response.text() + }) + + try { + return await Promise.any(attempts) + } catch (error) { + let message = error instanceof Error ? error.message : String(error) + + if (error instanceof AggregateError) { + const reasons = Array.from(error.errors ?? []) + .slice(0, 3) + .map(reason => (reason instanceof Error ? reason.message : String(reason))) + const reasonSummary = reasons.length ? ` Causes: ${reasons.join(' | ')}` : '' + + message = `Tried URLs: ${urls.join(', ')}.${reasonSummary}` + } + + throw new Error(`Unable to fetch TypeScript lib file ${fileName}: ${message}`, { + cause: error, + }) + } + } + + const parseTypeScriptLibReferences = sourceText => { + const references = new Set() + const libReferencePattern = /\/\/\/\s*/g + const pathReferencePattern = /\/\/\/\s*/g + + for (const match of sourceText.matchAll(libReferencePattern)) { + const libName = match[1]?.trim() + if (libName) { + references.add(`lib.${libName}.d.ts`) + } + } + + for (const match of sourceText.matchAll(pathReferencePattern)) { + const pathName = match[1]?.trim() + if (pathName) { + references.add(pathName.replace(/^\.\//, '')) + } + } + + return [...references] + } + + const hydrateTypeScriptLibFiles = async (pendingFileNames, loaded) => { + const batch = [...new Set(pendingFileNames.map(normalizeVirtualFileName))].filter( + fileName => + typeof fileName === 'string' && fileName.length > 0 && !loaded.has(fileName), + ) + + if (batch.length === 0) { + return + } + + const discoveredReferences = await Promise.all( + batch.map(async fileName => { + const sourceText = await fetchTypeScriptLibText(fileName) + loaded.set(fileName, sourceText) + return parseTypeScriptLibReferences(sourceText).map(normalizeVirtualFileName) + }), + ) + + await hydrateTypeScriptLibFiles(discoveredReferences.flat(), loaded) + } + + const ensureTypeScriptLibFiles = async () => { + if (typeScriptLibFiles) { + return typeScriptLibFiles + } + + const loaded = new Map() + await hydrateTypeScriptLibFiles([defaultTypeScriptLibFileName], loaded) + typeScriptLibFiles = loaded + return typeScriptLibFiles + } + + const collectTypeDiagnostics = async (compiler, sourceText) => { + const sourceFileName = 'component.tsx' + const jsxTypesFileName = 'knighted-jsx-runtime.d.ts' + const libFiles = await ensureTypeScriptLibFiles() + const jsxTypes = + 'declare namespace React {\n' + + ' type Key = string | number\n' + + ' interface Attributes { key?: Key | null }\n' + + '}\n' + + 'declare namespace JSX {\n' + + ' type Element = unknown\n' + + ' interface ElementChildrenAttribute { children: unknown }\n' + + ' interface IntrinsicAttributes extends React.Attributes {}\n' + + ' interface IntrinsicElements { [elemName: string]: Record }\n' + + '}\n' + + const files = new Map([ + [sourceFileName, sourceText], + [jsxTypesFileName, jsxTypes], + ...libFiles.entries(), + ]) + + const options = { + jsx: compiler.JsxEmit?.Preserve, + target: compiler.ScriptTarget?.ES2022, + module: compiler.ModuleKind?.ESNext, + strict: true, + noEmit: true, + skipLibCheck: true, + } + + const host = { + fileExists: fileName => files.has(normalizeVirtualFileName(fileName)), + readFile: fileName => files.get(normalizeVirtualFileName(fileName)), + getSourceFile: (fileName, languageVersion) => { + const normalizedFileName = normalizeVirtualFileName(fileName) + const text = files.get(normalizedFileName) + if (typeof text !== 'string') { + return undefined + } + + const scriptKind = normalizedFileName.endsWith('.tsx') + ? compiler.ScriptKind?.TSX + : normalizedFileName.endsWith('.d.ts') + ? compiler.ScriptKind?.TS + : compiler.ScriptKind?.TS + + return compiler.createSourceFile( + normalizedFileName, + text, + languageVersion, + true, + scriptKind, + ) + }, + getDefaultLibFileName: () => defaultTypeScriptLibFileName, + writeFile: () => {}, + getCurrentDirectory: () => '/', + getDirectories: () => [], + getCanonicalFileName: fileName => normalizeVirtualFileName(fileName), + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + } + + const program = compiler.createProgram({ + rootNames: [sourceFileName, jsxTypesFileName], + options, + host, + }) + + return compiler + .getPreEmitDiagnostics(program) + .filter(diagnostic => !shouldIgnoreTypeDiagnostic(diagnostic)) + } + + const runTypeDiagnostics = async runId => { + incrementTypeDiagnosticsRuns() + setTypecheckButtonLoading(true) + + setTypeDiagnosticsDetails({ + headline: 'Type checking…', + level: 'muted', + }) + + try { + const compiler = await ensureTypeScriptCompiler() + if (runId !== typeCheckRunId) { + return + } + + const diagnostics = await collectTypeDiagnostics(compiler, getJsxSource()) + const errorCategory = compiler.DiagnosticCategory?.Error + const errors = diagnostics.filter( + diagnostic => diagnostic.category === errorCategory, + ) + lastTypeErrorCount = errors.length + hasUnresolvedTypeErrors = errors.length > 0 + clearTypeRecheckTimer() + + if (errors.length === 0) { + setTypeDiagnosticsDetails({ + headline: 'No TypeScript errors found.', + level: 'ok', + }) + } else { + setTypeDiagnosticsDetails({ + headline: `TypeScript found ${errors.length} error${errors.length === 1 ? '' : 's'}:`, + lines: errors.map(diagnostic => formatTypeDiagnostic(compiler, diagnostic)), + level: 'error', + }) + } + + if (isRenderedStatus()) { + setRenderedStatus() + } + } catch (error) { + if (runId !== typeCheckRunId) { + return + } + + lastTypeErrorCount = 0 + hasUnresolvedTypeErrors = false + clearTypeRecheckTimer() + const message = error instanceof Error ? error.message : String(error) + setTypeDiagnosticsDetails({ + headline: `Type diagnostics unavailable: ${message}`, + level: 'error', + }) + + if (isRenderedTypeErrorStatus()) { + setStatus('Rendered', 'neutral') + } + } finally { + decrementTypeDiagnosticsRuns() + setTypecheckButtonLoading(getActiveTypeDiagnosticsRuns() > 0) + } + } + + const triggerTypeDiagnostics = () => { + typeCheckRunId += 1 + void runTypeDiagnostics(typeCheckRunId) + } + + const scheduleTypeRecheck = () => { + clearTypeRecheckTimer() + + if (!hasUnresolvedTypeErrors) { + return + } + + scheduledTypeRecheck = setTimeout(() => { + scheduledTypeRecheck = null + triggerTypeDiagnostics() + }, 450) + } + + const markTypeDiagnosticsStale = () => { + if (hasUnresolvedTypeErrors) { + setTypeDiagnosticsDetails({ + headline: 'Source changed. Re-checking type errors…', + level: 'muted', + }) + scheduleTypeRecheck() + return + } + + lastTypeErrorCount = 0 + setTypeDiagnosticsDetails({ + headline: 'Source changed. Click Typecheck to run diagnostics.', + level: 'muted', + }) + + if (isRenderedTypeErrorStatus()) { + setStatus('Rendered', 'neutral') + } + } + + const clearTypeDiagnosticsState = () => { + lastTypeErrorCount = 0 + hasUnresolvedTypeErrors = false + clearTypeRecheckTimer() + } + + return { + clearTypeDiagnosticsState, + clearTypeRecheckTimer, + getLastTypeErrorCount: () => lastTypeErrorCount, + markTypeDiagnosticsStale, + triggerTypeDiagnostics, + } +}