From 9c3b3fc5b1fe57ed7f5febcf32f8d373e4c7b09d Mon Sep 17 00:00:00 2001 From: Klink <85062+dogmar@users.noreply.github.com> Date: Sat, 2 May 2026 16:48:20 -0700 Subject: [PATCH 1/3] Move `marker` into `styled-system/css`, accept Panda condition values (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-import surface: `marker`, `css`, and `cx` all come from `styled-system/css` — the standalone `bearbones` runtime package is deleted. `@bearbones/vite`'s codegen-patch now injects the project-local `marker` declaration plus its `BearbonesMarker` / `BearbonesMarkerBuilder` interfaces into the patched `css.d.ts`, and appends a runtime stub to `css.mjs` that throws if executed unrewritten. Marker chains now accept any Panda condition value verbatim. The composition substitutes every `&` in the input with the marker's anchor selector and wraps in the relation (`M &` / `&:has(M)` / `& ~ M, M ~ &`); a missing `&` fails the build. Property form `m._` looks up `` in the new conditions stash (seeded from preset-base, replaced with resolved `config.conditions` at `config:resolved`), so users get autocomplete and runtime selectors for every registered condition — preset defaults plus their own extensions. Relation result types are concrete-literal templates parameterized over `Id` and `Cond` rather than wide `${string}` patterns. That avoids the TypeScript collapse where computed keys typed as wide template literals get promoted to a string-index signature on the enclosing object, which broke any `css({...})` call mixing a marker computed-key with a sibling CSS property. Mixed objects now type-check; the runtime transform substitutes the real hashed selector at parser:before time. Also: exclude `__type-tests__/**` from Panda extraction so the type-only test file doesn't leak classes into the production stylesheet. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/package.json | 1 - apps/website/panda.config.ts | 5 +- apps/website/src/Demo.tsx | 60 +++-- apps/website/src/__type-tests__/css-typing.ts | 34 ++- apps/website/src/markers.ts | 2 +- package.json | 2 +- packages/bearbones-vite/src/codegen-patch.ts | 231 +++++++++++------- .../bearbones-vite/src/conditions-stash.ts | 102 ++++++++ packages/bearbones-vite/src/index.ts | 75 ++++-- .../bearbones-vite/src/marker-registry.ts | 104 +++----- packages/bearbones-vite/src/transform.ts | 195 ++++++--------- .../__snapshots__/codegen-patch.test.ts.snap | 20 ++ .../tests/codegen-patch.test.ts | 89 +++++-- .../tests/marker-registry.test.ts | 30 ++- .../bearbones-vite/tests/transform.test.ts | 96 +++++--- packages/bearbones/package.json | 27 -- packages/bearbones/src/index.ts | 130 ---------- packages/bearbones/tsconfig.json | 20 -- packages/bearbones/vite.config.ts | 7 - pnpm-lock.yaml | 19 -- 20 files changed, 649 insertions(+), 600 deletions(-) create mode 100644 packages/bearbones-vite/src/conditions-stash.ts delete mode 100644 packages/bearbones/package.json delete mode 100644 packages/bearbones/src/index.ts delete mode 100644 packages/bearbones/tsconfig.json delete mode 100644 packages/bearbones/vite.config.ts diff --git a/apps/website/package.json b/apps/website/package.json index 92bb2e1..13fadcc 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -17,7 +17,6 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", - "bearbones": "workspace:*", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/apps/website/panda.config.ts b/apps/website/panda.config.ts index 681e806..4d9777f 100644 --- a/apps/website/panda.config.ts +++ b/apps/website/panda.config.ts @@ -5,7 +5,10 @@ import { bearbonesHooks } from "@bearbones/vite"; export default defineConfig({ preflight: true, include: ["./src/**/*.{ts,tsx}"], - exclude: [], + // Type-only tests author dummy `css()` calls solely to exercise the + // augmented `css()` typing surface — they're never executed and shouldn't + // contribute classes to the production stylesheet. Skip them at extraction. + exclude: ["./src/__type-tests__/**/*"], outdir: "styled-system", jsxFramework: "react", // We don't list presets explicitly because Panda's defaults diff --git a/apps/website/src/Demo.tsx b/apps/website/src/Demo.tsx index 9211779..9ef5a15 100644 --- a/apps/website/src/Demo.tsx +++ b/apps/website/src/Demo.tsx @@ -6,9 +6,8 @@ // The bearbones `codegen:prepare` hook patches Panda's emitted `css.d.ts` so // `css()` accepts utility strings + marker condition keys natively — no cast // needed at the call site. -import { css } from "../styled-system/css"; -import { cx } from "bearbones"; -import { cardMarker, rowMarker } from "./markers.ts"; +import { css, cx } from "../styled-system/css"; +import { cardMarker, innerMarker, rowMarker } from "./markers.ts"; export function Demo() { return ( @@ -78,22 +77,53 @@ export function Demo() { condition keys at build time; prescan registers the (modifier, relation) pairs so Panda's extractor emits matching CSS. */} -
-

` against a known state. - [cardMarker._focusVisible.is.descendant]: "text-blue-500", - })} + [innerMarker._focusVisible.is.descendant]: "bg-blue-500", + }), + )} + > + +

*").is.descendant]: "text-red-500", + // ['&:has(.flag-error)']: "text-red-500", + borderWidth: 1, + }), + )} > - Tab here — when this paragraph receives :focus-visible (descendant relation), the - card-marker ancestor's text turns blue. + Mixing a marker computed-key with a sibling CSS property in one object now type-checks — + relation types are concrete literal templates, so TS keeps the computed key as a named + property instead of widening to a string index signature.

Adding `.flag-error` anywhere inside the card flips this line red via `:has(...)`.

diff --git a/apps/website/src/__type-tests__/css-typing.ts b/apps/website/src/__type-tests__/css-typing.ts index dc56f70..97e7641 100644 --- a/apps/website/src/__type-tests__/css-typing.ts +++ b/apps/website/src/__type-tests__/css-typing.ts @@ -41,15 +41,37 @@ css({ "&:focus-within": "p-4" }); // Array of utility strings under an arbitrary nested selector. css({ "&:focus-within": ["p-4", "bg-blue-500"] }); -// Marker call form: arbitrary CSS-fragment modifier with explicit relation. -css({ [cardMarker(":has(.flag-error)").is.ancestor]: "text-red-500" }); - -// Marker underscore form: typed shortcut against a known pseudo-state with -// explicit relation. +// Marker call form: arbitrary Panda condition value with explicit relation. +// `&` is mandatory and is substituted with the marker's anchor selector. +css({ [cardMarker("&:has(.flag-error)").is.ancestor]: "text-red-500" }); + +// Marker underscore form: typed shortcut against a registered Panda +// condition with explicit relation. The condition value comes from +// `panda.config.conditions` (with preset-base defaults plus user +// extensions) and is substituted into the relation at lower-time. css({ [cardMarker._focusVisible.is.descendant]: "text-blue-500" }); // Sibling relation works too. -css({ [cardMarker(":focus-within").is.sibling]: "text-gray-700" }); +css({ [cardMarker("&:focus-within").is.sibling]: "text-gray-700" }); + +// Parent-nesting condition value — marker is descendant of state-bearing element. +css({ [cardMarker("[data-state=open] &").is.descendant]: "text-blue-500" }); + +// Mixing marker computed-keys with literal CSS properties in one object +// works: relation types are concrete literal templates parameterized on +// Id + Cond (no `${string}` widening), so TS preserves them as named +// property keys instead of collapsing to a string index signature. +css({ + [cardMarker("& > *").is.descendant]: "text-red-500", + borderWidth: 1, +}); + +// Underscore form mixed with sibling property — same property keeps literal +// inference; TypeScript treats the marker chain key like any other property. +css({ + [cardMarker._hover.is.ancestor]: "text-blue-500", + padding: "4", +}); // --- Rejected forms ----------------------------------------------------- diff --git a/apps/website/src/markers.ts b/apps/website/src/markers.ts index 8e6e92c..2494c1d 100644 --- a/apps/website/src/markers.ts +++ b/apps/website/src/markers.ts @@ -1,4 +1,4 @@ -import { marker } from "bearbones"; +import { marker } from "../styled-system/css"; // Module-level marker declarations. The `@bearbones/vite` parser:before hook // rewrites each `marker(...)` call to a synthesized object literal at build diff --git a/package.json b/package.json index 7163308..2be2cd9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "ready": "vp check && vp run -r test && vp run -r build", "dev": "vp run website#dev", - "check": "vp run --filter '@bearbones/*' --filter bearbones build && vp run --filter website codegen && vp check && vp run -r test", + "check": "vp run --filter '@bearbones/*' build && vp run --filter website codegen && vp check && vp run -r test", "fix": "vp fmt", "prepare": "vp config" }, diff --git a/packages/bearbones-vite/src/codegen-patch.ts b/packages/bearbones-vite/src/codegen-patch.ts index bec76f8..93a5ec5 100644 --- a/packages/bearbones-vite/src/codegen-patch.ts +++ b/packages/bearbones-vite/src/codegen-patch.ts @@ -1,52 +1,58 @@ /** - * Patch Panda's emitted `styled-system/css/css.d.ts` so the `css()` function - * accepts bearbones utility strings in addition to Panda's standard - * SystemStyleObject input. + * Patch Panda's emitted `styled-system/css/` artifacts so the project-local + * `css()` import is the single source of truth for bearbones primitives: * - * Wired into Panda's `codegen:prepare` hook in `index.ts`. Runs once per - * codegen pass, after `config:resolved` (so every `group()` declaration is - * already registered as a Panda condition) and immediately before Panda - * writes any artifact to disk. + * - `css.d.ts` — widens the `css()` signature to accept bearbones utility + * strings, and injects the project-local `marker()` declaration plus its + * `BearbonesMarker` / `BearbonesMarkerBuilder` interfaces. The + * `_` shortcut per registered Panda condition is enumerated from + * the conditions stash so users get autocomplete for their full + * vocabulary, including any extensions. + * + * - `css.mjs` — injects a runtime stub for `marker()` that throws with a + * diagnostic message if it ever runs unrewritten. Panda already ships + * `cx` in the same artifact (re-exported from `styled-system/css/cx`), + * so users get all four primitives — `css`, `cva`/`sva` (via recipes), + * `cx`, `marker` — from one import path. * - * The patch is purely a type-level change. The runtime `css()` function in - * `css.mjs` is untouched — Panda's implementation already accepts arbitrary - * input shapes; the missing piece was the static type surface telling the - * compiler those shapes are valid. + * Wired into Panda's `codegen:prepare` hook in `index.ts`. Runs once per + * codegen pass, after `config:resolved` (so the conditions stash is + * populated) and immediately before Panda writes any artifact to disk. * * Strategy: locate Panda's `type Styles = ...` line by exact-match anchor and * rewrite it to point at a widened type tree (`BearbonesSystemStyleObject`) * defined inline above. The widened tree mirrors Panda's own `Nested

` - * structure so that: - * - The whole tree may also be a `BearbonesUtilityName` (utility-string leaf). - * - Every condition / selector value position also accepts a utility string - * or an array of styles (matching the lowering transform's runtime contract). - * - CSS property values (`P` in Panda's recursion) remain strict — utility - * strings are *not* accepted as values for `padding`, `color`, etc. - * - * If Panda's emitted format ever changes such that the anchor isn't found, - * the patch throws a recognizable error rather than silently producing wrong - * types. The build fails loudly and the diagnosis is one Panda changelog - * read away. + * structure. If Panda's emitted format ever changes such that the anchor + * isn't found, the patch throws a recognizable error rather than silently + * producing wrong types. */ +import { listConditionsWithAnchor } from "./conditions-stash.ts"; import { listUtilities } from "./utility-map.ts"; /** - * The anchor we replace in Panda's emitted `css.d.ts`. Captured verbatim from - * `apps/website/styled-system/css/css.d.ts` after a real `panda codegen` run. - * If Panda renames `Styles` or restructures the file, this anchor stops - * matching and `patchCssArtifact` throws. + * The anchor we replace in Panda's emitted `css.d.ts`. */ const STYLES_ANCHOR = "type Styles = SystemStyleObject | undefined | null | false"; /** - * Patch the source of a single `styled-system/css/css.d.ts` file. Returns the - * patched source string. Pure function — no I/O, no side effects. + * Sentinel comment we drop into the runtime artifact so a re-patch (e.g. on + * Panda's watch-mode codegen) doesn't append a second copy of the marker + * stub. + */ +const RUNTIME_PATCH_SENTINEL = "/* @bearbones/vite: marker stub */"; + +/** + * Patch the source of `styled-system/css/css.d.ts`. Returns the patched + * source string. Pure function — no I/O, no side effects. * - * Throws if the source doesn't contain the expected anchor. The thrown error - * names the missing anchor explicitly so the failure is self-diagnosing. + * Throws if the source doesn't contain the expected anchor. */ -export function patchCssArtifact(source: string, utilityNames: readonly string[]): string { +export function patchCssArtifact( + source: string, + utilityNames: readonly string[], + conditionNames: readonly string[], +): string { if (!source.includes(STYLES_ANCHOR)) { throw new Error( `@bearbones/vite codegen-patch: expected anchor not found in css.d.ts.\n` + @@ -59,17 +65,10 @@ export function patchCssArtifact(source: string, utilityNames: readonly string[] const utilityUnion = renderUtilityUnion(utilityNames); const injectedTypes = renderInjectedTypes(utilityUnion); + const markerTypes = renderMarkerTypes(conditionNames); const patchedStyles = "type Styles = BearbonesSystemStyleObject | undefined | null | false"; - // Insert the injected types immediately after the existing `import` line so - // they're declared before the `Styles` alias references them. The types pull - // in `Nested`, `Selectors`, `AnySelector`, `Conditions`, `SystemProperties`, - // and `CssVarProperties` from sibling artifact files inside `../types/`. const importBlock = renderImportBlock(); - - // Place injected imports + types directly after Panda's existing - // `import type { SystemStyleObject } ...` line so we don't fight Panda's - // own header ordering. const pandaImportMarker = "import type { SystemStyleObject } from '../types/index';"; if (!source.includes(pandaImportMarker)) { throw new Error( @@ -80,29 +79,50 @@ export function patchCssArtifact(source: string, utilityNames: readonly string[] ); } - // No marker-registry augmentation. The chain compiles to raw selectors - // anchored at `bearbones-marker-`, and the wide template-literal - // types in `DefaultBearbonesMarker` (in the bearbones package) match - // Panda's `AnySelector` — so consumers' `[m._hover.is.ancestor]` keys - // typecheck via `BearbonesNestedObject

`'s existing `[K in AnySelector]` - // branch with no per-marker codegen here. return source .replace(pandaImportMarker, `${pandaImportMarker}\n${importBlock}\n${injectedTypes}`) - .replace(STYLES_ANCHOR, patchedStyles); + .replace(STYLES_ANCHOR, `${patchedStyles}\n\n${markerTypes}`); +} + +/** + * Patch `styled-system/css/css.mjs` to add the `marker()` runtime stub. The + * stub throws if it ever executes — when the bearbones transform has run on + * a file, every `marker('id')` call site has been rewritten to a synthesized + * literal record, so the runtime stub only fires as a misconfiguration alarm. + * + * Idempotent: if the stub is already present (sentinel comment match), we + * return the input unchanged. Panda's watch-mode regenerates `css.mjs` + * fresh on each pass so this idempotency is belt-and-suspenders only. + */ +export function patchCssRuntime(source: string): string { + if (source.includes(RUNTIME_PATCH_SENTINEL)) return source; + const stub = [ + "", + RUNTIME_PATCH_SENTINEL, + "export function marker(_id) {", + " throw new Error(", + ' "bearbones: marker() was called at runtime. " +', + ' "This usually means the @bearbones/vite transform did not run before this module. " +', + " \"Verify Panda's hooks include bearbonesHooks() and that the file imports `marker` from 'styled-system/css'.\"", + " );", + "}", + "", + ].join("\n"); + return source.endsWith("\n") ? source + stub : source + "\n" + stub; } /** - * Convenience wrapper that patches against the live utility list. Used by - * the `codegen:prepare` hook in production; tests pass fixed inputs to keep - * snapshots stable. + * Convenience wrapper that patches against the live utility list and + * conditions stash. Used by the `codegen:prepare` hook in production; tests + * pass fixed inputs to keep snapshots stable. */ export function patchCssArtifactLive(source: string): string { - return patchCssArtifact(source, listUtilities()); + const conditionNames = listConditionsWithAnchor().map(({ name }) => name); + return patchCssArtifact(source, listUtilities(), conditionNames); } function renderUtilityUnion(names: readonly string[]): string { if (names.length === 0) return "never"; - // One name per line for readability inside the generated file. return names.map((n) => ` | ${JSON.stringify(n)}`).join("\n"); } @@ -115,22 +135,6 @@ function renderImportBlock(): string { } function renderInjectedTypes(utilityUnion: string): string { - // The recursion shape mirrors Panda's `Nested

`. The two material - // additions are the `BearbonesUtilityName` leaf (whole tree may also be a - // utility string) and `readonly BearbonesNested

[]` at every condition / - // selector value position (matching the transform's runtime acceptance of - // arrays of utility strings). - // - // BearbonesNestedObject is intentionally factored out from BearbonesNested - // so that `Omit<..., 'base'>` (used to define BearbonesSystemStyleObject — - // mirroring Panda's own SystemStyleObject) wraps ONLY the object branch. - // Distributing `Omit` over `BearbonesUtilityName | ObjectType` widens the - // string-literal union to a structural string type and the closed-set - // checking would silently break. - // No `BearbonesMarkerConditionKey` mapped-type slot. Relational marker keys - // are raw CSS selectors — they're accepted as computed keys via Panda's - // existing `[K in AnySelector]` index in `BearbonesNestedObject

`, so - // there's nothing extra to inject here for them. return [ "export type BearbonesUtilityName =", utilityUnion, @@ -154,12 +158,73 @@ function renderInjectedTypes(utilityUnion: string): string { } /** - * Patch a Panda `Artifact[]` array in-place by finding the `css-fn` artifact's - * `css.d.ts` file and rewriting its `code`. Used by the `codegen:prepare` hook. + * Project-local `marker` declaration + supporting interfaces. + * + * The `_` shortcuts are enumerated from the conditions stash so the + * surface tracks the host project's full condition vocabulary, including + * presets it pulls in and any user `extend` entries. Conditions whose value + * lacks the `&` placeholder are omitted at the stash level (see + * `conditions-stash.ts`); the type emit only sees keys that compose into a + * relational marker query. * - * No-op (returns the input unchanged) if the artifact isn't present. Some - * Panda invocations only regenerate a subset of artifacts; the type patch - * only matters when the css.d.ts itself is being written. + * Relation result types are *concrete literal* template strings parameterized + * over `Id` and the condition name `Cond` — never just `${string}` widening. + * That matters because TypeScript collapses computed property keys whose type + * is a wide template-literal type (`${string}&`) into a string-index signature + * on the enclosing object, which then conflicts with Panda's narrow indexes + * the moment a sibling literal property (`borderWidth: 1`, etc.) is added. + * + * Concrete literals (`_bbm_${Id}_${Cond}_a &`) sidestep the collapse: TS + * keeps them as named property keys when used as computed keys, so mixing + * marker rules with sibling CSS properties in one `css({...})` argument + * type-checks. The concrete strings still satisfy `AnySelector` (each ends + * with ` &` or starts with `&`), so they're accepted as keys in + * `BearbonesNestedObject

` via the `[K in AnySelector]?` index. The runtime + * transform substitutes the real raw selector at parser:before time — TS + * never sees the runtime string, only this phantom placeholder. + */ +function renderMarkerTypes(conditionNames: readonly string[]): string { + const shortcutLines = conditionNames.map( + (name) => + ` readonly ${quoteIdentifierIfNeeded(`_${name}`)}: BearbonesMarkerBuilder;`, + ); + return [ + "export interface BearbonesMarkerBuilder {", + " readonly is: {", + " readonly ancestor: `_bbm_${Id}_${Cond}_a &`;", + " readonly descendant: `&_bbm_${Id}_${Cond}_d`;", + " readonly sibling: `&_bbm_${Id}_${Cond}_s`;", + " };", + "}", + "", + "export interface BearbonesMarker {", + " readonly anchor: `bearbones-marker-${Id}_${string}`;", + ...shortcutLines, + // Call form: `Cond` is inferred from the literal arg so each distinct + // call site gets a distinct phantom literal. Non-literal args widen to + // `string` and collapse — that aligns with the runtime contract since + // the lowering transform only resolves literal arguments anyway. + " (condValue: C): BearbonesMarkerBuilder;", + "}", + "", + "export declare function marker(id: Id): BearbonesMarker;", + "", + ].join("\n"); +} + +/** + * Property-key syntax helper. JS identifiers don't allow `-` or other punctuation, + * so condition names like `my-cond` produce property keys that need quoting. + */ +function quoteIdentifierIfNeeded(name: string): string { + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) return name; + return JSON.stringify(name); +} + +/** + * Patch a Panda `Artifact[]` array in-place by finding the `css-fn` artifact's + * `css.d.ts` and `css.mjs` files and rewriting their `code`. Used by the + * `codegen:prepare` hook. */ export function patchArtifacts(artifacts: PandaArtifact[]): PandaArtifact[] { return artifacts.map((artifact) => { @@ -167,27 +232,19 @@ export function patchArtifacts(artifacts: PandaArtifact[]): PandaArtifact[] { return { ...artifact, files: artifact.files.map((file) => { - if (file.file !== "css.d.ts") return file; if (file.code === undefined) return file; - return { ...file, code: patchCssArtifactLive(file.code) }; + if (file.file === "css.d.ts") { + return { ...file, code: patchCssArtifactLive(file.code) }; + } + if (file.file === "css.mjs") { + return { ...file, code: patchCssRuntime(file.code) }; + } + return file; }), }; }); } -/** - * Local mirror of Panda's `Artifact` shape and `ArtifactId` union, copied - * verbatim from `@pandacss/types/dist/artifact`. We mirror rather than - * import because `@pandacss/types/index` transitively re-exports through - * `config.d.ts` which imports `pkg-types`, which in turn fails to resolve - * a `CompilerOptions` export against the `typescript` version this project - * pins. Mirroring keeps types accurate at the hook boundary without - * dragging unrelated transitive deps into rolldown's bundle pass. - * - * If Panda renames or restructures the artifact shape, the type-check at - * the hook signature in `index.ts` (which uses these types) catches the - * drift; bump the mirror to match. - */ export type PandaArtifactId = | "helpers" | "keyframes" diff --git a/packages/bearbones-vite/src/conditions-stash.ts b/packages/bearbones-vite/src/conditions-stash.ts new file mode 100644 index 0000000..91758c4 --- /dev/null +++ b/packages/bearbones-vite/src/conditions-stash.ts @@ -0,0 +1,102 @@ +import { preset as pandaPreset } from "@pandacss/preset-base"; + +/** + * Module-scoped stash of the host project's resolved Panda condition values. + * + * Populated from `config.conditions` in the `config:resolved` hook (see + * `index.ts`). Until that hook fires we fall back to the conditions shipped by + * `@pandacss/preset-base` so unit tests that bypass the hook pipeline still + * see the standard `_hover`/`_focus`/etc. vocabulary. + * + * Values are stored under keys with any leading `_` stripped, so users can + * write `m._hover` regardless of whether the source preset declared the + * condition as `hover` (preset-base style) or `_hover` (typical user style). + * + * Only string condition values are kept — at-rule conditions and structured + * tokens are out of scope for relational marker chains, which require an `&` + * placeholder to compose against the marker's anchor class. + */ + +let CONDITIONS: Record = loadFallbackConditions(); + +/** + * Strip a single leading `_` from a condition key so user-written `_dark`, + * preset-base `hover`, and codegen-emitted `_focusVisible` all hash to the + * same lookup name. + */ +export function normalizeConditionName(key: string): string { + return key.startsWith("_") ? key.slice(1) : key; +} + +function loadFallbackConditions(): Record { + const out: Record = {}; + const conditions = (pandaPreset as { conditions?: Record }).conditions; + if (!conditions || typeof conditions !== "object") return out; + for (const [key, value] of Object.entries(conditions)) { + if (typeof value !== "string") continue; + out[normalizeConditionName(key)] = value; + } + return out; +} + +/** + * Replace the stash with the host project's resolved conditions. Called once + * per `config:resolved` from the bearbones hook. + */ +export function setConditions(conditions: Record): void { + const next: Record = {}; + for (const [key, value] of Object.entries(conditions)) { + if (typeof value !== "string") continue; + next[normalizeConditionName(key)] = value; + } + CONDITIONS = next; +} + +/** + * Look up a condition value by normalized name (no leading `_`). Returns + * `undefined` if the condition isn't registered. + */ +export function getCondition(name: string): string | undefined { + return CONDITIONS[normalizeConditionName(name)]; +} + +/** + * Snapshot of every condition name with a string value. Consumed by + * `codegen-patch.ts` to enumerate `_` shortcuts on the project-local + * `BearbonesMarker` interface. + * + * Filtered to entries whose value contains the `&` placeholder — bare + * at-rule conditions (`@media (...)`, `@container (...)`) can't compose into + * a relational marker query and are skipped from the type surface. + */ +export function listConditionsWithAnchor(): readonly { name: string; value: string }[] { + const out: { name: string; value: string }[] = []; + for (const [name, value] of Object.entries(CONDITIONS)) { + if (!value.includes("&")) continue; + out.push({ name, value }); + } + return out; +} + +/** + * Serialize the stash for cross-process hand-off (Panda extraction process → + * Vite dev-server process). Mirrors `serializeUtilityMap` in `utility-map.ts`. + */ +export function serializeConditions(): string { + return JSON.stringify(CONDITIONS); +} + +export function hydrateConditions(json: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return; + } + if (!parsed || typeof parsed !== "object") return; + const next: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string") next[key] = value; + } + CONDITIONS = next; +} diff --git a/packages/bearbones-vite/src/index.ts b/packages/bearbones-vite/src/index.ts index 8321414..d2b68e0 100644 --- a/packages/bearbones-vite/src/index.ts +++ b/packages/bearbones-vite/src/index.ts @@ -45,49 +45,56 @@ import { populateUtilityMapFromTokens, serializeUtilityMap, } from "./utility-map.ts"; +import { hydrateConditions, serializeConditions, setConditions } from "./conditions-stash.ts"; /** - * Cross-process hand-off path for the populated utility map. + * Cross-process hand-off paths. * * Panda's extraction runs in one process (`panda --watch`); the dev-server * lowering runs in another (`vp dev`). They don't share memory, so the - * Panda-side `config:resolved` hook serializes the populated map to this - * file and the Vite-side `configResolved` hook hydrates from it. Path is - * relative to the project's cwd — both processes have the same cwd in any - * normal `vp dev` flow. + * Panda-side `config:resolved` hook serializes the populated maps to these + * files and the Vite-side `configResolved` hook hydrates from them. */ const UTILITY_MAP_CACHE_REL_PATH = "node_modules/.cache/bearbones/utility-map.json"; +const CONDITIONS_CACHE_REL_PATH = "node_modules/.cache/bearbones/conditions.json"; -function utilityMapCachePath(cwd: string): string { - return resolvePath(cwd, UTILITY_MAP_CACHE_REL_PATH); +function cachePath(cwd: string, rel: string): string { + return resolvePath(cwd, rel); } -function writeUtilityMapCache(cwd: string): void { - const path = utilityMapCachePath(cwd); +function writeCache(cwd: string, rel: string, contents: string): void { + const path = cachePath(cwd, rel); try { mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, serializeUtilityMap(), "utf8"); + writeFileSync(path, contents, "utf8"); } catch { // Best-effort: a write failure here just means the Vite plugin won't - // see the map and runtime classNames will be wrong. Surfacing as a - // hard error blocks the entire build for what's a dev-mode optimization, - // so we swallow and let downstream symptoms be the signal. + // see the map and runtime behavior will degrade. Surfacing as a hard + // error blocks the entire build for what's a dev-mode optimization, so + // we swallow and let downstream symptoms be the signal. } } -function readUtilityMapCache(cwd: string): void { - const path = utilityMapCachePath(cwd); +function readCache(cwd: string, rel: string, hydrate: (json: string) => void): void { + const path = cachePath(cwd, rel); try { const json = readFileSync(path, "utf8"); - hydrateUtilityMap(json); + hydrate(json); } catch { - // Cache absent on first dev-server start before Panda has run, or in a - // production-only build that didn't go through Panda at all. The - // transform will pass utility strings through unchanged until Panda - // populates the cache. + // Cache absent on first dev-server start before Panda has run. } } +function writeBuildCaches(cwd: string): void { + writeCache(cwd, UTILITY_MAP_CACHE_REL_PATH, serializeUtilityMap()); + writeCache(cwd, CONDITIONS_CACHE_REL_PATH, serializeConditions()); +} + +function readBuildCaches(cwd: string): void { + readCache(cwd, UTILITY_MAP_CACHE_REL_PATH, hydrateUtilityMap); + readCache(cwd, CONDITIONS_CACHE_REL_PATH, hydrateConditions); +} + export interface BearbonesHooksOptions { /** * If true (default), surface a verbose log line each time the parser:before @@ -124,11 +131,19 @@ export function bearbonesHooks(_options: BearbonesHooksOptions = {}) { // (`p-{spacing}`, `bg-{color-shade}`, `text-{fontSize}`, …) reflects // the actual tokens available in the project — no manual scale arrays. populateUtilityMapFromTokens(config.theme?.tokens); - // Write the populated map to the cross-process cache so the Vite - // plugin (running in a separate `vp dev` process) can hydrate from - // the same vocabulary. + // Stash the resolved Panda conditions. The transform's relational + // marker chains read property-form values (`m._hover`, `m._dark`, + // `m._myCustomCond`) from this stash, and the codegen-patch + // enumerates `_` shortcuts on `BearbonesMarker` from the + // same source. + if (config.conditions && typeof config.conditions === "object") { + setConditions(config.conditions as Record); + } + // Write populated maps to cross-process caches so the Vite plugin + // (running in a separate `vp dev` process) can hydrate from the same + // vocabulary. const cwd = (config.cwd as string | undefined) ?? process.cwd(); - writeUtilityMapCache(cwd); + writeBuildCaches(cwd); }, "parser:before": ({ filePath, @@ -186,7 +201,7 @@ export function bearbonesVitePlugin(_options: BearbonesVitePluginOptions = {}): enforce: "pre", configResolved(config: { root: string }) { cwd = config.root; - readUtilityMapCache(cwd); + readBuildCaches(cwd); hydrated = true; }, transform(code: string, id: string) { @@ -196,7 +211,7 @@ export function bearbonesVitePlugin(_options: BearbonesVitePluginOptions = {}): // `vp dev`'s `configResolved` ran, the cache file may not have // existed yet. Try once on first transform call. if (!hydrated) { - readUtilityMapCache(cwd); + readBuildCaches(cwd); hydrated = true; } const result = transform({ filePath: id, source: code }); @@ -213,9 +228,15 @@ export { populateUtilityMapFromTokens, serializeUtilityMap, } from "./utility-map.ts"; +export { + hydrateConditions, + serializeConditions, + setConditions, + listConditionsWithAnchor, +} from "./conditions-stash.ts"; export { transform } from "./transform.ts"; // Expose the codegen patch helpers for tests / advanced wiring. -export { patchCssArtifact, patchArtifacts } from "./codegen-patch.ts"; +export { patchCssArtifact, patchCssRuntime, patchArtifacts } from "./codegen-patch.ts"; export type { PandaArtifact, PandaArtifactFile } from "./codegen-patch.ts"; // NOTE: `BearbonesUtilityName` is no longer re-exported as a static type. diff --git a/packages/bearbones-vite/src/marker-registry.ts b/packages/bearbones-vite/src/marker-registry.ts index 2d9e940..3723ff6 100644 --- a/packages/bearbones-vite/src/marker-registry.ts +++ b/packages/bearbones-vite/src/marker-registry.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { preset as pandaPreset } from "@pandacss/preset-base"; /** * Pure helpers used by the lowering transform to compose marker anchors and @@ -8,10 +7,9 @@ import { preset as pandaPreset } from "@pandacss/preset-base"; * everything it needs (suffix, anchor class, raw selector) deterministically * from `(id, modulePath)` on demand. * - * That deterministic derivation is the whole reason this module exists. - * Anything that needs a stable build-time identity for `(id, modulePath)` - * — the synthesized record's anchor class, cross-file lookups in the - * transform — calls into here and gets the same answer every time. + * The condition vocabulary that drives the `_` property-form shortcuts + * lives in `conditions-stash.ts`, not here. This module only knows how to + * compose a raw selector once a condition value has been resolved. */ export interface MarkerDescriptor { @@ -27,58 +25,6 @@ export interface MarkerDescriptor { readonly anchorClass: string; } -/** - * Standard set of pseudo-states each marker exposes as a typed `_` - * builder shortcut on the synthesized record. The shortcut is equivalent to - * calling the marker with the matching `STATE_PSEUDO[state]` selector. - */ -export const MARKER_STATES = ["hover", "focus", "focusVisible", "active", "disabled"] as const; - -export type MarkerState = (typeof MARKER_STATES)[number]; - -/** - * Map a state name to the CSS pseudo-class that selects it on the anchor. - * - * Sourced live from `@pandacss/preset-base` so our `_` shortcut - * selectors match Panda's built-in `_hover` / `_focus` / etc. exactly. If - * Panda widens any of these (e.g. recently `disabled` gained `[disabled]` - * and `[aria-disabled=true]`), we pick that up automatically on the next - * Panda upgrade. The leading `&` placeholder Panda uses is stripped — we - * concatenate the result onto the anchor class instead. - */ -export const STATE_PSEUDO: Record = readPandaStatePseudos(); - -function readPandaStatePseudos(): Record { - const conditions = (pandaPreset as { conditions?: Record }).conditions; - if (!conditions || typeof conditions !== "object") { - throw new Error( - "bearbones: @pandacss/preset-base.preset.conditions is missing. " + - "If Panda restructured its preset shape, update marker-registry.ts to mirror.", - ); - } - const out = {} as Record; - for (const state of MARKER_STATES) { - const sel = conditions[state]; - if (typeof sel !== "string") { - throw new Error( - `bearbones: expected @pandacss/preset-base to define condition "${state}". ` + - "If Panda renamed or removed it, update MARKER_STATES in marker-registry.ts.", - ); - } - out[state] = stripAnchorPrefix(sel); - } - return out; -} - -/** - * Panda's preset stores conditions with a leading `&` selector — `&:is(...)`, - * `&:focus-visible`, etc. We concatenate onto the anchor class itself - * (`.bearbones-marker-`) so the leading `&` has to go. - */ -function stripAnchorPrefix(selector: string): string { - return selector.startsWith("&") ? selector.slice(1) : selector; -} - export type MarkerRelation = "ancestor" | "descendant" | "sibling"; export const MARKER_RELATIONS: readonly MarkerRelation[] = ["ancestor", "descendant", "sibling"]; @@ -111,36 +57,42 @@ export function describeMarker(id: string, modulePath: string): MarkerDescriptor } /** - * Compose the raw CSS selector for a `(modifier, relation)` pair anchored at - * `anchorClass`. Pure — same inputs, same output. Used by both the build-time - * lowering and the runtime helper baked into the synthesized marker record, - * so they produce identical strings. + * Compose the raw CSS selector for a `(condValue, relation)` pair anchored at + * `anchorClass`. Pure — same inputs, same output. + * + * `condValue` is a Panda condition value verbatim — the same string the user + * would put on the right side of a `conditions: { _foo: '' }` entry. + * Every `&` in the input is substituted with the marker's anchor selector + * (`.bearbones-marker-`); the result is then wrapped in the relation: * - * Selectors: - * ancestor — ` &` - * descendant — `&:has()` - * sibling — `& ~ , ~ &` + * ancestor — `M &` + * descendant — `&:has(M)` + * sibling — `& ~ M, M ~ &` * - * Sibling is comma-emitted starting with `&` so the resulting string matches - * Panda's `AnySelector` (`&${string}`) on the type side. Ordering of - * comma-joined selectors is irrelevant to emitted CSS. + * The trailing `&` in the wrapped form refers to the *styled* element (Panda's + * normal placeholder); the inner `&` was the marker (now substituted out). * - * All three match Panda's `parseCondition` at runtime (`endsWith(" &")`, - * `startsWith("&")`, `includes("&")`), so Panda treats them as raw selectors - * at extraction time without any condition having to be pre-registered. + * Throws if `condValue` doesn't contain `&` — a relational marker query is + * fundamentally about element relationships, and the placeholder is how we + * say *which* element the marker is. */ export function buildRelationSelector( anchorClass: string, - modifier: string, + condValue: string, relation: MarkerRelation, ): string { - const anchor = `.${anchorClass}${modifier}`; + if (!condValue.includes("&")) { + throw new Error( + `bearbones: marker() requires the '&' placeholder; got: ${JSON.stringify(condValue)}`, + ); + } + const m = condValue.replaceAll("&", `.${anchorClass}`); switch (relation) { case "ancestor": - return `${anchor} &`; + return `${m} &`; case "descendant": - return `&:has(${anchor})`; + return `&:has(${m})`; case "sibling": - return `& ~ ${anchor}, ${anchor} ~ &`; + return `& ~ ${m}, ${m} ~ &`; } } diff --git a/packages/bearbones-vite/src/transform.ts b/packages/bearbones-vite/src/transform.ts index 784226f..dc5aa0e 100644 --- a/packages/bearbones-vite/src/transform.ts +++ b/packages/bearbones-vite/src/transform.ts @@ -3,15 +3,13 @@ import { dirname, resolve as resolvePath } from "node:path"; import { parse } from "@babel/parser"; import MagicString from "magic-string"; import { resolveUtility, type StyleFragment } from "./utility-map.ts"; +import { getCondition, listConditionsWithAnchor } from "./conditions-stash.ts"; import { MARKER_RELATIONS, - MARKER_STATES, - STATE_PSEUDO, buildRelationSelector, describeMarker, type MarkerDescriptor, type MarkerRelation, - type MarkerState, } from "./marker-registry.ts"; /** @@ -19,15 +17,16 @@ import { * * Responsibilities: * 1. Find every `marker('id')` call at module scope and register it. We - * rewrite the call site to a synthesized callable-record so the runtime + * rewrite the call site to a synthesized callable record so the runtime * sees a typed value matching the `BearbonesMarker` interface — both - * the property shortcuts and the `(modifier).is.` chain. - * 2. Find every `css(...)` call (only the local `css` binding from - * `bearbones`) and lower utility-string and condition-object arguments - * into Panda's native object form. Inside the object form, computed keys - * `[.]`, `[(LITERAL).is.]`, and - * `[._.is.]` are lowered to literal Panda - * condition names so the extractor sees a static object. + * the property shortcuts and the `(condValue).is.` chain. + * 2. Find every `css(...)` / `cva(...)` / `sva(...)` call (only local + * bindings imported from `styled-system/css` or `styled-system/recipes`) + * and lower utility-string and condition-object arguments into Panda's + * native object form. Inside the object form, computed keys + * `[(LITERAL).is.]` and `[._.is.]` + * are lowered to literal Panda raw selectors so the extractor sees a + * static object. * 3. Emit a transformed source string. Panda's extractor then parses the * transformed source as if it were authored that way. * @@ -38,9 +37,6 @@ import { * - `cva`/`sva` arguments accept the same input shapes; their `base` and * each variant arm are recursively lowered. * - `cx()` is left alone — it's a clsx-style runtime joiner per the spec. - * - * The transform is designed so that adding new utility names means appending - * to the utility-map; the AST traversal does not need to change. */ interface ImportBindings { @@ -68,19 +64,11 @@ function emptyBindings(): ImportBindings { /** * Determine if an import source resolves to a bearbones-relevant binding. * - * MVP recognizes: - * - `'bearbones'` itself — exposes marker, cx, and (future) css/cva/sva - * re-exports. - * - Panda's styled-system codegen output — `'../styled-system/css'`, - * `'./styled-system/recipes'`, etc. The path varies per project layout - * so we accept any path ending in `styled-system/css|recipes|jsx`. - * - * Future work: emit a virtual module from the bearbones facade so users can - * always write `import { css } from 'bearbones'`, and the host paths become - * an implementation detail. + * Both `css` and `marker` come from `styled-system/css`; `cva`/`sva` come + * from `styled-system/recipes`. The path varies per project layout so we + * accept any path ending in `styled-system/css|recipes|jsx`. */ function isStyledSystemSource(source: string): "css" | "recipes" | null { - if (source === "bearbones") return null; // handled separately if (/styled-system\/css(\.\w+)?$/.test(source)) return "css"; if (/styled-system\/recipes(\.\w+)?$/.test(source)) return "recipes"; return null; @@ -99,13 +87,10 @@ function trackReBindings(ast: any, bindings: ImportBindings): void { for (const declarator of node.declarations) { if (declarator.id.type !== "Identifier") continue; if (!declarator.init) continue; - // Strip a `... as T` cast wrapper so we see through it. let init = declarator.init; while (init.type === "TSAsExpression" || init.type === "TSTypeAssertion") { init = init.expression; } - // Allow chained casts: `_css as unknown as LooseCss` parses as - // ((_css as unknown) as LooseCss). The loop above handles both. if (init.type !== "Identifier") continue; const sourceName = init.name; const localName = declarator.id.name; @@ -122,20 +107,17 @@ function findBearbonesImports(ast: any): ImportBindings { if (node.type !== "ImportDeclaration") continue; const source = node.source.value; const styledSystemKind = isStyledSystemSource(source); - if (source !== "bearbones" && styledSystemKind === null) continue; + if (styledSystemKind === null) continue; for (const spec of node.specifiers) { if (spec.type !== "ImportSpecifier") continue; const imported = spec.imported.name; const local = spec.local.name; - // Imports from styled-system/css expose only `css`. Imports from - // styled-system/recipes expose `cva` and `sva`. Imports from - // 'bearbones' expose marker + cx (and, when re-export wiring is done, - // the others). - if (imported === "css") bindings.css.add(local); - else if (imported === "cva") bindings.cva.add(local); - else if (imported === "sva") bindings.sva.add(local); - else if (imported === "marker" && source === "bearbones") { - bindings.marker.add(local); + if (styledSystemKind === "css") { + if (imported === "css") bindings.css.add(local); + else if (imported === "marker") bindings.marker.add(local); + } else if (styledSystemKind === "recipes") { + if (imported === "cva") bindings.cva.add(local); + else if (imported === "sva") bindings.sva.add(local); } } } @@ -156,7 +138,6 @@ function lowerArgument(node: any, markers: MarkerCallContext): StyleFragment | n return lowerObject(node, markers); } if (node.type === "ArrayExpression") { - // Used inside `cva` arms — array of utility strings or mixed. const merged: StyleFragment = {}; for (const el of node.elements) { if (el == null) continue; @@ -171,8 +152,8 @@ function lowerArgument(node: any, markers: MarkerCallContext): StyleFragment | n /** * Lower an object literal into a Panda style fragment. Keys may be: * - A static key name (`_hover`, `padding`) — passed through. - * - A computed `[marker(LITERAL).is.]` or `[marker._.is.]` - * key — rewritten to the registered relational condition name. + * - A computed `[marker(LITERAL).is.]` or `[marker._.is.]` + * key — rewritten to the registered relational raw selector. */ function lowerObject(node: any, markers: MarkerCallContext): StyleFragment | null { const out: StyleFragment = {}; @@ -193,14 +174,11 @@ function resolveKey(prop: any, markers: MarkerCallContext): string | null { if (prop.key.type === "StringLiteral") return prop.key.value; return null; } - // Computed key: only the relational chain shapes are recognized. The legacy - // `[marker.]` shortcut form was removed in favor of the explicit - // `[marker._.is.]` builder. return resolveRelationalKey(prop.key, markers); } /** - * Match `(LITERAL).is.` and `._.is.` + * Match `(LITERAL).is.` and `._.is.` * computed keys. Returns the *raw selector string* Panda will treat as a * parent-/self-/combinator-nesting selector, or `null` if the key isn't one * of the recognized relational chain shapes. @@ -221,34 +199,37 @@ function resolveRelationalKey(node: any, markers: MarkerCallContext): string | n const inner = middle.object; let bindingName: string | null = null; - let modifier: string | null = null; + let condValue: string | null = null; if (inner?.type === "CallExpression") { if (inner.callee.type !== "Identifier") return null; bindingName = inner.callee.name; - modifier = literalStringArg(inner.arguments[0]); + condValue = literalStringArg(inner.arguments[0]); } else if (inner?.type === "MemberExpression" && !inner.computed) { if (inner.object.type !== "Identifier") return null; bindingName = inner.object.name; if (inner.property.type !== "Identifier") return null; const propName = inner.property.name as string; if (!propName.startsWith("_")) return null; - const stateName = propName.slice(1); - if (!isValidState(stateName)) return null; - modifier = STATE_PSEUDO[stateName]; + const condName = propName.slice(1); + const looked = getCondition(condName); + if (looked === undefined) { + throw new Error( + `bearbones: marker._${condName} references an unregistered condition. ` + + `Either declare it under \`conditions\` in panda.config.ts or use the ` + + `call form \`marker('')\` directly.`, + ); + } + condValue = looked; } else { return null; } - if (bindingName == null || modifier == null) return null; + if (bindingName == null || condValue == null) return null; const marker = markers.byBinding(bindingName); if (!marker) return null; - return buildRelationSelector(marker.anchorClass, modifier, relation); -} - -function isValidState(name: string): name is MarkerState { - return (MARKER_STATES as readonly string[]).includes(name); + return buildRelationSelector(marker.anchorClass, condValue, relation); } function isValidRelation(name: string): name is MarkerRelation { @@ -266,8 +247,6 @@ function literalStringArg(arg: any): string | null { function lowerValue(node: any, markers: MarkerCallContext): unknown { if (node.type === "StringLiteral") { - // A bare utility string used as a value of a condition key, e.g. - // `{ _hover: 'bg-blue-500' }` → resolves to a single fragment. const fragment = resolveUtility(node.value); if (fragment) return fragment; return node.value; @@ -276,7 +255,6 @@ function lowerValue(node: any, markers: MarkerCallContext): unknown { if (node.type === "BooleanLiteral") return node.value; if (node.type === "NullLiteral") return null; if (node.type === "ArrayExpression") { - // Array of utility strings under a condition. const merged: StyleFragment = {}; for (const el of node.elements) { if (el == null) continue; @@ -323,12 +301,6 @@ function deepAssign(target: StyleFragment, source: StyleFragment): void { * Per-call binding context: which `marker(...)` declarations are visible at * each call site. Includes both local declarations and imports from other * files (which get pre-resolved by reading the imported source on demand). - * - * Pre-reading the imported file is necessary because Panda calls - * `parser:before` once per file and doesn't guarantee processing order — a - * consumer of `cardMarker` may be parsed before its declaring module. The - * cache is per-context (per file), not global; nothing here outlives a - * single transform pass. */ class MarkerCallContext { private readonly bindings = new Map(); @@ -346,7 +318,6 @@ class MarkerCallContext { byBinding(localName: string): MarkerDescriptor | undefined { const cached = this.bindings.get(localName); if (cached) return cached; - // Look up cross-file imports lazily. const sourcePath = this.imports.get(localName); if (!sourcePath) return undefined; const fromImport = resolveImportedMarker(sourcePath, localName); @@ -362,10 +333,8 @@ class MarkerCallContext { * Read an imported file, scan it for the `marker()` declaration matching * `bindingName`, and return a `MarkerDescriptor` derived from `(id, path)`. * - * This is best-effort and intentionally loose: if the file can't be read, or - * doesn't contain the expected declaration, we silently return undefined and - * leave the call site untouched. The downstream Panda extractor will simply - * skip the unrecognized key. + * Best-effort and intentionally loose: if the file can't be read, or doesn't + * contain the expected declaration, we silently return undefined. */ function resolveImportedMarker( absolutePath: string, @@ -413,16 +382,11 @@ function resolveImportedMarker( /** * Resolve an import specifier (`./markers.ts`, `../foo/bar`) to the absolute * path of the imported file, relative to the importing file. - * - * MVP: only supports relative imports. Bare specifiers (e.g. - * `'@bearbones/preset'`) are ignored — they aren't where marker declarations - * live in practice. */ function resolveRelativeImport(fromFile: string, specifier: string): string | undefined { if (!specifier.startsWith(".")) return undefined; const base = dirname(fromFile); const candidate = resolvePath(base, specifier); - // Try the candidate as-is, plus common TS extensions, before giving up. const tries = [ candidate, `${candidate}.ts`, @@ -443,14 +407,13 @@ function resolveRelativeImport(fromFile: string, specifier: string): string | un /** * Discover top-level `const x = marker('id')` declarations. Each becomes a - * binding the call-site lowering can resolve when it sees `[x._.is.]` - * computed keys. + * binding the call-site lowering can resolve when it sees `[x._.is.]` + * or `[x(LITERAL).is.]` computed keys. * * For each declaration, we also rewrite the right-hand side to a synthesized - * callable record carrying the marker's anchor class, the typed `_` - * builders, and a tiny IIFE that handles `(modifier).is.` chains at - * runtime. Inline FNV-1a keeps build-side and runtime modifier hashes aligned - * without a shared bundle import. + * callable record carrying the marker's anchor class, the typed `_` + * builders (one per registered condition), and a tiny IIFE that handles + * `(condValue).is.` chains at runtime. */ function processMarkerDeclarations( ast: any, @@ -461,9 +424,6 @@ function processMarkerDeclarations( const ctx = new MarkerCallContext(); let needsRelationsHelper = false; - // Track every relative import so when we see a `[binding.state]` computed - // key in this file, we know which source file to consult for the - // declaration. for (const node of ast.program.body) { if (node.type !== "ImportDeclaration") continue; const spec = node.source.value; @@ -493,8 +453,6 @@ function processMarkerDeclarations( const descriptor = describeMarker(id, modulePath); ctx.bind(declarator.id.name, descriptor); - // Rewrite the call to a synthesized record literal so the runtime - // doesn't need a real `marker()` implementation. const replacement = renderMarkerRecord(descriptor); source.overwrite(declarator.init.start, declarator.init.end, replacement); needsRelationsHelper = true; @@ -505,39 +463,37 @@ function processMarkerDeclarations( /** * Inline runtime helper. Composes the three raw-selector strings for a - * `(modifier, anchorClass)` pair so variable-bound chains (e.g. - * `const k = m(':sel').is.ancestor`) work at runtime. The composition is - * byte-for-byte identical to `buildRelationSelector` in `marker-registry.ts` - * — Panda accepts the resulting strings as parent-/self-/combinator-nesting - * selectors directly. + * `(condValue, anchorClass)` pair so variable-bound chains (e.g. + * `const k = m('&:hover').is.ancestor`) work at runtime. Substitutes every + * `&` in the input with the marker's anchor selector, then wraps in the + * relation. Byte-for-byte identical to `buildRelationSelector` in + * `marker-registry.ts`. * * Emitted once per file that declares any marker. The synthesized marker * record closes over this constant via a normal lexical reference. */ const RELATIONS_HELPER_NAME = "__bearbones_relations"; -const RELATIONS_HELPER_SOURCE = `const ${RELATIONS_HELPER_NAME} = (m, a) => { - const x = "." + a + m; - return { is: { ancestor: x + " &", descendant: "&:has(" + x + ")", sibling: "& ~ " + x + ", " + x + " ~ &" } }; +const RELATIONS_HELPER_SOURCE = `const ${RELATIONS_HELPER_NAME} = (c, a) => { + const m = c.split("&").join("." + a); + return { is: { ancestor: m + " &", descendant: "&:has(" + m + ")", sibling: "& ~ " + m + ", " + m + " ~ &" } }; };`; function renderMarkerRecord(marker: MarkerDescriptor): string { - const fields: string[] = [ - `anchor: ${JSON.stringify(marker.anchorClass)}`, - // Underscore builder forms baked in as literal raw selectors. The inlined - // helper below produces the same strings for the call form, so both paths - // agree byte-for-byte. - ...MARKER_STATES.map((state) => { - const modifier = STATE_PSEUDO[state]; - const ancestor = buildRelationSelector(marker.anchorClass, modifier, "ancestor"); - const descendant = buildRelationSelector(marker.anchorClass, modifier, "descendant"); - const sibling = buildRelationSelector(marker.anchorClass, modifier, "sibling"); - return `_${state}: { is: { ancestor: ${JSON.stringify(ancestor)}, descendant: ${JSON.stringify(descendant)}, sibling: ${JSON.stringify(sibling)} } }`; - }), - ]; - // Object.assign(fn, { ...props }) — the function half handles the - // `(modifier).is.` call form, the assigned properties cover the - // anchor class and the `_.is.` underscore builders. - return `Object.assign((m) => ${RELATIONS_HELPER_NAME}(m, ${JSON.stringify(marker.anchorClass)}), { ${fields.join(", ")} })`; + const fields: string[] = [`anchor: ${JSON.stringify(marker.anchorClass)}`]; + // Bake in `_` shortcut for every condition whose value contains `&`. + // The stash is pre-populated from preset-base at module load and replaced + // with the host project's resolved conditions during `config:resolved` — + // by the time `parser:before` runs (where this transform fires), the stash + // reflects the user's full vocabulary including any extensions. + for (const { name, value } of listConditionsWithAnchor()) { + const ancestor = buildRelationSelector(marker.anchorClass, value, "ancestor"); + const descendant = buildRelationSelector(marker.anchorClass, value, "descendant"); + const sibling = buildRelationSelector(marker.anchorClass, value, "sibling"); + fields.push( + `_${name}: { is: { ancestor: ${JSON.stringify(ancestor)}, descendant: ${JSON.stringify(descendant)}, sibling: ${JSON.stringify(sibling)} } }`, + ); + } + return `Object.assign((c) => ${RELATIONS_HELPER_NAME}(c, ${JSON.stringify(marker.anchorClass)}), { ${fields.join(", ")} })`; } /** @@ -573,10 +529,6 @@ function renderObject(fragment: StyleFragment): string { return JSON.stringify(fragment); } -/** - * Tiny depth-first walker. Avoids pulling in @babel/traverse, which is heavy - * and has its own deps. We only need to visit every node once. - */ function walk(node: any, visit: (n: any) => void): void { if (!node || typeof node !== "object") return; if (Array.isArray(node)) { @@ -601,10 +553,9 @@ export interface TransformResult { } export function transform(input: TransformInput): TransformResult { - // Cheap early-exit: if the file references neither our package nor the - // styled-system entry points, there's nothing for us to do. Saves a Babel - // parse on most files in a typical project. - if (!input.source.includes("bearbones") && !input.source.includes("styled-system")) { + // Cheap early-exit: if the file references styled-system not at all, we + // have nothing to lower. Saves a Babel parse on most files. + if (!input.source.includes("styled-system")) { return { content: undefined }; } @@ -617,7 +568,6 @@ export function transform(input: TransformInput): TransformResult { ranges: true, }); } catch { - // Files we can't parse (e.g. weird syntax) are passed through. return { content: undefined }; } @@ -631,7 +581,6 @@ export function transform(input: TransformInput): TransformResult { return { content: undefined }; } - // Follow simple top-level re-bindings: `const css = _css as ...`. trackReBindings(ast, bindings); const ms = new MagicString(input.source); @@ -644,10 +593,6 @@ export function transform(input: TransformInput): TransformResult { processCalls(ast, bindings, ms, markers); if (needsRelationsHelper) { - // Prepend the helper. The transform's argument-lowering pass produces - // static condition strings inside `css({})` calls, but variable bindings - // of `(sel).is.` (and runtime evaluation in general) need - // the helper to compute matching condition names. ms.prepend(`${RELATIONS_HELPER_SOURCE}\n`); } diff --git a/packages/bearbones-vite/tests/__snapshots__/codegen-patch.test.ts.snap b/packages/bearbones-vite/tests/__snapshots__/codegen-patch.test.ts.snap index 8a02ce0..0967ba7 100644 --- a/packages/bearbones-vite/tests/__snapshots__/codegen-patch.test.ts.snap +++ b/packages/bearbones-vite/tests/__snapshots__/codegen-patch.test.ts.snap @@ -29,6 +29,26 @@ export type BearbonesSystemStyleObject = type Styles = BearbonesSystemStyleObject | undefined | null | false +export interface BearbonesMarkerBuilder { + readonly is: { + readonly ancestor: \`_bbm_\${Id}_\${Cond}_a &\`; + readonly descendant: \`&_bbm_\${Id}_\${Cond}_d\`; + readonly sibling: \`&_bbm_\${Id}_\${Cond}_s\`; + }; +} + +export interface BearbonesMarker { + readonly anchor: \`bearbones-marker-\${Id}_\${string}\`; + readonly _hover: BearbonesMarkerBuilder; + readonly _focus: BearbonesMarkerBuilder; + readonly _focusVisible: BearbonesMarkerBuilder; + readonly _dark: BearbonesMarkerBuilder; + (condValue: C): BearbonesMarkerBuilder; +} + +export declare function marker(id: Id): BearbonesMarker; + + interface CssRawFunction { (styles: Styles): SystemStyleObject (styles: Styles[]): SystemStyleObject diff --git a/packages/bearbones-vite/tests/codegen-patch.test.ts b/packages/bearbones-vite/tests/codegen-patch.test.ts index 164ae54..fc08281 100644 --- a/packages/bearbones-vite/tests/codegen-patch.test.ts +++ b/packages/bearbones-vite/tests/codegen-patch.test.ts @@ -1,7 +1,12 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, it, expect } from "vitest"; -import { patchCssArtifact, patchArtifacts, type PandaArtifact } from "../src/codegen-patch.ts"; +import { + patchCssArtifact, + patchCssRuntime, + patchArtifacts, + type PandaArtifact, +} from "../src/codegen-patch.ts"; // Fixture is named `.d.ts.txt` (not `.d.ts`) so oxfmt and tsc skip it. // We need Panda's *exact* emitted bytes — including its single-quote import @@ -12,29 +17,58 @@ const FIXTURE_PATH = join(__dirname, "fixtures", "panda-css.d.ts.txt"); const FIXTURE_SOURCE = readFileSync(FIXTURE_PATH, "utf8"); const SAMPLE_UTILITIES = ["p-4", "bg-blue-500", "flex"] as const; +const SAMPLE_CONDITIONS = ["hover", "focus", "focusVisible", "dark"] as const; describe("patchCssArtifact", () => { it("injects BearbonesUtilityName, BearbonesNested, BearbonesSystemStyleObject", () => { - const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES); + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); expect(patched).toContain("export type BearbonesUtilityName ="); expect(patched).toContain("export type BearbonesNested

"); expect(patched).toContain("export type BearbonesSystemStyleObject"); }); + it("injects BearbonesMarker / BearbonesMarkerBuilder / marker declaration", () => { + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); + expect(patched).toContain("export interface BearbonesMarkerBuilder"); + expect(patched).toContain("export interface BearbonesMarker"); + expect(patched).toContain("export declare function marker(id: Id)"); + }); + + it("enumerates `_` shortcut on BearbonesMarker for every passed condition", () => { + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); + for (const name of SAMPLE_CONDITIONS) { + expect(patched).toContain(`readonly _${name}: BearbonesMarkerBuilder`); + } + }); + + it("emits concrete-literal relation types parameterized on Id and Cond", () => { + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); + // Concrete-literal patterns (no bare `${string}`) so computed keys stay + // as named properties and don't collapse to a string-index signature. + expect(patched).toContain("readonly ancestor: `_bbm_${Id}_${Cond}_a &`"); + expect(patched).toContain("readonly descendant: `&_bbm_${Id}_${Cond}_d`"); + expect(patched).toContain("readonly sibling: `&_bbm_${Id}_${Cond}_s`"); + }); + + it("emits a generic call form so literal condValue args produce concrete chain types", () => { + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); + expect(patched).toContain("(condValue: C): BearbonesMarkerBuilder"); + }); + it("includes every utility name passed in as a quoted union member", () => { - const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES); + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); for (const name of SAMPLE_UTILITIES) { expect(patched).toContain(`| "${name}"`); } }); it("emits `never` when no utilities are passed", () => { - const patched = patchCssArtifact(FIXTURE_SOURCE, []); + const patched = patchCssArtifact(FIXTURE_SOURCE, [], SAMPLE_CONDITIONS); expect(patched).toContain("export type BearbonesUtilityName =\nnever"); }); it("rewrites the Styles type alias to point at BearbonesSystemStyleObject", () => { - const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES); + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); expect(patched).toContain( "type Styles = BearbonesSystemStyleObject | undefined | null | false", ); @@ -44,7 +78,7 @@ describe("patchCssArtifact", () => { }); it("imports the Panda helper types it references", () => { - const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES); + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); expect(patched).toContain("import type { Nested, Conditions } from '../types/conditions';"); expect(patched).toContain("import type { Selectors, AnySelector } from '../types/selectors';"); expect(patched).toContain( @@ -53,7 +87,7 @@ describe("patchCssArtifact", () => { }); it("preserves the rest of Panda's emitted file (CssFunction, css const)", () => { - const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES); + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); expect(patched).toContain("interface CssFunction"); expect(patched).toContain("export declare const css: CssFunction;"); expect(patched).toContain("interface CssRawFunction"); @@ -64,7 +98,9 @@ describe("patchCssArtifact", () => { "type Styles = SystemStyleObject | undefined | null | false", "type Styles = SomethingElse", ); - expect(() => patchCssArtifact(broken, SAMPLE_UTILITIES)).toThrow(/expected anchor not found/); + expect(() => patchCssArtifact(broken, SAMPLE_UTILITIES, SAMPLE_CONDITIONS)).toThrow( + /expected anchor not found/, + ); }); it("throws a self-diagnosing error when the Panda import marker is missing", () => { @@ -72,16 +108,16 @@ describe("patchCssArtifact", () => { "import type { SystemStyleObject } from '../types/index';", "import { Foo } from 'somewhere-else';", ); - expect(() => patchCssArtifact(broken, SAMPLE_UTILITIES)).toThrow( + expect(() => patchCssArtifact(broken, SAMPLE_UTILITIES, SAMPLE_CONDITIONS)).toThrow( /expected Panda import marker not found/, ); }); - it("does not emit any per-marker augmentation", () => { + it("does not emit any per-marker registry augmentation", () => { // The marker-registry augmentation, conditions augmentation, and per-modifier // overloads are all gone — the chain lowers to raw selectors that match Panda's - // existing `AnySelector` type without any codegen help. - const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES); + // existing `AnySelector` type without any registry codegen. + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); expect(patched).not.toContain("declare module 'bearbones'"); expect(patched).not.toContain("declare module '../types/conditions'"); expect(patched).not.toContain("BearbonesMarkerRegistry"); @@ -89,11 +125,26 @@ describe("patchCssArtifact", () => { }); it("matches snapshot for a representative utility list", () => { - const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES); + const patched = patchCssArtifact(FIXTURE_SOURCE, SAMPLE_UTILITIES, SAMPLE_CONDITIONS); expect(patched).toMatchSnapshot(); }); }); +describe("patchCssRuntime", () => { + it("appends a marker stub that throws", () => { + const out = patchCssRuntime("/* runtime */\nexport const css = ...;\n"); + expect(out).toContain("export function marker(_id)"); + expect(out).toContain("throw new Error"); + expect(out).toContain("@bearbones/vite transform did not run"); + }); + + it("is idempotent — re-patching does not append a second copy", () => { + const once = patchCssRuntime("/* runtime */\n"); + const twice = patchCssRuntime(once); + expect(twice).toBe(once); + }); +}); + describe("patchArtifacts", () => { it("patches the css.d.ts file inside the css-fn artifact", () => { const artifacts: PandaArtifact[] = [ @@ -101,16 +152,16 @@ describe("patchArtifacts", () => { id: "css-fn", files: [ { file: "css.d.ts", code: FIXTURE_SOURCE }, - { file: "css.mjs", code: "/* runtime */" }, + { file: "css.mjs", code: "/* runtime */\n" }, ], }, ]; const out = patchArtifacts(artifacts); const cssDts = out[0]?.files.find((f) => f.file === "css.d.ts"); expect(cssDts?.code).toContain("BearbonesSystemStyleObject"); - // The runtime mjs file is untouched. + // The runtime mjs file gets the marker stub. const cssMjs = out[0]?.files.find((f) => f.file === "css.mjs"); - expect(cssMjs?.code).toBe("/* runtime */"); + expect(cssMjs?.code).toContain("export function marker(_id)"); }); it("leaves unrelated artifacts unchanged", () => { @@ -132,11 +183,13 @@ describe("patchArtifacts", () => { const artifacts: PandaArtifact[] = [ { id: "css-fn", - files: [{ file: "css.mjs", code: "/* runtime */" }], + files: [{ file: "css.mjs", code: "/* runtime */\n" }], }, ]; const out = patchArtifacts(artifacts); - expect(out).toEqual(artifacts); + // The runtime stub still gets injected even if css.d.ts is absent. + const cssMjs = out[0]?.files.find((f) => f.file === "css.mjs"); + expect(cssMjs?.code).toContain("export function marker(_id)"); }); it("no-ops when the css.d.ts file has undefined code", () => { diff --git a/packages/bearbones-vite/tests/marker-registry.test.ts b/packages/bearbones-vite/tests/marker-registry.test.ts index 909e020..c8729d2 100644 --- a/packages/bearbones-vite/tests/marker-registry.test.ts +++ b/packages/bearbones-vite/tests/marker-registry.test.ts @@ -25,25 +25,35 @@ describe("describeMarker", () => { describe("buildRelationSelector", () => { const anchor = "bearbones-marker-card_a27adb16"; - it("ancestor → ` &`", () => { - expect(buildRelationSelector(anchor, ":hover", "ancestor")).toBe(`.${anchor}:hover &`); + it("ancestor → `M &` after & substitution", () => { + expect(buildRelationSelector(anchor, "&:hover", "ancestor")).toBe(`.${anchor}:hover &`); }); - it("descendant → `&:has()`", () => { - expect(buildRelationSelector(anchor, "[data-state=open]", "descendant")).toBe( - `&:has(.${anchor}[data-state=open])`, + it("descendant → `&:has(M)` after & substitution", () => { + expect(buildRelationSelector(anchor, "[data-state=open] &", "descendant")).toBe( + `&:has([data-state=open] .${anchor})`, ); }); - it("sibling → `& ~ , ~ &`", () => { - expect(buildRelationSelector(anchor, ":focus-within", "sibling")).toBe( + it("sibling → `& ~ M, M ~ &` after & substitution", () => { + expect(buildRelationSelector(anchor, "&:focus-within", "sibling")).toBe( `& ~ .${anchor}:focus-within, .${anchor}:focus-within ~ &`, ); }); + it("substitutes every & in the input (global replace)", () => { + expect(buildRelationSelector(anchor, ".foo:has(&) ~ &", "ancestor")).toBe( + `.foo:has(.${anchor}) ~ .${anchor} &`, + ); + }); + it("ends in `&` for ancestor, starts with `&` for descendant + sibling — matches Panda's AnySelector", () => { - expect(buildRelationSelector(anchor, ":hover", "ancestor").endsWith(" &")).toBe(true); - expect(buildRelationSelector(anchor, ":hover", "descendant").startsWith("&")).toBe(true); - expect(buildRelationSelector(anchor, ":hover", "sibling").startsWith("&")).toBe(true); + expect(buildRelationSelector(anchor, "&:hover", "ancestor").endsWith(" &")).toBe(true); + expect(buildRelationSelector(anchor, "&:hover", "descendant").startsWith("&")).toBe(true); + expect(buildRelationSelector(anchor, "&:hover", "sibling").startsWith("&")).toBe(true); + }); + + it("throws when the condition value lacks the & placeholder", () => { + expect(() => buildRelationSelector(anchor, ":hover", "ancestor")).toThrow(/'&' placeholder/); }); }); diff --git a/packages/bearbones-vite/tests/transform.test.ts b/packages/bearbones-vite/tests/transform.test.ts index 6cc378a..43c22ec 100644 --- a/packages/bearbones-vite/tests/transform.test.ts +++ b/packages/bearbones-vite/tests/transform.test.ts @@ -81,7 +81,7 @@ export const x = css({ _hover: ["bg-blue-500", "text-white"] }); const result = transform({ filePath: "/virtual/markers.ts", source: ` -import { marker } from "bearbones"; +import { marker } from "../styled-system/css"; export const cardMarker = marker("card"); `.trim(), }); @@ -96,14 +96,12 @@ export const cardMarker = marker("card"); const result = transform({ filePath: "/virtual/markers.ts", source: ` -import { marker } from "bearbones"; +import { marker } from "../styled-system/css"; export const cardMarker = marker("card"); export const rowMarker = marker("row"); `.trim(), }); expect(result.content).toBeDefined(); - // Each marker call site replaced with a synthesized record carrying its - // own anchor class, derived purely from `(id, modulePath)`. expect(result.content).toMatch(/anchor: "bearbones-marker-card_[0-9a-f]{8}"/); expect(result.content).toMatch(/anchor: "bearbones-marker-row_[0-9a-f]{8}"/); }); @@ -113,7 +111,7 @@ export const rowMarker = marker("row"); transform({ filePath: "/virtual/markers.ts", source: ` -import { marker } from "bearbones"; +import { marker } from "../styled-system/css"; const name = "card"; export const cardMarker = marker(name); `.trim(), @@ -127,7 +125,7 @@ export const cardMarker = marker(name); const markersPath = join(dir, "markers.ts"); writeFileSync( markersPath, - `import { marker } from "bearbones"; + `import { marker } from "../styled-system/css"; export const cardMarker = marker("card"); `, ); @@ -148,7 +146,7 @@ export const x = css({ [cardMarker._hover.is.ancestor]: "bg-blue-500" }); ); }); - it("passes through files with no bearbones imports unchanged", () => { + it("passes through files with no styled-system imports unchanged", () => { const source = "export const x = 1;"; const result = transform({ filePath: "/virtual/plain.ts", @@ -174,7 +172,7 @@ describe("transform — relational marker chains", () => { const result = transform({ filePath: "/virtual/markers.ts", source: ` -import { marker } from "bearbones"; +import { marker } from "../styled-system/css"; export const cardMarker = marker("card"); `.trim(), }); @@ -182,10 +180,10 @@ export const cardMarker = marker("card"); // Helper is prepended once per file. expect(result.content).toContain("__bearbones_relations"); // Synthesized record uses Object.assign to wrap the call form + the - // typed `_` builder properties. Function half delegates to the + // typed `_` builder properties. Function half delegates to the // helper with the marker's anchor class. expect(result.content).toMatch( - /Object\.assign\(\(m\) => __bearbones_relations\(m, "bearbones-marker-card_/, + /Object\.assign\(\(c\) => __bearbones_relations\(c, "bearbones-marker-card_/, ); // Underscore builder forms are emitted with literal raw-selector strings. expect(result.content).toMatch( @@ -196,14 +194,13 @@ export const cardMarker = marker("card"); expect(result.content).not.toMatch(/_marker_card_[0-9a-f]{8}_ancestor_/); }); - it("lowers marker(LITERAL).is.ancestor to a raw selector key", () => { + it("lowers marker('&:has(.error)').is.ancestor to a raw selector key (call form, & substituted)", () => { const result = transform({ filePath: "/virtual/file.tsx", source: ` -import { css } from "../styled-system/css"; -import { marker } from "bearbones"; +import { css, marker } from "../styled-system/css"; const m = marker("container"); -export const x = css({ [m(":has(.error)").is.ancestor]: "p-4" }); +export const x = css({ [m("&:has(.error)").is.ancestor]: "p-4" }); `.trim(), }); expect(result.content).toBeDefined(); @@ -212,12 +209,11 @@ export const x = css({ [m(":has(.error)").is.ancestor]: "p-4" }); ); }); - it("lowers marker._.is.descendant to a `&:has(...)` raw selector key", () => { + it("lowers marker._.is.descendant to a `&:has(...)` raw selector key", () => { const result = transform({ filePath: "/virtual/file.tsx", source: ` -import { css } from "../styled-system/css"; -import { marker } from "bearbones"; +import { css, marker } from "../styled-system/css"; const m = marker("panel"); export const x = css({ [m._focusVisible.is.descendant]: "p-4" }); `.trim(), @@ -232,10 +228,9 @@ export const x = css({ [m._focusVisible.is.descendant]: "p-4" }); const result = transform({ filePath: "/virtual/file.tsx", source: ` -import { css } from "../styled-system/css"; -import { marker } from "bearbones"; +import { css, marker } from "../styled-system/css"; const m = marker("group"); -export const x = css({ [m(":focus-within").is.sibling]: "p-4" }); +export const x = css({ [m("&:focus-within").is.sibling]: "p-4" }); `.trim(), }); expect(result.content).toBeDefined(); @@ -248,17 +243,14 @@ export const x = css({ [m(":focus-within").is.sibling]: "p-4" }); const result = transform({ filePath: "/virtual/file.tsx", source: ` -import { css } from "../styled-system/css"; -import { marker } from "bearbones"; +import { css, marker } from "../styled-system/css"; const widgetMarker = marker("widget"); export const x = css({ - [widgetMarker(":has(.error)").is.ancestor]: "p-4", + [widgetMarker("&:has(.error)").is.ancestor]: "p-4", }); `.trim(), }); expect(result.content).toBeDefined(); - // Pull the suffix out of the synthesized record's anchor field and assert - // the chain's lowered selector uses the same one. const anchorMatch = result.content!.match(/anchor: "bearbones-marker-widget_([0-9a-f]{8})"/); expect(anchorMatch).not.toBeNull(); const suffix = anchorMatch![1]; @@ -267,14 +259,60 @@ export const x = css({ ); }); - it("leaves the chain untouched when modifier is dynamic", () => { + it("substitutes every & in the condition value (parent-nesting form)", () => { const result = transform({ filePath: "/virtual/file.tsx", source: ` -import { css } from "../styled-system/css"; -import { marker } from "bearbones"; +import { css, marker } from "../styled-system/css"; +const m = marker("card"); +export const x = css({ [m("[data-state=open] &").is.descendant]: "p-4" }); + `.trim(), + }); + expect(result.content).toBeDefined(); + // Marker is the descendant of [data-state=open]; the styled element has + // the marker as a descendant of itself. Both `&` placeholders in the + // input refer to the marker; the wrapped relation re-introduces a + // trailing `&` for the styled element. + expect(result.content).toMatch( + /"&:has\(\[data-state=open\] \.bearbones-marker-card_[0-9a-f]{8}\)":\{"p":"4"\}/, + ); + }); + + it("substitutes multiple & occurrences in a single condition value", () => { + const result = transform({ + filePath: "/virtual/file.tsx", + source: ` +import { css, marker } from "../styled-system/css"; +const m = marker("card"); +export const x = css({ [m(".foo:has(&) ~ &").is.sibling]: "p-4" }); + `.trim(), + }); + expect(result.content).toBeDefined(); + expect(result.content).toMatch( + /\.foo:has\(\.bearbones-marker-card_[0-9a-f]{8}\) ~ \.bearbones-marker-card_[0-9a-f]{8}/, + ); + }); + + it("rejects a call-form condition value missing the & placeholder", () => { + expect(() => + transform({ + filePath: "/virtual/file.tsx", + source: ` +import { css, marker } from "../styled-system/css"; +const m = marker("noamp"); +export const x = css({ [m(":hover").is.ancestor]: "p-4" }); + `.trim(), + }), + ).toThrow(/'&' placeholder/); + }); + + it("leaves the chain untouched when condition value is dynamic", () => { + const result = transform({ + filePath: "/virtual/file.tsx", + source: ` +import { css, marker } from "../styled-system/css"; const m = marker("dyn"); -const sel = ":hover"; +const sel = "&:hover"; export const x = css({ [m(sel).is.ancestor]: "p-4" }); `.trim(), }); diff --git a/packages/bearbones/package.json b/packages/bearbones/package.json deleted file mode 100644 index 31ddd5e..0000000 --- a/packages/bearbones/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "bearbones", - "version": "0.0.0", - "description": "Typed utility-class styling on top of PandaCSS.", - "license": "MIT", - "files": [ - "dist" - ], - "type": "module", - "exports": { - ".": "./dist/index.mjs", - "./package.json": "./package.json" - }, - "scripts": { - "build": "vp pack", - "dev": "vp pack --watch", - "check": "vp check" - }, - "dependencies": { - "@bearbones/vite": "workspace:*" - }, - "devDependencies": { - "@types/node": "catalog:", - "typescript": "^6.0.2", - "vite-plus": "^0.1.20" - } -} diff --git a/packages/bearbones/src/index.ts b/packages/bearbones/src/index.ts deleted file mode 100644 index 648696a..0000000 --- a/packages/bearbones/src/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Public entry point for the `bearbones` package. - * - * The runtime surface here is intentionally minimal — Panda owns the heavy - * lifting. We only ship: - * - * - `cx`: a clsx-style class string joiner used to combine multiple class - * strings (the result of `css()`, recipe outputs, prop passthrough, etc.). - * - * - `marker`: declares a typed marker symbol for parent-state styling. - * Lowered by `@bearbones/vite`'s parser:before transform into a - * synthesized object literal at build time. - * - * - Type augmentation of Panda's `css()` signature happens in - * `@bearbones/vite`'s `codegen:prepare` hook, which patches Panda's - * emitted `styled-system/css/css.d.ts` directly. The patched file - * exports a project-accurate `BearbonesUtilityName` union derived - * from Panda's resolved tokens — import from there if you want to - * reference the union in your own code. - * - * - Re-exports of `css`, `cva`, `sva` from the host project's - * `styled-system/` directory (Panda's generated runtime). At install time - * a tiny shim is wired up so `import { css } from 'bearbones'` resolves - * to the host's `styled-system/css`. - * - * Note on `css()` and friends: the actual runtime functions live in the host - * project's `styled-system/` directory (Panda's codegen output). The bearbones - * facade is a *type-erased* reference; the lowering transform in - * `@bearbones/vite` rewrites `import { css } from 'bearbones'` → matching - * Panda imports during the parser:before pass. (MVP simplification: this - * rewrite is not yet implemented; consumers temporarily import `css` directly - * from `styled-system/css`. Tracked in the design spec under "open questions: - * facade rewriting.") - * - * The `cx` and `marker` exports are full runtime implementations and ship from - * this package without any rewriting. - */ - -/** - * clsx-style class string concat. Loose by design — accepts arbitrary strings - * and falsy values. The discipline lives at the `css()` boundary, not here. - */ -export function cx(...args: Array): string { - let out = ""; - for (const arg of args) { - if (!arg) continue; - if (out.length === 0) { - out = arg; - } else { - out = out + " " + arg; - } - } - return out; -} - -/** - * Runtime shape returned from `marker(...)` after the transform rewrites the - * call site. Useful only as a TypeScript type — at runtime, the transform - * replaces every `marker('id')` call with a synthesized callable record. - * - * If a consumer somehow imports `marker` directly without running through - * the transform (e.g., an SSR runtime that didn't pre-build), this fallback - * implementation throws. That's the loudest possible signal that the build - * pipeline isn't wired correctly. - * - * Each shape position uses template-literal types parameterized over `Id` - * — narrow enough to satisfy Panda's `AnySelector` (`${string}&` | - * `&${string}`) for relational keys, with no per-marker codegen needed. - */ -export function marker(_id: Id): BearbonesMarker { - throw new Error( - "bearbones: marker() was called at runtime. " + - "This usually means the @bearbones/vite transform did not run before this module. " + - "Verify Panda's hooks include bearbonesHooks() and that the file imports `marker` from 'bearbones'.", - ); -} - -/** - * The relational builder returned from a marker call (`m(':sel')`) or an - * underscore shortcut (`m._hover`). `.is.` resolves to a *raw CSS - * selector string* anchored at the marker's `bearbones-marker-` - * class. Panda's `parseCondition` recognizes the result as parent-/self-/ - * combinator-nesting and emits CSS for it without any condition having to - * be pre-registered. - * - * Each variant matches Panda's `AnySelector` template-literal types - * (`${string}&` | `&${string}`), so the result is accepted as a computed - * key in `BearbonesNestedObject

`'s `[K in AnySelector]` branch. - */ -export interface BearbonesMarkerBuilder { - readonly is: { - readonly ancestor: `.bearbones-marker-${Id}_${string} &`; - readonly descendant: `&:has(.bearbones-marker-${Id}_${string})`; - // Sibling is comma-joined and ordered to start with `&` so the string - // matches Panda's `AnySelector` (`&${string}`). Order of selectors in a - // CSS comma-list is irrelevant to the emitted rule. - readonly sibling: `& ~ .bearbones-marker-${Id}_${string}, .bearbones-marker-${Id}_${string} ~ &`; - }; -} - -export interface BearbonesMarker { - readonly anchor: `bearbones-marker-${Id}_${string}`; - // Underscore builder form: each yields an `.is.{ancestor,descendant,sibling}` - // chain that lets consumers pick the relation explicitly. Equivalent to - // calling the marker with the matching `STATE_PSEUDO[state]` selector. - readonly _hover: BearbonesMarkerBuilder; - readonly _focus: BearbonesMarkerBuilder; - readonly _active: BearbonesMarkerBuilder; - readonly _focusVisible: BearbonesMarkerBuilder; - readonly _disabled: BearbonesMarkerBuilder; - /** - * Call form: pass an arbitrary CSS-fragment modifier (e.g. `:has(.error)`, - * `[data-state=open]`, `:focus-within`) and pick a relation via `.is`. - * The `@bearbones/vite` transform requires the modifier to be a string - * literal at the call site so it can compose the raw selector at build - * time; dynamic strings still work at runtime but won't be statically - * extracted. - */ - (selector: string): BearbonesMarkerBuilder; -} - -// Note: `BearbonesUtilityName` is no longer re-exported from this package. -// The closed set of valid utility names is derived from the host project's -// resolved Panda tokens at codegen time, so the only project-accurate -// version of the type lives in the patched `styled-system/css/css.d.ts`: -// -// import type { BearbonesUtilityName } from '../styled-system/css'; -// -// The `css()` function itself doesn't need this import — utility strings are -// accepted natively at the call site via the same patch. diff --git a/packages/bearbones/tsconfig.json b/packages/bearbones/tsconfig.json deleted file mode 100644 index ff4adab..0000000 --- a/packages/bearbones/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "esnext", - "lib": ["es2023"], - "moduleDetection": "force", - "module": "nodenext", - "moduleResolution": "nodenext", - "resolveJsonModule": true, - "types": ["node"], - "strict": true, - "noUnusedLocals": true, - "declaration": true, - "noEmit": true, - "allowImportingTsExtensions": true, - "esModuleInterop": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - } -} diff --git a/packages/bearbones/vite.config.ts b/packages/bearbones/vite.config.ts deleted file mode 100644 index 817d434..0000000 --- a/packages/bearbones/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vite-plus"; - -export default defineConfig({ - pack: { dts: { tsgo: true }, exports: true }, - lint: { options: { typeAware: true, typeCheck: true } }, - fmt: {}, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47b44e4..3fbcc33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,6 @@ importers: '@vitejs/plugin-react': specifier: 'catalog:' version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3)) - bearbones: - specifier: workspace:* - version: link:../../packages/bearbones react: specifier: 'catalog:' version: 19.2.5 @@ -83,22 +80,6 @@ importers: specifier: 'catalog:' version: 0.1.20(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3))(jiti@2.6.1)(typescript@6.0.3)(yaml@2.8.3) - packages/bearbones: - dependencies: - '@bearbones/vite': - specifier: workspace:* - version: link:../bearbones-vite - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.12.2 - typescript: - specifier: ^6.0.2 - version: 6.0.3 - vite-plus: - specifier: ^0.1.20 - version: 0.1.20(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) - packages/bearbones-preset: devDependencies: '@pandacss/dev': From 28b8d4fa0f99cbd13c991806b8000e203c63505c Mon Sep 17 00:00:00 2001 From: Klink <85062+dogmar@users.noreply.github.com> Date: Sat, 2 May 2026 16:50:01 -0700 Subject: [PATCH 2/3] demo: drop the marker+borderWidth scratch paragraph It served as a repro for the TS computed-key collapse; now that the typing fix is in and exercised by `__type-tests__/css-typing.ts`, the demo doesn't need a duplicate showcase. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/src/Demo.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/website/src/Demo.tsx b/apps/website/src/Demo.tsx index 9ef5a15..25d4ac6 100644 --- a/apps/website/src/Demo.tsx +++ b/apps/website/src/Demo.tsx @@ -103,19 +103,6 @@ export function Demo() { )} /> -

*").is.descendant]: "text-red-500", - // ['&:has(.flag-error)']: "text-red-500", - borderWidth: 1, - }), - )} - > - Mixing a marker computed-key with a sibling CSS property in one object now type-checks — - relation types are concrete literal templates, so TS keeps the computed key as a named - property instead of widening to a string index signature. -

Date: Sat, 2 May 2026 16:56:30 -0700 Subject: [PATCH 3/3] conditions-stash: drop normalizeConditionName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panda's resolved `config.conditions` always uses unprefixed keys (`hover`, `dark`, `myCustomCond`) — the leading `_` you write in panda.config.ts is stripped during resolution. The transform already slices `_` from `m._` before looking up here, so no normalization is needed on the stash side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bearbones-vite/src/conditions-stash.ts | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/bearbones-vite/src/conditions-stash.ts b/packages/bearbones-vite/src/conditions-stash.ts index 91758c4..8dc3527 100644 --- a/packages/bearbones-vite/src/conditions-stash.ts +++ b/packages/bearbones-vite/src/conditions-stash.ts @@ -4,13 +4,17 @@ import { preset as pandaPreset } from "@pandacss/preset-base"; * Module-scoped stash of the host project's resolved Panda condition values. * * Populated from `config.conditions` in the `config:resolved` hook (see - * `index.ts`). Until that hook fires we fall back to the conditions shipped by - * `@pandacss/preset-base` so unit tests that bypass the hook pipeline still - * see the standard `_hover`/`_focus`/etc. vocabulary. + * `index.ts`). Until that hook fires we fall back to the conditions shipped + * by `@pandacss/preset-base` so unit tests that bypass the hook pipeline + * still see the standard `hover`/`focus`/etc. vocabulary. * - * Values are stored under keys with any leading `_` stripped, so users can - * write `m._hover` regardless of whether the source preset declared the - * condition as `hover` (preset-base style) or `_hover` (typical user style). + * Keys are stored exactly as Panda emits them. Panda's resolved + * `config.conditions` always uses the unprefixed form (`hover`, `dark`, + * `myCustomCond`) — the leading-`_` you write in `panda.config.ts` is + * stripped during resolution. The user-facing API exposes them through + * `marker._`, and the transform slices the leading `_` before + * looking up here, so the round-trip stays consistent without any + * normalization step on this side. * * Only string condition values are kept — at-rule conditions and structured * tokens are out of scope for relational marker chains, which require an `&` @@ -19,22 +23,13 @@ import { preset as pandaPreset } from "@pandacss/preset-base"; let CONDITIONS: Record = loadFallbackConditions(); -/** - * Strip a single leading `_` from a condition key so user-written `_dark`, - * preset-base `hover`, and codegen-emitted `_focusVisible` all hash to the - * same lookup name. - */ -export function normalizeConditionName(key: string): string { - return key.startsWith("_") ? key.slice(1) : key; -} - function loadFallbackConditions(): Record { const out: Record = {}; const conditions = (pandaPreset as { conditions?: Record }).conditions; if (!conditions || typeof conditions !== "object") return out; for (const [key, value] of Object.entries(conditions)) { if (typeof value !== "string") continue; - out[normalizeConditionName(key)] = value; + out[key] = value; } return out; } @@ -47,17 +42,18 @@ export function setConditions(conditions: Record): void { const next: Record = {}; for (const [key, value] of Object.entries(conditions)) { if (typeof value !== "string") continue; - next[normalizeConditionName(key)] = value; + next[key] = value; } CONDITIONS = next; } /** - * Look up a condition value by normalized name (no leading `_`). Returns - * `undefined` if the condition isn't registered. + * Look up a condition value by name (e.g. `hover`, `dark`). The transform + * passes in the stripped form of `m._`. Returns `undefined` if the + * condition isn't registered. */ export function getCondition(name: string): string | undefined { - return CONDITIONS[normalizeConditionName(name)]; + return CONDITIONS[name]; } /**