Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/core-property-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@styleframe/core": minor
---

Add a first-class `property()` factory for typed custom properties. It composes `variable()` (the value declaration) with an `@property` at-rule (the registration) and returns the same `Variable` — one custom property in the graph, distinct at authoring:

```ts
property("space", "0px", { syntax: "<length>", inherits: false });
// --space: 0px;
// @property --space { syntax: "<length>"; inherits: false; initial-value: 0px; }
```

`initialValue` defaults to the value; the `@property` block is registered once per name. Purely additive — the raw `atRule('property', …)` hatch is unchanged, and output is byte-identical when `property()` is unused. The `syntax`→TS-type narrowing is a separate follow-up.
5 changes: 4 additions & 1 deletion engine/core/src/styleframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
createCssFunction,
createKeyframesFunction,
createMediaFunction,
createPropertyFunction,
createRefFunction,
createSelectorFunction,
createVariableFunction,
Expand All @@ -22,6 +23,7 @@ export interface Styleframe {
id: string;
root: Root;
variable: ReturnType<typeof createVariableFunction>;
property: ReturnType<typeof createPropertyFunction>;
selector: ReturnType<typeof createSelectorFunction>;
utility: ReturnType<typeof createUtilityFunction>;
modifier: ReturnType<typeof createModifierFunction>;
Expand All @@ -46,13 +48,14 @@ export function styleframe(userOptions?: StyleframeOptions): Styleframe {
const recipe = createRecipeFunction(root, root);
const theme = createThemeFunction(root, root);

const { variable, selector, atRule, keyframes, media, ref, css } =
const { variable, property, selector, atRule, keyframes, media, ref, css } =
createDeclarationsCallbackContext(root, root);

return {
id,
root,
variable,
property,
selector,
utility,
modifier,
Expand Down
5 changes: 3 additions & 2 deletions engine/core/src/tokens/declarations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,13 +714,14 @@ describe("createDeclarationsCallbackContext", () => {
});

describe("context object properties", () => {
it("should return object with exactly four properties", () => {
it("should return object with the expected properties", () => {
const context = createDeclarationsCallbackContext(root, root);
const keys = Object.keys(context);

expect(keys).toHaveLength(7);
expect(keys).toHaveLength(8);
expect(keys).toContain("atRule");
expect(keys).toContain("variable");
expect(keys).toContain("property");
expect(keys).toContain("selector");
expect(keys).toContain("keyframes");
expect(keys).toContain("media");
Expand Down
3 changes: 3 additions & 0 deletions engine/core/src/tokens/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createMediaFunction,
} from "./atRule";
import { createCssFunction } from "./css";
import { createPropertyFunction } from "./property";
import { createRefFunction } from "./ref";
import { createPropertyValueResolver } from "./resolve";
import { createSelectorFunction } from "./selector";
Expand All @@ -21,6 +22,7 @@ export function createDeclarationsCallbackContext(
root: Root,
): DeclarationsCallbackContext {
const variable = createVariableFunction(parent, root);
const property = createPropertyFunction(parent, root);
const selector = createSelectorFunction(parent, root);
const atRule = createAtRuleFunction(parent, root);
const keyframes = createKeyframesFunction(root, root);
Expand All @@ -30,6 +32,7 @@ export function createDeclarationsCallbackContext(

return {
variable,
property,
selector,
keyframes,
atRule,
Expand Down
1 change: 1 addition & 0 deletions engine/core/src/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./css";
export * from "./declarations";
export * from "./atRule";
export * from "./modifier";
export * from "./property";
export * from "./ref";
export * from "./root";
export * from "./selector";
Expand Down
114 changes: 114 additions & 0 deletions engine/core/src/tokens/property.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { AtRule, Root } from "../types";
import { createPropertyFunction } from "./property";
import { createRoot } from "./root";
import { createSelectorFunction } from "./selector";

function findPropertyRule(root: Root, name: string): AtRule | undefined {
return root.children.find(
(child): child is AtRule =>
child.type === "at-rule" &&
child.identifier === "property" &&
child.rule === name,
);
}

describe("createPropertyFunction", () => {
let root: Root;
let property: ReturnType<typeof createPropertyFunction>;

beforeEach(() => {
root = createRoot();
property = createPropertyFunction(root, root);
});

it("should create the underlying variable and return it", () => {
const result = property("space", "0px", { syntax: "<length>" });

expect(result).toEqual({
type: "variable",
id: expect.any(String),
name: "space",
value: "0px",
parentId: expect.any(String),
});
expect(root.variables).toHaveLength(1);
expect(root.variables[0]).toBe(result);
});

it("should register an @property at-rule on root", () => {
property("space", "0px", { syntax: "<length>" });

const rule = findPropertyRule(root, "--space");
expect(rule).toBeDefined();
expect(rule?.declarations).toEqual({
syntax: '"<length>"',
inherits: "false",
"initial-value": "0px",
});
});

it("should default inherits to false and honor inherits: true", () => {
property("a", "0px", { syntax: "<length>" });
property("b", "0px", { syntax: "<length>", inherits: true });

expect(findPropertyRule(root, "--a")?.declarations.inherits).toBe("false");
expect(findPropertyRule(root, "--b")?.declarations.inherits).toBe("true");
});

it("should default initial-value to the variable value", () => {
property("brand", "#006cff", { syntax: "<color>" });

expect(
findPropertyRule(root, "--brand")?.declarations["initial-value"],
).toBe("#006cff");
});

it("should honor an explicit initialValue", () => {
property("space", "1rem", { syntax: "<length>", initialValue: "0px" });

const rule = findPropertyRule(root, "--space");
expect(rule?.declarations["initial-value"]).toBe("0px");
expect(root.variables[0]?.value).toBe("1rem");
});

it("should register the @property block only once per name", () => {
property("space", "0px", { syntax: "<length>" });
const updated = property("space", "4px", { syntax: "<length>" });

const propertyRules = root.children.filter(
(child) => child.type === "at-rule" && child.identifier === "property",
);
expect(propertyRules).toHaveLength(1);
// The value declaration still updates through the variable factory.
expect(updated.value).toBe("4px");
expect(root.variables).toHaveLength(1);
});

it("should support numeric values", () => {
property("z", 10, { syntax: "<number>" });

expect(findPropertyRule(root, "--z")?.declarations["initial-value"]).toBe(
10,
);
});

it("should keep the value declaration local while hoisting @property to root", () => {
const selector = createSelectorFunction(root, root);
const card = selector(".card", ({ property }) => {
property("card-space", "4px", { syntax: "<length>" });
return {};
});

// The value declaration lives in the calling container.
expect(card.variables).toHaveLength(1);
expect(card.variables[0]?.name).toBe("card-space");

// The @property registration hoists to root, not the child container.
expect(findPropertyRule(root, "--card-space")).toBeDefined();
expect(
card.children.some(
(child) => child.type === "at-rule" && child.identifier === "property",
),
).toBe(false);
});
});
68 changes: 68 additions & 0 deletions engine/core/src/tokens/property.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { AtRule, Container, Root, TokenValue, Variable } from "../types";
import { createAtRuleFunction } from "./atRule";
import { createVariableFunction } from "./variable";

export type PropertyOptions = {
/**
* The CSS `syntax` descriptor, e.g. `"<length>"`, `"<color>"`, `"*"`.
* Pass it unquoted — the quotes are added in the generated `@property` block.
*/
syntax: string;
/** The CSS `inherits` descriptor. Defaults to `false`. */
inherits?: boolean;
/**
* The CSS `initial-value` descriptor. Defaults to `value`.
* Should be a concrete value; references are emitted verbatim.
*/
initialValue?: TokenValue;
/** Same semantics as `variable`'s `default` option. */
default?: boolean;
};

/**
* Registers a typed custom property. Composes `variable` (the value
* declaration) with an `@property` at-rule (the registration), and returns the
* same `Variable` — one custom property in the graph, distinct at authoring.
*/
export function createPropertyFunction(parent: Container, root: Root) {
const variable = createVariableFunction(parent, root);
// `@property` is only valid at the top level, so register it on root.
const atRule = createAtRuleFunction(root, root);

return function property<Name extends string>(
nameOrInstance: Name | Variable<Name>,
value: TokenValue,
options: PropertyOptions,
): Variable<Name> {
const instance = variable(nameOrInstance, value, {
default: options.default ?? false,
});

const registrationName = `--${instance.name}`;

// One `@property` block per custom property name. A later call with a
// different value updates the variable declaration but keeps the first
// `initial-value` — the cascade lands on the latest value regardless.
const alreadyRegistered = root.children.some(
(child): child is AtRule =>
child.type === "at-rule" &&
child.identifier === "property" &&
child.rule === registrationName,
);

if (!alreadyRegistered) {
const initialValue =
options.initialValue !== undefined
? options.initialValue
: instance.value;

atRule("property", registrationName, {
syntax: JSON.stringify(options.syntax),
inherits: options.inherits ? "true" : "false",
"initial-value": initialValue,
});
}

return instance;
};
}
1 change: 1 addition & 0 deletions engine/core/src/tokens/selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ describe("createSelectorFunction", () => {
expect(callback).toHaveBeenCalledWith({
atRule: expect.any(Function),
variable: expect.any(Function),
property: expect.any(Function),
selector: expect.any(Function),
keyframes: expect.any(Function),
media: expect.any(Function),
Expand Down
2 changes: 2 additions & 0 deletions engine/core/src/types/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
createCssFunction,
createKeyframesFunction,
createMediaFunction,
createPropertyFunction,
createRefFunction,
createSelectorFunction,
createVariableFunction,
Expand Down Expand Up @@ -31,6 +32,7 @@ export type DeclarationsBlock = {

export type DeclarationsCallbackContext = {
variable: ReturnType<typeof createVariableFunction>;
property: ReturnType<typeof createPropertyFunction>;
selector: ReturnType<typeof createSelectorFunction>;
atRule: ReturnType<typeof createAtRuleFunction>;
keyframes: ReturnType<typeof createKeyframesFunction>;
Expand Down
48 changes: 48 additions & 0 deletions engine/transpiler/src/consume/css/property.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type {
AtRule,
Root,
StyleframeOptions,
Variable,
} from "@styleframe/core";
import { createPropertyFunction, createRoot } from "@styleframe/core";
import { consume } from "./consume";

describe("property() CSS output", () => {
let root: Root;
let property: ReturnType<typeof createPropertyFunction>;
const options: StyleframeOptions = {};

beforeEach(() => {
root = createRoot();
property = createPropertyFunction(root, root);
});

function propertyRule(name: string): AtRule {
const rule = root.children.find(
(child): child is AtRule =>
child.type === "at-rule" &&
child.identifier === "property" &&
child.rule === name,
);
if (!rule) throw new Error(`missing @property rule ${name}`);
return rule;
}

it("emits the @property registration block", () => {
property("space", "0px", { syntax: "<length>" });

expect(consume(propertyRule("--space"), options)).toBe(`@property --space {
\tsyntax: "<length>";
\tinherits: false;
\tinitial-value: 0px;
}`);
});

it("emits the value declaration through the normal variable path", () => {
const instance = property("space", "0px", {
syntax: "<length>",
}) as Variable;

expect(consume(instance, options)).toBe("--space: 0px;");
});
});
Loading