diff --git a/README.md b/README.md
index 052a7ec..91a42b0 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,27 @@ browser acts as the runtime host for editing, render, lint, and typecheck flows.
- use AI chat with tab-aware proposals and apply/undo controls
- switch theme and collapse the preview panel while preserving fast feedback loops
+## CSS Query Imports In Editor Tabs
+
+`@knighted/develop` supports `@knighted/css` query syntax in workspace tabs, including:
+
+- `./styles/button.module.css?knighted-css`
+- `./button.tsx?knighted-css&combined`
+
+For Lit + React-in-Shadow-DOM flows, a minimal pattern is:
+
+1. In `button.tsx`, export your React component and import CSS Modules normally.
+2. In `lit-host.ts`, import from `./button.tsx?knighted-css&combined` and use `knightedCss`.
+3. Apply `unsafeCSS(knightedCss)` in `static styles` so styles render inside the shadow root.
+
+Example imports:
+
+- `import { ReactButton, knightedCss } from './button.tsx?knighted-css&combined'`
+- `import { LitElement, css, html, unsafeCSS } from 'lit'`
+
+`knightedCssModules` is optional for this flow and is not required when you only need
+the compiled CSS text plus your component exports.
+
## Why this shape
The app started as a focused compile-and-preview loop and has grown into a
diff --git a/playwright/rendering-modes/core.spec.ts b/playwright/rendering-modes/core.spec.ts
index b4f3489..4a28a12 100644
--- a/playwright/rendering-modes/core.spec.ts
+++ b/playwright/rendering-modes/core.spec.ts
@@ -165,6 +165,69 @@ test('renders in react mode with css modules', async ({ page }) => {
await expectPreviewHasRenderedContent(page)
})
+test('reactJsx tag interpolation renders memo and forwardRef components', async ({
+ page,
+}) => {
+ await waitForInitialRender(page)
+ await ensurePanelToolsVisible(page, 'component')
+
+ await page.getByRole('button', { name: 'Open tab App.tsx' }).click()
+ await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react')
+
+ await setComponentEditorSource(
+ page,
+ [
+ "import { memo, forwardRef } from 'react'",
+ "import { reactJsx } from '@knighted/jsx/react'",
+ '',
+ 'type ButtonProps = {',
+ ' label: string',
+ '}',
+ '',
+ 'const MemoButton = memo(({ label }: ButtonProps) => (',
+ ' ',
+ '))',
+ '',
+ 'const ForwardRefButton = forwardRef(',
+ ' ({ label }, ref) => (',
+ ' ',
+ ' ),',
+ ')',
+ '',
+ 'const App = () =>',
+ ' reactJsx`',
+ ' ',
+ ' <${MemoButton} label="Memo OK" />',
+ ' <${ForwardRefButton} label="ForwardRef OK" />',
+ ' ',
+ ' `',
+ ].join('\n'),
+ )
+
+ await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered')
+ await expect(getPreviewFrame(page).getByTestId('memo-button')).toHaveText('Memo OK')
+ await expect(getPreviewFrame(page).getByTestId('forward-ref-button')).toHaveText(
+ 'ForwardRef OK',
+ )
+
+ await expect
+ .poll(async () => {
+ return getPreviewFrame(page)
+ .locator('html')
+ .evaluate(
+ () =>
+ Array.from(document.querySelectorAll('*')).filter(node =>
+ /^__kx_expr__/i.test(node.localName),
+ ).length,
+ )
+ })
+ .toBe(0)
+})
+
test('react mode keeps App.ts entry but surfaces rename guidance until compatible', async ({
page,
}) => {
@@ -306,6 +369,128 @@ test('css module imports expose class map for module tabs', async ({ page }) =>
.not.toContain('.item:active')
})
+test('workspace modules support ?knighted-css&combined imports', async ({ page }) => {
+ await waitForInitialRender(page)
+
+ await ensurePanelToolsVisible(page, 'component')
+ await ensurePanelToolsVisible(page, 'styles')
+
+ await page.getByRole('button', { name: 'Open tab App.tsx' }).click()
+ await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react')
+
+ await renameWorkspaceTab(page, {
+ from: 'app.css',
+ to: 'button.module.css',
+ })
+
+ await page.getByRole('button', { name: 'Open tab button.module.css' }).click()
+ await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module')
+
+ await setWorkspaceTabSource(page, {
+ fileName: 'button.module.css',
+ kind: 'styles',
+ source: ['.btn {', ' color: rgb(7, 89, 160);', ' font-weight: 700;', '}'].join(
+ '\n',
+ ),
+ })
+
+ await addWorkspaceTab(page, { type: 'script' })
+ await renameWorkspaceTab(page, {
+ from: 'module.tsx',
+ to: 'button.tsx',
+ })
+
+ await setWorkspaceTabSource(page, {
+ fileName: 'button.tsx',
+ source: [
+ "import styles from '../styles/button.module.css'",
+ '',
+ 'type ButtonProps = {',
+ ' label: string',
+ '}',
+ '',
+ 'export const ReactButton = ({ label }: ButtonProps) => (',
+ ' ',
+ ')',
+ ].join('\n'),
+ })
+
+ await setComponentEditorSource(
+ page,
+ [
+ "import { ReactButton, knightedCss } from './button.tsx?knighted-css&combined'",
+ '',
+ 'const App = () => (',
+ ' <>',
+ ' ',
+ ' ',
+ ' >',
+ ')',
+ ].join('\n'),
+ )
+
+ await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered')
+ await expect(page.locator('#preview-host pre.preview-runtime-error')).toHaveCount(0)
+ await expect(
+ getPreviewFrame(page).getByRole('button', { name: 'Combined query works' }),
+ ).toHaveCSS('color', 'rgb(7, 89, 160)')
+})
+
+test('workspace style tabs support ?knighted-css query exports', async ({ page }) => {
+ await waitForInitialRender(page)
+
+ await ensurePanelToolsVisible(page, 'component')
+ await ensurePanelToolsVisible(page, 'styles')
+
+ await page.getByRole('button', { name: 'Open tab app.css' }).click()
+ await renameWorkspaceTab(page, {
+ from: 'app.css',
+ to: 'button.module.css',
+ })
+ await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module')
+
+ await setWorkspaceTabSource(page, {
+ fileName: 'button.module.css',
+ kind: 'styles',
+ source: [
+ '.btn {',
+ ' color: rgb(14, 110, 173);',
+ ' border: 1px solid rgb(14, 110, 173);',
+ '}',
+ ].join('\n'),
+ })
+
+ await page.getByRole('button', { name: 'Open tab App.tsx' }).click()
+ await setComponentEditorSource(
+ page,
+ [
+ "import styles, { knightedCss } from '../styles/button.module.css?knighted-css'",
+ '',
+ 'const App = () => (',
+ ' <>',
+ ' ',
+ ' ',
+ ' >',
+ ')',
+ ].join('\n'),
+ )
+
+ await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered')
+ await expect(page.locator('#preview-host pre.preview-runtime-error')).toHaveCount(0)
+
+ const previewButton = getPreviewFrame(page).getByRole('button', {
+ name: 'Style query works',
+ })
+ await expect(previewButton).toHaveCSS('color', 'rgb(14, 110, 173)')
+ await expect(previewButton).toHaveAttribute('data-css-length', /[1-9]\d*/)
+})
+
test('preview styles require explicit import from entry graph', async ({ page }) => {
await waitForInitialRender(page)
diff --git a/src/modules/preview-runtime/virtual-workspace-modules.js b/src/modules/preview-runtime/virtual-workspace-modules.js
index bc7dc8c..a22067e 100644
--- a/src/modules/preview-runtime/virtual-workspace-modules.js
+++ b/src/modules/preview-runtime/virtual-workspace-modules.js
@@ -54,6 +54,55 @@ const stripQueryAndHash = value => {
return value.split(/[?#]/, 1)[0]
}
+const getOwnRecordValue = (record, key) => {
+ if (!record || typeof record !== 'object') {
+ return undefined
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
+ return undefined
+ }
+
+ return record[key]
+}
+
+const parseSpecifierQueryFlags = specifier => {
+ if (typeof specifier !== 'string') {
+ return {
+ hasKnightedCss: false,
+ isCombined: false,
+ }
+ }
+
+ const queryIndex = specifier.indexOf('?')
+ if (queryIndex < 0) {
+ return {
+ hasKnightedCss: false,
+ isCombined: false,
+ }
+ }
+
+ const hashIndex = specifier.indexOf('#', queryIndex + 1)
+ const queryText =
+ hashIndex >= 0
+ ? specifier.slice(queryIndex + 1, hashIndex)
+ : specifier.slice(queryIndex + 1)
+
+ const flags = new Set(
+ queryText
+ .split('&')
+ .map(part => part.trim())
+ .filter(Boolean)
+ .map(part => part.split('=')[0]?.trim().toLowerCase())
+ .filter(Boolean),
+ )
+
+ return {
+ hasKnightedCss: flags.has('knighted-css'),
+ isCombined: flags.has('combined'),
+ }
+}
+
const normalizePathSegments = value => {
const normalized = toModuleSpecifierKey(value)
const inputParts = normalized.split('/')
@@ -421,6 +470,61 @@ const toStyleModuleDataUrl = ({ moduleKey, styleModuleExports }) => {
)
}
+const toKnightedCssQueryDataUrl = ({
+ moduleKey,
+ targetModuleUrl,
+ targetStyleModuleUrl,
+ cssText,
+ isCombined,
+}) => {
+ const safeCssText = typeof cssText === 'string' ? cssText : ''
+ const safeModuleKey = moduleKey || 'module'
+
+ if (typeof targetStyleModuleUrl === 'string' && targetStyleModuleUrl.length > 0) {
+ const sourceUrl = `//# sourceURL=knighted-workspace/${safeModuleKey}.knighted-css.style.mjs`
+ return toModuleDataUrl(
+ [
+ `import __knightedCssDefault from ${JSON.stringify(targetStyleModuleUrl)}`,
+ 'export default __knightedCssDefault',
+ `export const knightedCss = ${JSON.stringify(safeCssText)}`,
+ 'export const knightedCssModules = __knightedCssDefault',
+ 'export const stableSelectors = {}',
+ sourceUrl,
+ ].join('\n'),
+ )
+ }
+
+ if (typeof targetModuleUrl !== 'string' || targetModuleUrl.length === 0) {
+ return null
+ }
+
+ if (isCombined) {
+ const sourceUrl = `//# sourceURL=knighted-workspace/${safeModuleKey}.knighted-css.combined.mjs`
+ return toModuleDataUrl(
+ [
+ `import * as __knightedCombinedModule from ${JSON.stringify(targetModuleUrl)}`,
+ `export * from ${JSON.stringify(targetModuleUrl)}`,
+ 'const __knightedCombinedDefault = __knightedCombinedModule.default',
+ 'export default __knightedCombinedDefault',
+ `export const knightedCss = ${JSON.stringify(safeCssText)}`,
+ 'export const knightedCssModules = {}',
+ 'export const stableSelectors = {}',
+ sourceUrl,
+ ].join('\n'),
+ )
+ }
+
+ const sourceUrl = `//# sourceURL=knighted-workspace/${safeModuleKey}.knighted-css.mjs`
+ return toModuleDataUrl(
+ [
+ `export const knightedCss = ${JSON.stringify(safeCssText)}`,
+ 'export const knightedCssModules = {}',
+ 'export const stableSelectors = {}',
+ sourceUrl,
+ ].join('\n'),
+ )
+}
+
const runtimeSpecifierRewrites = runtimeSpecifiers => ({
react: runtimeSpecifiers.react,
'react-dom': runtimeSpecifiers.reactDom,
@@ -563,6 +667,7 @@ export const planWorkspaceVirtualModules = ({
mode,
runtimeSpecifiers,
styleModuleExportsByTabId = {},
+ styleCssByTabId = {},
}) => {
if (!entryTab || typeof entryTab.content !== 'string') {
return null
@@ -694,6 +799,72 @@ export const planWorkspaceVirtualModules = ({
const moduleDataByTabId = new Map()
const styleModuleUrlByTabId = new Map()
+ const styleDependencyIdsByModuleTabId = new Map()
+ const styleDependencyResolutionInProgress = new Set()
+
+ const collectStyleDependencyIdsForTabId = tabId => {
+ if (styleDependencyIdsByModuleTabId.has(tabId)) {
+ return styleDependencyIdsByModuleTabId.get(tabId)
+ }
+
+ if (styleDependencyResolutionInProgress.has(tabId)) {
+ return []
+ }
+
+ const tab = byId.get(tabId)
+ if (!tab || isStyleTab(tab)) {
+ styleDependencyIdsByModuleTabId.set(tabId, [])
+ return []
+ }
+
+ styleDependencyResolutionInProgress.add(tabId)
+
+ const importerModuleKey = toTabModuleKey(tab)
+ const imports = getParsedImportsForTab(tab)
+ const collectedStyleIds = []
+
+ for (const entry of imports) {
+ if (!isRelativeSpecifier(entry?.source) && !isStyleImportSpecifier(entry?.source)) {
+ continue
+ }
+
+ const target = resolveWorkspaceImport({
+ importerModuleKey,
+ source: entry.source,
+ byModuleKey,
+ })
+
+ if (!target || typeof target.id !== 'string') {
+ continue
+ }
+
+ if (isStyleTab(target)) {
+ collectedStyleIds.push(target.id)
+ continue
+ }
+
+ const nestedStyleIds = collectStyleDependencyIdsForTabId(target.id)
+ collectedStyleIds.push(...nestedStyleIds)
+ }
+
+ styleDependencyResolutionInProgress.delete(tabId)
+
+ const uniqueStyleIds = [...new Set(collectedStyleIds)]
+ styleDependencyIdsByModuleTabId.set(tabId, uniqueStyleIds)
+ return uniqueStyleIds
+ }
+
+ const toCssTextForStyleIds = styleIds => {
+ if (!Array.isArray(styleIds) || styleIds.length === 0) {
+ return ''
+ }
+
+ return styleIds
+ .map(styleId => getOwnRecordValue(styleCssByTabId, styleId) ?? '')
+ .filter(part => typeof part === 'string' && part.length > 0)
+ .join('\n\n')
+ }
+
for (const tabId of moduleDependencyOrder) {
const tab = byId.get(tabId)
if (!tab) {
@@ -723,10 +894,7 @@ export const planWorkspaceVirtualModules = ({
}
const moduleKey = toTabModuleKey(tab) || tab.id
- const styleModuleExports =
- styleModuleExportsByTabId && typeof styleModuleExportsByTabId === 'object'
- ? styleModuleExportsByTabId[tabId]
- : {}
+ const styleModuleExports = getOwnRecordValue(styleModuleExportsByTabId, tabId) ?? {}
const moduleCacheKey = [
'style-module',
moduleKey,
@@ -767,6 +935,70 @@ export const planWorkspaceVirtualModules = ({
source: moduleData.source,
imports: importsForRewrite,
resolveSpecifier: sourceSpecifier => {
+ const queryFlags = parseSpecifierQueryFlags(sourceSpecifier)
+
+ if (
+ queryFlags.hasKnightedCss &&
+ (isRelativeSpecifier(sourceSpecifier) ||
+ isStyleImportSpecifier(sourceSpecifier))
+ ) {
+ const target = resolveWorkspaceImport({
+ importerModuleKey: moduleData.moduleKey,
+ source: sourceSpecifier,
+ byModuleKey,
+ })
+
+ if (!target || typeof target.id !== 'string') {
+ return null
+ }
+
+ const isStyleTarget = isStyleTab(target)
+ const targetStyleModuleUrl = isStyleTarget
+ ? (styleModuleUrlByTabId.get(target.id) ?? null)
+ : null
+ const targetModuleUrl = isStyleTarget
+ ? null
+ : (moduleUrlByTabId.get(target.id) ?? null)
+
+ const queryCssText = isStyleTarget
+ ? toCssTextForStyleIds([target.id])
+ : toCssTextForStyleIds(collectStyleDependencyIdsForTabId(target.id))
+
+ const queryModuleKey = `${moduleData.moduleKey || tab.id}->${toTabModuleKey(target) || target.id}`
+ const queryModuleCacheKey = [
+ 'knighted-css-query',
+ queryModuleKey,
+ queryFlags.isCombined ? 'combined' : 'plain',
+ targetModuleUrl ?? '',
+ targetStyleModuleUrl ?? '',
+ queryCssText,
+ ].join('\u0000')
+
+ const cachedQueryModuleUrl = getCachedValue(
+ moduleDataUrlCache,
+ queryModuleCacheKey,
+ )
+ if (typeof cachedQueryModuleUrl === 'string') {
+ return cachedQueryModuleUrl
+ }
+
+ const queryModuleUrl = toKnightedCssQueryDataUrl({
+ moduleKey: queryModuleKey,
+ targetModuleUrl,
+ targetStyleModuleUrl,
+ cssText: queryCssText,
+ isCombined: queryFlags.isCombined,
+ })
+
+ if (typeof queryModuleUrl !== 'string' || queryModuleUrl.length === 0) {
+ return null
+ }
+
+ moduleDataUrlCache.set(queryModuleCacheKey, queryModuleUrl)
+ trimCache(moduleDataUrlCache, maxModuleDataUrlCacheEntries)
+ return queryModuleUrl
+ }
+
if (
isRelativeSpecifier(sourceSpecifier) ||
isStyleImportSpecifier(sourceSpecifier)
diff --git a/src/modules/preview/render-runtime.js b/src/modules/preview/render-runtime.js
index da18f79..ea6f8e6 100644
--- a/src/modules/preview/render-runtime.js
+++ b/src/modules/preview/render-runtime.js
@@ -381,7 +381,12 @@ export const createRenderRuntimeController = ({
if (!entryTab) {
clearStyleDiagnostics()
- return { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} }
+ return {
+ css: '',
+ userStyleSheets: [],
+ styleModuleExportsByTabId: {},
+ styleCssByTabId: {},
+ }
}
const runtimeSpecifiers = getWorkspaceRuntimeSpecifiers()
@@ -397,7 +402,12 @@ export const createRenderRuntimeController = ({
if (!virtualModulePlan) {
clearStyleDiagnostics()
- return { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} }
+ return {
+ css: '',
+ userStyleSheets: [],
+ styleModuleExportsByTabId: {},
+ styleCssByTabId: {},
+ }
}
const workspaceTabById = new Map(
@@ -451,7 +461,12 @@ export const createRenderRuntimeController = ({
if (styleInputs.length === 0) {
clearStyleDiagnostics()
- const output = { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} }
+ const output = {
+ css: '',
+ userStyleSheets: [],
+ styleModuleExportsByTabId: {},
+ styleCssByTabId: {},
+ }
compiledStylesCache = {
key: cacheKey,
value: output,
@@ -525,7 +540,8 @@ export const createRenderRuntimeController = ({
}),
)
- const styleModuleExportsByTabId = {}
+ const styleModuleExportsByTabId = Object.create(null)
+ const styleCssByTabId = Object.create(null)
const compiledCssParts = []
const userStyleSheets = []
@@ -536,13 +552,14 @@ export const createRenderRuntimeController = ({
if (part && typeof part.css === 'string') {
compiledCssParts.push(part.css)
userStyleSheets.push(part.css)
+ styleCssByTabId[input.id] = part.css
}
if (input?.dialect !== 'module' || !part?.moduleExports) {
continue
}
- const normalizedModuleExports = {}
+ const normalizedModuleExports = Object.create(null)
for (const [localClassName, exportedValue] of Object.entries(
part.moduleExports,
)) {
@@ -565,6 +582,7 @@ export const createRenderRuntimeController = ({
css: compiledCssParts.join('\n\n'),
userStyleSheets,
styleModuleExportsByTabId,
+ styleCssByTabId,
}
if (styleWarningLines.length > 0) {
setStyleDiagnosticsDetails({
@@ -680,6 +698,7 @@ export const createRenderRuntimeController = ({
cssText,
userStyleSheets = [],
styleModuleExportsByTabId = {},
+ styleCssByTabId = {},
}) => {
const workspaceTabs = getWorkspaceTabsForPreview()
const entryTab = resolveWorkspaceEntryTab(workspaceTabs)
@@ -718,6 +737,7 @@ export const createRenderRuntimeController = ({
mode,
runtimeSpecifiers,
styleModuleExportsByTabId,
+ styleCssByTabId,
})
if (!virtualModulePlan) {
@@ -790,6 +810,7 @@ export const createRenderRuntimeController = ({
cssText: compiledStyles.css,
userStyleSheets: compiledStyles.userStyleSheets,
styleModuleExportsByTabId: compiledStyles.styleModuleExportsByTabId,
+ styleCssByTabId: compiledStyles.styleCssByTabId,
})
}
@@ -809,6 +830,7 @@ export const createRenderRuntimeController = ({
cssText: compiledStyles.css,
userStyleSheets: compiledStyles.userStyleSheets,
styleModuleExportsByTabId: compiledStyles.styleModuleExportsByTabId,
+ styleCssByTabId: compiledStyles.styleCssByTabId,
})
}