diff --git a/.github/workflows/nodejs-dependencies.yml b/.github/workflows/nodejs-dependencies.yml index 91a3f2420a..0b5bf7bab9 100644 --- a/.github/workflows/nodejs-dependencies.yml +++ b/.github/workflows/nodejs-dependencies.yml @@ -36,4 +36,4 @@ jobs: package-manager-cache: false - run: | pnpm dedupe --check - pnpm dlx sherif@latest + pnpm dlx sherif@latest -i tailwindcss diff --git a/.github/workflows/workflow-website.yml b/.github/workflows/workflow-website.yml index 3333d2fe12..0b460bc32f 100644 --- a/.github/workflows/workflow-website.yml +++ b/.github/workflows/workflow-website.yml @@ -26,6 +26,7 @@ jobs: key: turbo-v4-${{ runner.os }}-${{ hashFiles('**/packages/**/src/**/*.rs') }}-${{ github.sha }} fail-on-cache-miss: true - name: Setup Pages + id: pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Install run: | @@ -35,6 +36,16 @@ jobs: - name: Build run: | pnpm turbo --filter website build:docs + - name: Build REPL + run: | + BASE_PATH='${{ steps.pages.outputs.base_path }}' + export ASSET_PREFIX="${BASE_PATH%/}/repl/" + pnpm --filter @lynx-js/repl build + - name: Copy REPL into website output + run: | + rm -rf website/doc_build/repl + mkdir -p website/doc_build + cp -r packages/repl/dist website/doc_build/repl - name: Upload artifact uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 with: diff --git a/CODEOWNERS b/CODEOWNERS index 1b7c81c3ff..68fbe22b6a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,3 +1,4 @@ +packages/repl/** @huxpro packages/web-platform/** @pupiltong @Sherry-hue packages/webpack/** @colinaaa @upupming @luhc228 packages/rspeedy/** @colinaaa @upupming @luhc228 diff --git a/biome.jsonc b/biome.jsonc index e5e33fce6e..8a87dad6e0 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -57,6 +57,9 @@ "packages/motion/__tests__/**", "packages/rspeedy/lynx-bundle-rslib-config/test/fixtures/**", + + // REPL examples use Lynx platform globals and are not subject to lint rules + "packages/repl/src/examples/**", ], "rules": { // We are migrating from ESLint to Biome diff --git a/eslint.config.js b/eslint.config.js index 6e5e773d9d..4c8b0c9301 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -71,6 +71,14 @@ export default tseslint.config( 'packages/react/transform/index.cjs', 'packages/react/transform/**/index.d.ts', + // REPL examples use Lynx platform globals and are not subject to lint rules + 'packages/repl/src/examples/**', + // REPL components use Vite path aliases and ?raw imports not handled by root tsconfig + 'packages/repl/src/components/**', + 'packages/repl/src/editor.ts', + // REPL build config is not part of the TS project + 'packages/repl/rsbuild.config.ts', + // TODO: enable eslint for react // react 'packages/react/types/**', diff --git a/package.json b/package.json index a747793fb3..a1ef3188ab 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,12 @@ "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "engines": { "node": "^22 || ^24" + }, + "pnpm": { + "peerDependencyRules": { + "allowedVersions": { + "rsbuild-plugin-tailwindcss>tailwindcss": "^3 || ^4" + } + } } } diff --git a/packages/repl/.gitignore b/packages/repl/.gitignore new file mode 100644 index 0000000000..d3db8f9b18 --- /dev/null +++ b/packages/repl/.gitignore @@ -0,0 +1 @@ +src/generated/ diff --git a/packages/repl/README.md b/packages/repl/README.md new file mode 100644 index 0000000000..ea426fb794 --- /dev/null +++ b/packages/repl/README.md @@ -0,0 +1,77 @@ +# @lynx-js/repl + +A **pure-browser** playground for Vanilla Lynx. Users write raw Element PAPI code and see it rendered in real time — no local toolchain, no server, no install. + +## Architecture Principle: Pure Browser, Zero Server + +This REPL runs **entirely in the browser**. The built artifact is a static site that can be deployed to any CDN or GitHub Pages. There is no compilation server, no Node.js dependency at runtime, and no network round-trip in the edit-preview loop. + +An alternative approach would be to run a local (or remote) Node.js server that invokes rspack for each edit — effectively a browser-based editor bolted onto the real build pipeline. We deliberately chose **not** to do this because: + +1. **If you need a local server, you already have Node.js** — at that point `rspeedy dev` gives you a strictly better experience (your own editor, incremental builds, HMR, full plugin pipeline). +2. A server-backed REPL sits in an awkward middle ground: it loses the zero-setup advantage of a pure-browser tool while offering a worse DX than the real toolchain. +3. The value of this REPL is precisely that it **does not overlap** with `rspeedy dev`. It serves a different audience (learners, docs, quick experiments) with a different tradeoff (instant access, limited features). + +## Where We Cut Into the Build Pipeline + +A standard Lynx project goes through 8 steps from source to bundle: + +```text +Step What happens Tool / Plugin +──── ────────────────────────────────── ───────────────────────────── + 1. Source Transform (JSX/TS → JS) rspack loader (SWC) + 2. Module Resolution (import/require) rspack resolver + 3. CSS Processing (CSS → LynxStyleNode) @lynx-js/css-serializer + 4. Bundling (multi-file → chunks) rspack + 5. Asset Tagging (lynx:main-thread) MarkMainThreadPlugin + 6. Template Assembly (assets → data) LynxTemplatePlugin + 7. Encoding (data → binary/JSON) @lynx-js/tasm / WebEncodePlugin + 8. Emit (write to disk) rspack compilation +``` + +This REPL **enters at step 6** and takes a shortcut: + +```text + rspeedy dev (full pipeline): + [1] → [2] → [3] → [4] → [5] → [6] → [7] → [8] → lynx-view (via URL fetch) + + This REPL (pure browser): + [3'] → [6'] ─────────→ lynx-view (via callback) +``` + +- **Step 3':** Parse user CSS in-browser via `css-tree` (pure JS) and a simplified `genStyleInfo` (adapted from `@lynx-js/css-serializer`) to produce `styleInfo`. +- **Step 6':** Directly construct a `LynxTemplate` JS object from the user's code strings and the processed `styleInfo`. No webpack compilation, no asset pipeline. +- **Delivery:** The template object is handed to `` via the `customTemplateLoader` callback — no encoding, no file I/O, no URL fetch. + +Steps 1, 2, 4, 5 are skipped entirely because the user writes final-form JS (no JSX, no imports, no multi-file). Steps 7–8 are skipped because `` can consume a `LynxTemplate` object directly. + +## Functional Boundaries + +What this REPL **can** do: + +- All Element PAPIs (`__CreateElement`, `__AppendElement`, `__AddInlineStyle`, etc.) +- CSS class selectors, `@keyframes`, `@font-face`, CSS variables +- Inline styles via `__AddInlineStyle` +- Real-time preview with Lynx Web runtime (``) +- Dual-thread model: main-thread.js (Lepus) + background.js (Web Worker) + +What this REPL **cannot** do (by design): + +- `import` / `require` — no module resolution, no bundler +- JSX / TypeScript — no source transform +- Export `.lynx.bundle` — requires `@lynx-js/tasm` (Node native addon) +- Export `.web.json` binary — possible in future but not a priority +- Multi-file projects — single main-thread.js + background.js + index.css + +Users who need any of the above should use `rspeedy dev`. + +## Target Capability: L0 + L1 + +We target two capability levels, both achievable in pure browser: + +| Level | Capability | Browser dependency | +| ------------- | ----------------------------------- | --------------------------- | +| **L0** (done) | Raw Element PAPI with inline styles | None (zero extra KB) | +| **L1** (done) | + CSS class selectors | `css-tree` (~200KB gzipped) | + +Higher levels (JSX, module imports, bundle export) are **not planned** — they would move toward `rspeedy dev` territory without matching its DX. diff --git a/packages/repl/index.html b/packages/repl/index.html new file mode 100644 index 0000000000..5d67bfdd96 --- /dev/null +++ b/packages/repl/index.html @@ -0,0 +1,23 @@ + + + + + + Lynx REPL + + + + +
+ + diff --git a/packages/repl/package.json b/packages/repl/package.json new file mode 100644 index 0000000000..b465ca3ffc --- /dev/null +++ b/packages/repl/package.json @@ -0,0 +1,51 @@ +{ + "name": "@lynx-js/repl", + "version": "0.0.1", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/lynx-family/lynx-stack.git", + "directory": "packages/repl" + }, + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "node scripts/collect-lynx-types.mjs && rsbuild build", + "dev": "node scripts/collect-lynx-types.mjs && rsbuild dev" + }, + "dependencies": { + "@radix-ui/react-select": "^2.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.575.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-resizable-panels": "^4.6.5", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@lynx-js/lynx-core": "0.1.3", + "@lynx-js/type-element-api": "0.0.3", + "@lynx-js/types": "3.7.0", + "@lynx-js/web-constants": "workspace:*", + "@lynx-js/web-core": "workspace:*", + "@lynx-js/web-elements": "workspace:*", + "@lynx-js/web-mainthread-apis": "workspace:*", + "@lynx-js/web-platform-rsbuild-plugin": "workspace:*", + "@lynx-js/web-worker-rpc": "workspace:*", + "@lynx-js/web-worker-runtime": "workspace:*", + "@rsbuild/core": "catalog:rsbuild", + "@rsbuild/plugin-react": "^1.4.5", + "@tailwindcss/postcss": "^4.2.1", + "@types/css-tree": "^2.3.11", + "@types/react": "npm:@types/react@^19.2.14", + "@types/react-dom": "^19.2.3", + "css-tree": "^3.1.0", + "hyphenate-style-name": "^1.1.0", + "monaco-editor": "^0.52.2", + "postcss": "^8.5.6", + "tailwindcss": "^4.2.1", + "tslib": "^2.8.1", + "wasm-feature-detect": "^1.8.0" + } +} diff --git a/packages/repl/postcss.config.mjs b/packages/repl/postcss.config.mjs new file mode 100644 index 0000000000..8c5fa35031 --- /dev/null +++ b/packages/repl/postcss.config.mjs @@ -0,0 +1,9 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/packages/repl/rsbuild.config.ts b/packages/repl/rsbuild.config.ts new file mode 100644 index 0000000000..14b90ba959 --- /dev/null +++ b/packages/repl/rsbuild.config.ts @@ -0,0 +1,74 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { pluginWebPlatform } from '@lynx-js/web-platform-rsbuild-plugin'; +import path from 'node:path'; + +export default defineConfig({ + source: { + entry: { + index: './src/index.tsx', + }, + include: [/node_modules[\\/]@lynx-js[\\/]/, /@lynx-js[\\/]/], + }, + output: { + target: 'web', + assetPrefix: process.env.ASSET_PREFIX, + distPath: { + root: 'dist', + }, + overrideBrowserslist: ['Chrome > 118'], + }, + html: { + title: 'Lynx REPL', + template: './index.html', + }, + tools: { + rspack: { + ignoreWarnings: [ + (warning) => + warning.module?.resource?.includes('monaco-editor') + && warning.message.includes('Critical dependency') + && warning.message.includes('require function is used in a way'), + (warning) => + warning.module?.resource?.includes('monaco-editor') + && warning.message.includes( + '"__filename" is used and has been mocked', + ), + (warning) => + warning.module?.resource?.includes('monaco-editor') + && warning.message.includes( + '"__dirname" is used and has been mocked', + ), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@lynx-js/type-element-api/types/element-api.d.ts': path.resolve( + __dirname, + 'node_modules/@lynx-js/type-element-api/types/element-api.d.ts', + ), + }, + fallback: { + module: false, + }, + modules: [ + 'node_modules', + path.resolve(__dirname, 'node_modules'), + path.resolve(__dirname, '../../node_modules'), + ], + symlinks: true, + }, + }, + }, + performance: { + chunkSplit: { + strategy: 'all-in-one', + }, + }, + plugins: [ + pluginReact(), + pluginWebPlatform({ + polyfill: false, + }), + ], +}); diff --git a/packages/repl/scripts/collect-lynx-types.mjs b/packages/repl/scripts/collect-lynx-types.mjs new file mode 100644 index 0000000000..a276bf2dc9 --- /dev/null +++ b/packages/repl/scripts/collect-lynx-types.mjs @@ -0,0 +1,107 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Resolve @lynx-js/types from node_modules (works with pnpm hoisting) +function findPackageDir(packageName) { + const candidates = [ + join(__dirname, '../node_modules', packageName), + join(__dirname, '../../../node_modules', packageName), + ]; + for (const dir of candidates) { + try { + statSync(dir); + return dir; + } catch { /* skip */ } + } + // Fallback: resolve via pnpm store + const pnpmDir = join( + __dirname, + '../../../node_modules/.pnpm', + ); + try { + for (const entry of readdirSync(pnpmDir)) { + if ( + entry.startsWith(packageName.replace('/', '+').replace('@', '') + '@') + || entry.startsWith(packageName.replace('/', '+') + '@') + ) { + const resolved = join(pnpmDir, entry, 'node_modules', packageName); + try { + statSync(resolved); + return resolved; + } catch { /* skip */ } + } + } + } catch { /* skip */ } + throw new Error(`Cannot find ${packageName} in node_modules`); +} + +function collectDtsFiles(dir, baseDir) { + const results = []; + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + if (statSync(fullPath).isDirectory()) { + results.push(...collectDtsFiles(fullPath, baseDir)); + } else if (entry.endsWith('.d.ts')) { + results.push({ + relativePath: relative(baseDir, fullPath), + content: readFileSync(fullPath, 'utf-8'), + }); + } + } + return results; +} + +// Collect @lynx-js/types +const lynxTypesDir = findPackageDir('@lynx-js/types'); +const lynxTypesRoot = join(lynxTypesDir, 'types'); +const lynxFiles = collectDtsFiles(lynxTypesRoot, lynxTypesRoot); + +// Collect csstype (external dependency used by @lynx-js/types) +const csstypeDir = findPackageDir('csstype'); +const csstypeIndex = join(csstypeDir, 'index.d.ts'); +let csstypeContent; +try { + csstypeContent = readFileSync(csstypeIndex, 'utf-8'); +} catch { + console.warn('Warning: csstype/index.d.ts not found, using stub'); + csstypeContent = + 'export interface Properties { [key: string]: any; }'; +} + +// Build the output map +const typeMap = {}; + +// Add csstype with proper virtual path so @lynx-js/types can import it +typeMap['node_modules/csstype/index.d.ts'] = csstypeContent; + +// Add all @lynx-js/types files +for (const file of lynxFiles) { + typeMap[`node_modules/@lynx-js/types/types/${file.relativePath}`] = + file.content; +} + +// Write JSON output +const outputDir = join(__dirname, '../src/generated'); +mkdirSync(outputDir, { recursive: true }); +writeFileSync( + join(outputDir, 'lynx-types-map.json'), + JSON.stringify(typeMap, null, 2), +); + +console.info( + `Collected ${ + Object.keys(typeMap).length + } type files → src/generated/lynx-types-map.json`, +); diff --git a/packages/repl/src/App.tsx b/packages/repl/src/App.tsx new file mode 100644 index 0000000000..ebba5717b3 --- /dev/null +++ b/packages/repl/src/App.tsx @@ -0,0 +1,321 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { LynxTemplate } from '@lynx-js/web-constants'; + +import { buildLynxTemplate } from './bundler/template-builder.js'; +import { EditorPane } from './components/EditorPane.js'; +import type { EditorPaneHandle } from './components/EditorPane.js'; +import { Header } from './components/Header.js'; +import { PreviewPane } from './components/PreviewPane.js'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from './components/ui/resizable.js'; +import { useConsole } from './console/useConsole.js'; +import { + clearLocalStorage, + loadFromLocalStorage, + saveToLocalStorage, +} from './local-storage.js'; +import { samples } from './samples.js'; +import { getInitialState, saveSampleToUrl, saveToUrl } from './url-state.js'; + +const MOBILE_BREAKPOINT = 768; +const SESSION_ID = Math.random().toString(36).slice(2); + +function useIsMobile() { + const [isMobile, setIsMobile] = useState( + () => window.innerWidth < MOBILE_BREAKPOINT, + ); + useEffect(() => { + const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + return isMobile; +} + +// Resolve initial code and sample index from URL > localStorage > default +function resolveInitial(): { + code: { mainThread: string; background: string; css: string }; + sampleIndex: number | null; +} { + const urlState = getInitialState(); + + if (urlState?.type === 'custom') { + return { code: urlState.code, sampleIndex: null }; + } + + if (urlState?.type === 'sample') { + const sample = samples[urlState.sampleIndex]; + return { + code: { + mainThread: sample.mainThread, + background: sample.background, + css: sample.css, + }, + sampleIndex: urlState.sampleIndex, + }; + } + + const cached = loadFromLocalStorage(); + if (cached) { + return { code: cached, sampleIndex: null }; + } + + const defaultSample = samples[0]; + return { + code: { + mainThread: defaultSample.mainThread, + background: defaultSample.background, + css: defaultSample.css, + }, + sampleIndex: 0, + }; +} + +const initial = resolveInitial(); + +export function App() { + const [layout, setLayout] = useState<'rows' | 'cols'>('rows'); + const [isDark, setIsDark] = useState(true); + const [layoutReady, setLayoutReady] = useState(false); + const [sampleIndex, setSampleIndex] = useState( + initial.sampleIndex, + ); + const [timingText, setTimingText] = useState(''); + const [template, setTemplate] = useState(null); + const pendingTimingRef = useRef< + | { css: number | null; assemble: number; t0: number; buildEnd: number } + | null + >(null); + const [mobileTab, setMobileTab] = useState<'editor' | 'preview'>('editor'); + const isMobile = useIsMobile(); + const { entries: consoleEntries, clear: clearConsole } = useConsole( + SESSION_ID, + ); + + const editorPaneRef = useRef(null); + + const handleRenderComplete = useCallback(() => { + const pending = pendingTimingRef.current; + if (!pending) return; + const renderEnd = performance.now(); + const renderTime = renderEnd - pending.buildEnd; + const totalTime = renderEnd - pending.t0; + // Show µs for sub-millisecond values so they don't round to 0.00ms + const fmt = (v: number) => + v >= 1 ? `${v.toFixed(1)}ms` : `${(v * 1000).toFixed(0)}µs`; + const cssText = pending.css === null ? '-' : fmt(pending.css); + setTimingText( + [ + `css: ${cssText}`, + `asm: ${fmt(pending.assemble)}`, + `render: ${fmt(renderTime)}`, + `total: ${fmt(totalTime)}`, + ].join(' \u00b7 '), + ); + pendingTimingRef.current = null; + }, []); + + const rebuild = useCallback(() => { + const editor = editorPaneRef.current?.editor; + if (!editor) return; + + setTimingText(''); + + const t0 = performance.now(); + const { background, mainThread, css } = editor.getCode(); + + const { template: newTemplate, timing } = buildLynxTemplate( + mainThread, + background, + css, + SESSION_ID, + ); + const buildEnd = performance.now(); + + pendingTimingRef.current = { + css: timing['css-serializer'], + assemble: timing.assemble, + t0, + buildEnd, + }; + + setTemplate(newTemplate); + }, []); + + // Debounced rebuild + persist + const timerRef = useRef>(); + const debouncedRebuild = useCallback(() => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + const editor = editorPaneRef.current?.editor; + if (!editor) return; + + rebuild(); + + // Persist current code to localStorage and URL + const code = editor.getCode(); + saveToLocalStorage(code); + saveToUrl(code); + setSampleIndex(null); + }, 500); + }, [rebuild]); + + // Initial render + collapse empty panels after mount. + // React runs child effects before parent effects, so by the time this runs + // the Monaco editor in EditorPane is already initialized — no timeout needed. + // We keep the content invisible (opacity-0) until the collapse+resize settles + // to avoid the flash of an un-collapsed layout. + useEffect(() => { + rebuild(); + editorPaneRef.current?.collapseByContent(initial.code); + // applyCollapsedState schedules a rAF for distributeEqual; wait for it, + // then wait one more frame for React to commit the resize, then reveal. + requestAnimationFrame(() => { + requestAnimationFrame(() => setLayoutReady(true)); + }); + }, [rebuild]); + + const handleToggleLayout = useCallback(() => { + setLayout((prev) => (prev === 'rows' ? 'cols' : 'rows')); + }, []); + + const handleToggleTheme = useCallback(() => { + setIsDark((prev) => { + const next = !prev; + document.documentElement.setAttribute( + 'data-theme', + next ? 'dark' : 'light', + ); + editorPaneRef.current?.editor?.setDarkMode(next); + return next; + }); + }, []); + + const handleReload = useCallback(() => { + clearConsole(); + rebuild(); + }, [clearConsole, rebuild]); + + const handleSampleChange = useCallback( + (index: number) => { + setSampleIndex(index); + clearConsole(); + const sample = samples[index]; + const code = { + background: sample.background, + mainThread: sample.mainThread, + css: sample.css, + }; + editorPaneRef.current?.editor?.setCode(code); + editorPaneRef.current?.collapseByContent(code); + clearLocalStorage(); + saveSampleToUrl(index); + rebuild(); + // setCode() triggers Monaco's onChange which queues a debouncedRebuild; + // cancel it so the sample isn't immediately overwritten as custom code. + clearTimeout(timerRef.current); + }, + [rebuild, clearConsole], + ); + + const handleShare = useCallback(() => { + if (sampleIndex === null) { + const editor = editorPaneRef.current?.editor; + if (!editor) return; + saveToUrl(editor.getCode()); + } else { + saveSampleToUrl(sampleIndex); + } + // eslint-disable-next-line n/no-unsupported-features/node-builtins, @typescript-eslint/no-floating-promises + navigator.clipboard.writeText(window.location.href); + }, [sampleIndex]); + + return ( +
+
+ + {/* Fade in once the initial collapse+resize has settled to avoid layout shift */} +
+ {isMobile + ? ( +
+
+ +
+
+ +
+
+ ) + : ( + + + + + + + + + + )} +
+
+ ); +} diff --git a/packages/repl/src/bundler/css-processor.ts b/packages/repl/src/bundler/css-processor.ts new file mode 100644 index 0000000000..42f27bc96e --- /dev/null +++ b/packages/repl/src/bundler/css-processor.ts @@ -0,0 +1,373 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import * as csstree from 'css-tree'; + +import type { CSSRule, OneInfo, StyleInfo } from '@lynx-js/web-constants'; + +// --- Simplified parse (adapted from css-serializer/parse.ts) --- + +interface Declaration { + name: string; + value: string; +} + +interface VariableDeclaration extends Declaration { + type: 'css_var'; + defaultValue?: string; + defaultValueMap?: Record; +} + +type CSSDeclaration = Declaration | VariableDeclaration; + +interface StyleRule { + type: 'StyleRule'; + selectorText: { value: string }; + variables: Record; + style: CSSDeclaration[]; +} + +interface FontFaceRule { + type: 'FontFaceRule'; + style: CSSDeclaration[]; +} + +interface KeyframesRule { + type: 'KeyframesRule'; + name: { value: string }; + styles: { keyText: { value: string }; style: CSSDeclaration[] }[]; +} + +type LynxStyleNode = StyleRule | FontFaceRule | KeyframesRule; + +function transformDeclaration(node: csstree.Declaration): CSSDeclaration { + let hasVarFunction = false; + + csstree.walk(node, (n) => { + if (n.type === 'Function' && n.name === 'var') { + hasVarFunction = true; + } + }); + + if (hasVarFunction) { + const defaultValueMap: Record = {}; + let hasDefaultValue = true; + + const valueNode = csstree.clone(node) as csstree.Declaration; + csstree.walk(valueNode, (n, item) => { + if (n.type === 'Function' && n.name === 'var') { + const varFunctionValues = n.children.toArray(); + const varName = varFunctionValues[0]?.type === 'Identifier' + ? varFunctionValues[0].name + : undefined; + item.data = { + ...n, + type: 'Raw', + value: ` {{${varName}}}${item.next === null ? '' : ' '}`, + }; + } + }); + + csstree.walk(node, (n, item) => { + if (n.type === 'Function' && n.name === 'var') { + const varFunctionValues = n.children.toArray(); + const varName = varFunctionValues[0]?.type === 'Identifier' + ? varFunctionValues[0].name + : undefined; + const firstOperator = varFunctionValues[1]?.type === 'Operator' + ? varFunctionValues[1].value + : undefined; + const varDefaultValueNodes = varFunctionValues.slice(2); + + if (!varName || (firstOperator && firstOperator !== ',')) { + throw new Error(`illegal css value ${csstree.generate(n)}`); + } + if (varDefaultValueNodes.length > 0) { + const currentDefaultValueText = varDefaultValueNodes + .map(node => csstree.generate(node)) + .join(''); + defaultValueMap[varName] = currentDefaultValueText; + item.data = { + ...n, + type: 'Raw', + value: currentDefaultValueText, + }; + } else { + hasDefaultValue = false; + defaultValueMap[varName] = ''; + } + } + }); + + return { + type: 'css_var', + name: node.property, + value: csstree.generate(valueNode.value).trim() + + (node.important ? ' !important' : ''), + defaultValue: hasDefaultValue + ? csstree.generate(node.value).trim() + : '', + defaultValueMap, + }; + } + + return { + name: node.property, + value: csstree.generate(node.value) + (node.important ? ' !important' : ''), + }; +} + +function transformBlock(block: csstree.Block): CSSDeclaration[] { + const declarations = block.children.toArray().filter( + (node) => node.type === 'Declaration' && !node.property.startsWith('--'), + ) as csstree.Declaration[]; + return declarations.map((e) => transformDeclaration(e)); +} + +function parseCSS(content: string): LynxStyleNode[] { + const result: LynxStyleNode[] = []; + const ast = csstree.parse(content, { + parseValue: true, + parseAtrulePrelude: true, + parseCustomProperty: true, + parseRulePrelude: true, + positions: true, + filename: './index.css', + }) as csstree.StyleSheet; + + // First pass: flatten nested rules and handle URLs/comments + csstree.walk(ast, { + enter: function( + this: csstree.WalkContext, + node: csstree.CssNode, + item: csstree.ListItem, + list: csstree.List, + ) { + if (node.type === 'Url') { + item.data = { + ...node, + type: 'Raw', + value: `url('${node.value}')`, + }; + } else if (node.type === 'Comment') { + list.remove(item); + } else if (node.type === 'Rule') { + const parent = node; + node.block?.children.filter(node => node.type === 'Raw').forEach( + (child, childItem, childList) => { + childList.remove(childItem); + const childAst = csstree.parse(child.value, { + positions: true, + ...child.loc?.start, + }); + csstree.walk(childAst, (subParseChild, subParseChildItem) => { + if (subParseChild.type === 'Rule') { + if ( + subParseChild.prelude.type === 'SelectorList' + && parent.prelude.type === 'SelectorList' + ) { + const parentSelectorList = parent.prelude + .children as csstree.List; + (subParseChild.prelude.children as csstree.List< + csstree.Selector + >).forEach((selector) => { + selector.children.prependData({ + ...selector, + type: 'WhiteSpace', + value: ' ', + }); + selector.children.prependList(parentSelectorList.copy()); + }); + } + if (item.next) { + list.insert(subParseChildItem, item.next); + } else { + list.append(subParseChildItem); + } + } + }); + }, + ); + } + }, + }); + + // Second pass: extract style nodes + csstree.walk(ast, { + enter: function( + this: csstree.WalkContext, + node: csstree.CssNode, + ): symbol | undefined { + if (node.type === 'Atrule') { + if (node.name === 'font-face') { + result.push({ + type: 'FontFaceRule', + style: transformBlock(node.block!), + }); + return this.skip; + } else if (node.name === 'keyframes') { + if (!node.block) return; + result.push({ + type: 'KeyframesRule', + name: { + value: node.prelude ? csstree.generate(node.prelude) : '', + }, + styles: node.block.children.toArray().filter(node => + node.type === 'Rule' + ).map(rule => { + return { + keyText: { + value: csstree.generate(rule.prelude), + }, + style: transformBlock(rule.block), + }; + }), + }); + return this.skip; + } + // Skip @import and other at-rules (not supported in REPL) + return this.skip; + } else if (node.type === 'Rule') { + const preludeText = csstree.generate(node.prelude); + result.push({ + type: 'StyleRule', + style: transformBlock(node.block), + selectorText: { value: preludeText }, + variables: Object.fromEntries( + node.block.children.toArray().filter(node => + node.type === 'Declaration' && node.property.startsWith('--') + ).map((node) => { + return [ + (node as csstree.Declaration).property, + csstree.generate((node as csstree.Declaration).value) + + ((node as csstree.Declaration).important + ? ' !important' + : ''), + ]; + }), + ), + }); + return this.skip; + } + }, + }); + + return result; +} + +// --- genStyleInfo (adapted from template-webpack-plugin/src/web/genStyleInfo.ts) --- + +function genStyleInfo( + cssMap: Record, +): StyleInfo { + return Object.fromEntries( + Object.entries(cssMap).map(([cssId, nodes]) => { + const contentsAtom: string[] = []; + const rules: CSSRule[] = []; + for (const node of nodes) { + if (node.type === 'FontFaceRule') { + contentsAtom.push( + [ + '@font-face {', + node.style.map((declaration) => + `${declaration.name}:${declaration.value}` + ).join(';'), + '}', + ].join(''), + ); + } else if (node.type === 'KeyframesRule') { + contentsAtom.push( + [ + `@keyframes ${node.name.value} {`, + node.styles.map((keyframesStyle) => + `${keyframesStyle.keyText.value} {${ + keyframesStyle.style.map((declaration) => + `${declaration.name}:${declaration.value};` + ).join('') + }}` + ).join(' '), + '}', + ].join(''), + ); + } else if (node.type === 'StyleRule') { + const ast = csstree.parse( + `${node.selectorText.value}{ --mocked-declaration:1;}`, + ) as csstree.StyleSheet; + const selectors = ((ast.children.first as csstree.Rule) + .prelude as csstree.SelectorList).children + .toArray() as csstree.Selector[]; + const groupedSelectors: CSSRule['sel'] = []; + for (const selectorList of selectors) { + let plainSelectors: string[] = []; + let pseudoClassSelectors: string[] = []; + let pseudoElementSelectors: string[] = []; + const currentSplitSelectorInfo: string[][] = []; + for (const selector of selectorList.children.toArray()) { + if ( + selector.type === 'PseudoClassSelector' + && selector.name === 'root' + ) { + plainSelectors.push('[lynx-tag="page"]'); + } else if (selector.type === 'PseudoClassSelector') { + pseudoClassSelectors.push(csstree.generate(selector)); + } else if (selector.type === 'PseudoElementSelector') { + if (selector.name === 'placeholder') { + pseudoClassSelectors.push('::part(input)::placeholder'); + } else { + pseudoElementSelectors.push(csstree.generate(selector)); + } + } else if (selector.type === 'TypeSelector') { + plainSelectors.push(`[lynx-tag="${selector.name}"]`); + } else if (selector.type === 'Combinator') { + currentSplitSelectorInfo.push( + plainSelectors, + pseudoClassSelectors, + pseudoElementSelectors, + [csstree.generate(selector)], + ); + plainSelectors = []; + pseudoClassSelectors = []; + pseudoElementSelectors = []; + } else { + plainSelectors.push(csstree.generate(selector)); + } + } + currentSplitSelectorInfo.push( + plainSelectors, + pseudoClassSelectors, + pseudoElementSelectors, + [], + ); + groupedSelectors.push(currentSplitSelectorInfo); + } + const decl = node.style.map<[string, string]>(( + declaration, + ) => [ + declaration.name, + declaration.value.replaceAll(/\{\{--([^}]+)\}\}/g, 'var(--$1)'), + ]); + + decl.push(...(Object.entries(node.variables))); + + rules.push({ + sel: groupedSelectors, + decl, + }); + } + } + const info: OneInfo = { + content: [contentsAtom.join('\n')], + rules, + }; + return [cssId, info]; + }), + ); +} + +// --- Public API --- + +export function processCSS(cssString: string): StyleInfo { + const nodes = parseCSS(cssString); + return genStyleInfo({ '0': nodes }); +} diff --git a/packages/repl/src/bundler/template-builder.ts b/packages/repl/src/bundler/template-builder.ts new file mode 100644 index 0000000000..c6b9efd4e4 --- /dev/null +++ b/packages/repl/src/bundler/template-builder.ts @@ -0,0 +1,76 @@ +/* eslint-disable headers/header-format */ +import type { LynxTemplate, StyleInfo } from '@lynx-js/web-constants'; + +import { processCSS } from './css-processor.js'; +import { getConsoleWrapperCode } from '../console/console-wrapper.js'; + +// Injects callDestroyLifetimeFun into lynxCoreInject.tt so that lynx-core can +// register it on multiApps[id] and invoke it safely during card dispose. +// Mirrors what ReactLynx does in packages/react/runtime/src/lynx/tt.ts, +// but without the React/worklet-specific teardown — for raw Element PAPI cards +// the only meaningful cleanup is neutralizing stale event handlers. +function getBackgroundLifecycleCode(): string { + return `(function(){ + if (typeof lynxCoreInject !== 'undefined' && lynxCoreInject.tt) { + lynxCoreInject.tt.callDestroyLifetimeFun = function() { + lynxCoreInject.tt.publishEvent = function() {}; + lynxCoreInject.tt.publicComponentEvent = function() {}; + }; + } +})(); +`; +} + +export function buildLynxTemplate( + mainThread: string, + background: string, + css: string, + sessionId: string, +): { + template: LynxTemplate; + timing: { 'css-serializer': number | null; assemble: number }; +} { + const mainThreadWithFallback = `${mainThread} + +if (typeof globalThis.renderPage !== 'function') { + globalThis.renderPage = () => {}; +} +`; + + const mainThreadCode = getConsoleWrapperCode('main-thread', sessionId) + + mainThreadWithFallback; + const backgroundCode = getConsoleWrapperCode('background', sessionId) + + getBackgroundLifecycleCode() + + background; + + let styleInfo: StyleInfo = {}; + let cssSerializerTime: number | null = null; + if (css.trim()) { + const t = performance.now(); + styleInfo = processCSS(css); + cssSerializerTime = performance.now() - t; + } + + const assembleStart = performance.now(); + const template: LynxTemplate = { + lepusCode: { root: mainThreadCode }, + manifest: { '/app-service.js': backgroundCode }, + styleInfo, + pageConfig: { + enableCSSSelector: true, + enableRemoveCSSScope: true, + defaultDisplayLinear: true, + defaultOverflowVisible: true, + enableJSDataProcessor: false, + }, + customSections: {}, + elementTemplate: {}, + appType: 'card', + }; + const assembleTime = performance.now() - assembleStart; + + return { + template, + timing: { 'css-serializer': cssSerializerTime, assemble: assembleTime }, + }; +} diff --git a/packages/repl/src/components/ConsolePanel.tsx b/packages/repl/src/components/ConsolePanel.tsx new file mode 100644 index 0000000000..03a6c0782a --- /dev/null +++ b/packages/repl/src/components/ConsolePanel.tsx @@ -0,0 +1,169 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import type { ConsoleEntry, ConsoleSource } from '../console/types.js'; +import { Button } from './ui/button.js'; + +type FilterTab = 'all' | ConsoleSource; + +const TABS: FilterTab[] = ['all', 'main-thread', 'background']; + +interface ConsolePanelProps { + entries: ConsoleEntry[]; + onClear: () => void; + timingText?: string; +} + +export function ConsolePanel( + { entries, onClear, timingText }: ConsolePanelProps, +) { + const [filter, setFilter] = useState('all'); + const scrollRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — scroll only when entry count changes + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [entries.length]); + + const filtered = filter === 'all' + ? entries + : entries.filter(e => e.source === filter); + + return ( +
+
+
+ {TABS.map(tab => ( + + ))} +
+ +
+ +
+ {timingText && ( +
+ {timingText} +
+ )} + {filtered.length === 0 && !timingText + ? ( +
+ No console output yet. +
+ ) + : ( + filtered.map(entry => ( + + )) + )} +
+
+ ); +} + +function ConsoleEntryRow({ entry }: { entry: ConsoleEntry }) { + const levelStyles: Record< + string, + { color: string; bg: string; border: string } + > = { + log: { + color: 'var(--repl-text)', + bg: 'transparent', + border: 'var(--repl-border)', + }, + info: { + color: 'var(--repl-console-info)', + bg: 'transparent', + border: 'var(--repl-border)', + }, + warn: { + color: 'var(--repl-console-warn)', + bg: 'var(--repl-console-warn-bg)', + border: 'var(--repl-console-warn)', + }, + error: { + color: 'var(--repl-error-text)', + bg: 'var(--repl-console-error-bg)', + border: 'var(--repl-error-border)', + }, + debug: { + color: 'var(--repl-text-dim)', + bg: 'transparent', + border: 'var(--repl-border)', + }, + }; + + const style = levelStyles[entry.level] ?? levelStyles.log; + const sourceLabel = entry.source === 'main-thread' ? 'MT' : 'BG'; + + return ( +
+ + {sourceLabel} + + + {entry.args.join(' ')} + +
+ ); +} diff --git a/packages/repl/src/components/EditorPane.tsx b/packages/repl/src/components/EditorPane.tsx new file mode 100644 index 0000000000..610a87e4b2 --- /dev/null +++ b/packages/repl/src/components/EditorPane.tsx @@ -0,0 +1,329 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import type { PanelImperativeHandle, PanelSize } from 'react-resizable-panels'; + +import { createEditor } from '../editor.js'; +import type { EditorInstance } from '../editor.js'; +import { EditorWindowBody, EditorWindowHeader } from './EditorWindow.js'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from './ui/resizable.js'; + +export interface EditorPaneHandle { + editor: EditorInstance | null; + /** Collapse panels whose corresponding code is empty, expand those with content. */ + collapseByContent( + code: { mainThread: string; background: string; css: string }, + ): void; +} + +interface EditorPaneProps { + layout: 'rows' | 'cols'; + defaultCode: { mainThread: string; background: string; css: string }; + onChange: () => void; +} + +/** Height/width of a collapsed panel in px (main-axis size). */ +const COLLAPSED_SIZE_PX = 30; + +/** Monaco editor line height: Math.round(1.5 × fontSize=13) */ +const LINE_HEIGHT_PX = 20; +/** From EDITOR_OPTIONS padding.top */ +const MONACO_TOP_PADDING_PX = 4; +/** EditorWindowHeader min-h-[26px] */ +const HEADER_HEIGHT_PX = 26; +/** Bottom buffer for scrollbar / focus ring */ +const MONACO_BOTTOM_BUFFER_PX = 8; + +/** Estimate the pixel height a panel needs to display `code` without scrolling. */ +function naturalHeightPx(code: string): number { + const lines = (code.match(/\n/g)?.length ?? 0) + 1; + return ( + HEADER_HEIGHT_PX + + MONACO_TOP_PADDING_PX + + lines * LINE_HEIGHT_PX + + MONACO_BOTTOM_BUFFER_PX + ); +} + +const WINDOWS = [ + { + id: 'window-main-thread', + title: 'main-thread.js', + key: 'mainThread' as const, + }, + { + id: 'window-background', + title: 'background.js', + key: 'background' as const, + }, + { id: 'window-css', title: 'index.css', key: 'css' as const }, +]; + +export const EditorPane = forwardRef( + function EditorPane({ layout, defaultCode, onChange }, ref) { + const mainThreadRef = useRef(null); + const backgroundRef = useRef(null); + const cssRef = useRef(null); + const editorRef = useRef(null); + + // Ref to the panel group's root element for measuring available space + const groupElementRef = useRef(null); + + // Individual panel refs stored in a stable ref container so useCallback + // closures always see the current handles without needing them as deps. + const panel0Ref = useRef(null); + const panel1Ref = useRef(null); + const panel2Ref = useRef(null); + const panelRefs = useRef([panel0Ref, panel1Ref, panel2Ref]); + + const [collapsedStates, setCollapsedStates] = useState([ + false, + false, + false, + ]); + + /** + * Distribute space among expanded panels, shrinking panels whose natural + * content height is less than their equal share and giving surplus to others. + * Falls back to equal distribution for panels that want more space. + */ + const distributeByContent = useCallback( + ( + collapsed: boolean[], + code: { mainThread: string; background: string; css: string }, + ) => { + const expandedIndices: number[] = []; + collapsed.forEach((c, i) => { + if (!c) expandedIndices.push(i); + }); + if (expandedIndices.length === 0) return; + + const el = groupElementRef.current; + if (!el) return; + const totalPx = layout === 'rows' ? el.offsetHeight : el.offsetWidth; + if (!totalPx) return; + + const collapsedCount = collapsed.filter(Boolean).length; + const remainingPx = totalPx - collapsedCount * COLLAPSED_SIZE_PX; + + const codeValues = [code.mainThread, code.background, code.css]; + const naturalHeights = WINDOWS.map((_, i) => + naturalHeightPx(codeValues[i] ?? '') + ); + + // Iteratively cap panels whose natural height < their equal share, + // redistributing the surplus to uncapped panels. + const sizes: number[] = [0, 0, 0]; + let uncapped = [...expandedIndices]; + let available = remainingPx; + + while (uncapped.length > 0) { + const share = available / uncapped.length; + const nowCapped: number[] = []; + + for (const i of uncapped) { + if (naturalHeights[i] < share) { + sizes[i] = naturalHeights[i]; + nowCapped.push(i); + } + } + + if (nowCapped.length === 0) { + // All remaining panels want at least their share — split equally + for (const i of uncapped) { + sizes[i] = share; + } + break; + } + + available -= nowCapped.reduce((sum, i) => sum + sizes[i], 0); + uncapped = uncapped.filter(i => !nowCapped.includes(i)); + } + + for (const i of expandedIndices) { + panelRefs.current[i]?.current?.resize( + Math.max(sizes[i], COLLAPSED_SIZE_PX + 1), + ); + } + }, + [layout], + ); + + /** + * Resize all currently-expanded panels to equal shares of the remaining space. + * `collapsed` is the intended target state (already applied to the panel refs). + */ + const distributeEqual = useCallback((collapsed: boolean[]) => { + const expandedIndices: number[] = []; + collapsed.forEach((c, i) => { + if (!c) expandedIndices.push(i); + }); + if (expandedIndices.length === 0) return; // all collapsed – nothing to distribute + + const el = groupElementRef.current; + if (!el) return; + const totalPx = layout === 'rows' ? el.offsetHeight : el.offsetWidth; + if (!totalPx) return; + + const collapsedCount = collapsed.filter(Boolean).length; + const remainingPx = totalPx - collapsedCount * COLLAPSED_SIZE_PX; + const perPanelPx = Math.max( + remainingPx / expandedIndices.length, + COLLAPSED_SIZE_PX + 1, + ); + + for (const i of expandedIndices) { + panelRefs.current[i]?.current?.resize(perPanelPx); + } + }, [layout]); + + // Track collapsed state when the user drags panels; use the library's own isCollapsed() + // rather than a pixel comparison to avoid false positives during mid-drag. + const makeResizeHandler = useCallback((index: number) => { + return (_size: PanelSize) => { + const isCollapsed = panelRefs.current[index]?.current?.isCollapsed() + ?? false; + setCollapsedStates(prev => { + if (prev[index] === isCollapsed) return prev; + const next = [...prev]; + next[index] = isCollapsed; + return next; + }); + }; + }, []); + + /** + * Apply a target collapsed state to all panels, then redistribute space. + * Pass a custom `distributeFn` for content-aware sizing; omit for equal split. + * This is the single shared path used by both toggle and collapseByContent. + */ + const applyCollapsedState = useCallback( + ( + newCollapsed: boolean[], + distributeFn?: (collapsed: boolean[]) => void, + ) => { + panelRefs.current.forEach((pRef, i) => { + const panel = pRef.current; + if (!panel) return; + if (newCollapsed[i]) { + if (!panel.isCollapsed()) panel.collapse(); + } else { + if (panel.isCollapsed()) panel.expand(); + } + }); + setCollapsedStates(newCollapsed); + requestAnimationFrame(() => + (distributeFn ?? distributeEqual)(newCollapsed) + ); + }, + [distributeEqual], + ); + + const handleToggle = useCallback((index: number) => { + const panel = panelRefs.current[index]?.current; + if (!panel) return; + const willCollapse = !panel.isCollapsed(); + const newCollapsed = panelRefs.current.map((pRef, i) => + i === index ? willCollapse : (pRef.current?.isCollapsed() ?? false) + ); + applyCollapsedState(newCollapsed); + }, [applyCollapsedState]); + + useImperativeHandle(ref, () => ({ + get editor() { + return editorRef.current; + }, + collapseByContent( + code: { mainThread: string; background: string; css: string }, + ) { + const values = [code.mainThread, code.background, code.css] as const; + const newCollapsed = values.map(v => !v?.trim()); + applyCollapsedState( + newCollapsed, + collapsed => distributeByContent(collapsed, code), + ); + }, + }), [applyCollapsedState, distributeByContent]); + + // When the layout orientation changes, reset all panels to equal expanded state. + const prevLayoutRef = useRef(layout); + useEffect(() => { + if (prevLayoutRef.current === layout) return; + prevLayoutRef.current = layout; + applyCollapsedState([false, false, false]); + }, [layout, applyCollapsedState]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally mount-only; defaultCode and onChange are stable initial values + useEffect(() => { + if ( + mainThreadRef.current && backgroundRef.current && cssRef.current + && !editorRef.current + ) { + editorRef.current = createEditor( + { + mainThread: mainThreadRef.current, + background: backgroundRef.current, + css: cssRef.current, + }, + defaultCode, + onChange, + ); + } + return () => { + editorRef.current?.dispose(); + editorRef.current = null; + }; + }, []); + + const bodyRefs = [mainThreadRef, backgroundRef, cssRef]; + + return ( + + {WINDOWS.map((win, i) => ( + + {i > 0 && } + +
+ handleToggle(i)} + /> +
+
+
+ ))} +
+ ); + }, +); diff --git a/packages/repl/src/components/EditorWindow.tsx b/packages/repl/src/components/EditorWindow.tsx new file mode 100644 index 0000000000..248ab7c274 --- /dev/null +++ b/packages/repl/src/components/EditorWindow.tsx @@ -0,0 +1,98 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { Minus, Plus } from 'lucide-react'; +import type React from 'react'; + +import { Button } from './ui/button.js'; + +interface EditorWindowHeaderProps { + title: string; + collapsed: boolean; + layout: 'rows' | 'cols'; + onToggle: () => void; +} + +export function EditorWindowHeader( + { title, collapsed, layout, onToggle }: EditorWindowHeaderProps, +) { + // Collapsed in cols mode: narrow vertical strip — expand button on top, rotated title below. + if (collapsed && layout === 'cols') { + return ( +
+ + + {title} + +
+ ); + } + + // Normal horizontal header (rows mode, or cols mode when expanded). + return ( +
+ + {title} + + +
+ ); +} + +interface EditorWindowBodyProps { + id: string; + bodyRef: React.RefObject; + hidden?: boolean; +} + +export function EditorWindowBody( + { id, bodyRef, hidden }: EditorWindowBodyProps, +) { + return ( +
+
+
+ ); +} diff --git a/packages/repl/src/components/Header.tsx b/packages/repl/src/components/Header.tsx new file mode 100644 index 0000000000..26e45679ca --- /dev/null +++ b/packages/repl/src/components/Header.tsx @@ -0,0 +1,174 @@ +/* eslint-disable headers/header-format, sort-imports, import/order, n/file-extension-in-import, unicorn/consistent-function-scoping, @typescript-eslint/prefer-nullish-coalescing */ +import { useMemo, useState, useCallback } from 'react'; +import { + Sun, + Moon, + Rows3, + Columns3, + Link2, + Check, + Code2, + Eye, +} from 'lucide-react'; +import { Button } from './ui/button'; +import { Separator } from './ui/separator'; +import { samples } from '../samples.js'; + +interface HeaderProps { + layout: 'rows' | 'cols'; + onToggleLayout: () => void; + isDark: boolean; + onToggleTheme: () => void; + sampleIndex: number | null; + onSampleChange: (index: number) => void; + onShare: () => void; + isMobile?: boolean; + mobileTab?: 'editor' | 'preview'; + onMobileTabChange?: (tab: 'editor' | 'preview') => void; +} + +export function Header({ + layout, + onToggleLayout, + isDark, + onToggleTheme, + sampleIndex, + onSampleChange, + onShare, + isMobile, + mobileTab, + onMobileTabChange, +}: HeaderProps) { + const [copied, setCopied] = useState(false); + + const handleShare = useCallback(() => { + onShare(); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [onShare]); + + const tabButtonClass = (active: boolean) => + `flex items-center gap-1 font-mono text-[11px] h-8 px-2 sm:px-3 py-1 rounded-md border transition-colors ${ + active + ? 'bg-[var(--repl-accent)] border-[var(--repl-accent)] text-white' + : 'bg-[var(--repl-bg-elevated)] hover:bg-[var(--repl-bg-input)] border-[var(--repl-border)] text-[var(--repl-text)]' + }`; + + // Build grouped options: filter hidden, group by category, preserve original indices + const groupedOptions = useMemo(() => { + const groups: { + category: string; + items: { index: number; name: string }[]; + }[] = []; + const categoryMap = new Map(); + + samples.forEach((s, i) => { + if (s.hidden) return; + const cat = s.category || 'Other'; + if (!categoryMap.has(cat)) { + const items: { index: number; name: string }[] = []; + categoryMap.set(cat, items); + groups.push({ category: cat, items }); + } + categoryMap.get(cat)!.push({ index: i, name: s.name }); + }); + + return groups; + }, []); + + return ( +
+
+ + LYNX REPL + + + + {isMobile && onMobileTabChange + ? ( +
+ + +
+ ) + : ( + + )} + + +
+ +
+ + +
+
+ ); +} diff --git a/packages/repl/src/components/LynxPreview.tsx b/packages/repl/src/components/LynxPreview.tsx new file mode 100644 index 0000000000..bf7268051c --- /dev/null +++ b/packages/repl/src/components/LynxPreview.tsx @@ -0,0 +1,140 @@ +/* eslint-disable headers/header-format, sort-imports, import/order, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, n/no-unsupported-features/node-builtins, @typescript-eslint/prefer-nullish-coalescing */ +import { useRef, useEffect, useState, useCallback } from 'react'; +import type { LynxTemplate } from '@lynx-js/web-constants'; +import type { LynxView } from '@lynx-js/web-core'; + +let renderCounter = 0; + +interface LynxPreviewProps { + template: LynxTemplate | null; + isDark: boolean; + onLoad?: () => void; +} + +export function LynxPreview({ template, isDark, onLoad }: LynxPreviewProps) { + const containerRef = useRef(null); + const viewRef = useRef(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleError = useCallback((event: Event) => { + const detail = (event as CustomEvent).detail; + const errorMessage = detail?.error?.message || detail?.error + || 'Unknown error'; + const fileName = detail?.fileName; + setIsLoading(false); + if ( + fileName === 'app-service.js' + && typeof errorMessage === 'string' + && errorMessage.includes('__CreatePage is not defined') + ) { + setError( + 'Runtime Error: __CreatePage is not defined in background.js.\n' + + 'Hint: put Element PAPI rendering code in main-thread.js ' + + '(inside globalThis.renderPage).', + ); + return; + } + setError(`Runtime Error: ${errorMessage}`); + }, []); + + // Create lynx-view once and keep it alive for the component's lifetime. + // biome-ignore lint/correctness/useExhaustiveDependencies: isDark sets initial theme at mount; live changes handled by a separate effect + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const lynxView = document.createElement('lynx-view') as LynxView; + lynxView.globalProps = { + locale: navigator.language, + theme: isDark ? 'dark' : 'light', + }; + lynxView.addEventListener('error', handleError); + container.appendChild(lynxView); + viewRef.current = lynxView; + return () => { + lynxView.removeEventListener('error', handleError); + lynxView.remove(); + viewRef.current = null; + }; + }, [handleError]); + + // Notify the running card whenever the REPL theme toggles, and keep the + // element's globalProps in sync so re-boots see the updated theme. + // Skip the initial mount — globalProps is already set at element creation. + const isMounted = useRef(false); + useEffect(() => { + if (!isMounted.current) { + isMounted.current = true; + return; + } + const lynxView = viewRef.current; + if (!lynxView) return; + const theme = isDark ? 'dark' : 'light'; + lynxView.globalProps = { locale: navigator.language, theme }; + lynxView.sendGlobalEvent('themeChanged', [theme]); + }, [isDark]); + + // On each template change: swap loader + bump url. + // lynx-view tears down the old instance and boots fresh via queueMicrotask. + useEffect(() => { + const lynxView = viewRef.current; + if (!template || !lynxView) return; + + setError(null); + setIsLoading(true); + + lynxView.customTemplateLoader = async () => template; + lynxView.url = `repl://template/v${renderCounter++}`; + + // lynx-view has no load event. The url setter schedules teardown+boot via + // queueMicrotask, so our Promise microtask (queued after) runs once the + // shadow root has been reset. Watch for [lynx-tag="page"] — the style tag + // is injected first, so childElementCount > 0 fires too early. + let observer: MutationObserver | null = null; + void Promise.resolve().then(() => { + const root = lynxView.shadowRoot; + if (!root) return; + const checkPage = () => root.querySelector('[lynx-tag="page"]') !== null; + if (checkPage()) { + setIsLoading(false); + onLoad?.(); + return; + } + observer = new MutationObserver(() => { + if (checkPage()) { + setIsLoading(false); + onLoad?.(); + observer?.disconnect(); + observer = null; + } + }); + observer.observe(root, { childList: true, subtree: true }); + }); + + return () => { + observer?.disconnect(); + }; + }, [template, onLoad]); + + return ( +
+ {isLoading && !error && ( +
+ loading… +
+ )} + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/packages/repl/src/components/PreviewPane.tsx b/packages/repl/src/components/PreviewPane.tsx new file mode 100644 index 0000000000..1d60bdf3c1 --- /dev/null +++ b/packages/repl/src/components/PreviewPane.tsx @@ -0,0 +1,78 @@ +/* eslint-disable headers/header-format, sort-imports, import/order, n/file-extension-in-import */ +import { RotateCw } from 'lucide-react'; +import type { LynxTemplate } from '@lynx-js/web-constants'; +import { LynxPreview } from './LynxPreview'; +import { ConsolePanel } from './ConsolePanel'; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from './ui/resizable'; +import type { ConsoleEntry } from '../console/types.js'; + +interface PreviewPaneProps { + template: LynxTemplate | null; + timingText: string; + consoleEntries: ConsoleEntry[]; + onConsoleClear: () => void; + isDark: boolean; + onLoad?: () => void; + onReload?: () => void; +} + +export function PreviewPane( + { + template, + timingText, + consoleEntries, + onConsoleClear, + isDark, + onLoad, + onReload, + }: PreviewPaneProps, +) { + return ( +
+
+ preview + {onReload && ( + + )} +
+ + + + + + + + + + +
+ ); +} diff --git a/packages/repl/src/components/ui/button.tsx b/packages/repl/src/components/ui/button.tsx new file mode 100644 index 0000000000..3697d51402 --- /dev/null +++ b/packages/repl/src/components/ui/button.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/utils/cn'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-8 px-3 py-1', + sm: 'h-7 rounded-md px-2 text-xs', + lg: 'h-9 rounded-md px-4', + icon: 'h-7 w-7', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends + React.ButtonHTMLAttributes, + VariantProps +{} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( +