Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/packages/sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ npm install @hyperframes/sdk
| `@hyperframes/sdk/adapters/memory` | In-memory persistence adapter for tests, demos, and ephemeral sessions |
| `@hyperframes/sdk/adapters/fs` | Node.js filesystem persistence adapter with version history |
| `@hyperframes/sdk/adapters/headless` | No-op preview adapter for agents, CI, and server-side editing |
| `@hyperframes/sdk/adapters/iframe` | Same-origin iframe preview and visual-paint queries |
| `@hyperframes/sdk/visual-paint` | CSS colour alpha and transparency helpers for browser hosts |

## Quick Start

Expand Down
17 changes: 17 additions & 0 deletions docs/sdk/reference/utilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ This page covers everything exported from `@hyperframes/sdk` that is not covered

---

## Visual paint utilities

```typescript
import { cssColorAlpha, isTransparentColor } from "@hyperframes/sdk/visual-paint";
```

Use these helpers when a browser host needs the same colour semantics as the iframe preview adapter. `cssColorAlpha()` reads legacy comma syntax and modern slash syntax for RGB and HSL colours, including percentage alpha. Unknown colour syntaxes fail safe as opaque.

```typescript
cssColorAlpha("rgb(255 255 255 / 25%)"); // 0.25
isTransparentColor("hsl(0 0% 100% / 0)"); // true
```

`isTransparentColor()` also treats an empty computed value as transparent, matching the SDK's visual-paint checks.

---

## History Module

```typescript
Expand Down
32 changes: 5 additions & 27 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,11 +296,7 @@

function hasPaint(style) {
const backgroundColor = style.backgroundColor || "";
const hasBackground =
backgroundColor !== "" &&
backgroundColor !== "transparent" &&
!backgroundColor.endsWith(", 0)") &&
backgroundColor !== "rgba(0, 0, 0, 0)";
const hasBackground = backgroundColor !== "" && colorAlpha(backgroundColor) > 0;
const hasImage = style.backgroundImage && style.backgroundImage !== "none";
const hasBorder =
parsePx(style.borderTopWidth) +
Expand Down Expand Up @@ -548,28 +544,10 @@
return !!element.closest("[data-layout-allow-overlap]");
}

function isTransparentColor(color) {
return (
!color || color === "transparent" || color === "rgba(0, 0, 0, 0)" || color.endsWith(", 0)")
);
}

function alphaFromParts(parts, index) {
if (parts.length <= index) return 1;
const raw = parts[index].trim();
return raw.endsWith("%") ? parsePx(raw) / 100 : parsePx(raw);
}

// Alpha of a CSS colour; 1 when no alpha component is present. Handles both
// legacy `rgba(r, g, b, a)` and modern `rgb(r g b / a)` syntaxes.
function colorAlpha(color) {
const match = (color || "").match(/rgba?\(([^)]+)\)/);
if (!match) return 1;
const body = match[1];
return body.includes(",")
? alphaFromParts(body.split(","), 3)
: alphaFromParts(body.split("/"), 1);
}
// Injected from @hyperframes/core/visual-paint by prepareBrowserScript. Keeping the
// binding lexical makes the final browser script standalone without installing globals.
const colorAlpha = __hyperframesCssColorAlpha;
const isTransparentColor = (color) => colorAlpha(color) === 0;

// A text block competes for space only when it is solid: watermark-style text
// (low colour alpha) is decorative and exempt, as are elements opted out with
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { prepareBrowserScript } from "../utils/browserScript";

const __dirname = dirname(fileURLToPath(import.meta.url));
const script = readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8");
const script = prepareBrowserScript(
"layout-audit.browser.js",
readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8"),
);
const contrastScript = readFileSync(join(__dirname, "contrast-audit.browser.js"), "utf-8");

interface RectInput {
Expand Down Expand Up @@ -1732,6 +1736,14 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});

it("ignores zero-alpha HSL overlays", () => {
const issues = auditOcclusionScene({
overlayStyle: { backgroundColor: "hsl(0 0% 100% / 0%)" },
topmostId: "overlay",
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});

it("does not treat transparent pixels in an image as text occlusion", () => {
const issues = auditImageOcclusionScene(0);
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { prepareBrowserScript } from "../utils/browserScript.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
import {
Expand Down Expand Up @@ -288,7 +289,7 @@ async function runLayoutAudit(
export function loadBrowserScript(name: string): string {
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
for (const candidate of candidates) {
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
if (existsSync(candidate)) return prepareBrowserScript(name, readFileSync(candidate, "utf-8"));
}
throw new Error(`Missing browser script ${name}`);
}
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/utils/browserScript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { loadBrowserScript } from "../commands/layout";
import { prepareBrowserScript } from "./browserScript";

const here = dirname(fileURLToPath(import.meta.url));
const layoutAuditSource = readFileSync(join(here, "../commands/layout-audit.browser.js"), "utf-8");

describe("prepareBrowserScript", () => {
it("produces an executable standalone audit without leaking the injected helper", () => {
const prepared = prepareBrowserScript("layout-audit.browser.js", layoutAuditSource);

window.eval(prepared);

expect(Reflect.get(window, "__hyperframesLayoutAudit")).toBeTypeOf("function");
expect(Reflect.get(window, "__hyperframesCssColorAlpha")).toBeUndefined();
});

it("leaves unrelated standalone scripts unchanged", () => {
expect(prepareBrowserScript("motion-sample.browser.js", "window.example = true;")).toBe(
"window.example = true;",
);
});

it("is applied by the CLI loader", () => {
Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
window.eval(loadBrowserScript("layout-audit.browser.js"));

expect(Reflect.get(window, "__hyperframesLayoutAudit")).toBeTypeOf("function");
expect(Reflect.get(window, "__hyperframesCssColorAlpha")).toBeUndefined();
});
});
14 changes: 14 additions & 0 deletions packages/cli/src/utils/browserScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { cssColorAlpha } from "@hyperframes/core/visual-paint";

const LAYOUT_AUDIT_SCRIPT = "layout-audit.browser.js";
const COLOR_ALPHA_BINDING = "__hyperframesCssColorAlpha";

/**
* Inject shared paint primitives into scripts that must execute without module imports.
* The outer scope is private and disappears after the audit installs its window hooks.
*/
export function prepareBrowserScript(name: string, source: string): string {
if (name !== LAYOUT_AUDIT_SCRIPT) return source;
const serializedColorAlpha = cssColorAlpha.toString();
return `(function () {\nconst ${COLOR_ALPHA_BINDING} = ${serializedColorAlpha};\n${source}\n})();`;
}
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@
"types": "./dist/colorGrading.d.ts",
"environments": ["browser", "bun", "node"]
},
"./visual-paint": {
"source": "./src/visualPaint.ts",
"runtime": "./dist/visualPaint.js",
"types": "./dist/visualPaint.d.ts",
"environments": ["browser", "bun", "node"]
},
"./color-luts": {
"source": "./src/colorLuts.ts",
"runtime": "./dist/colorLuts.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@
"import": "./src/colorGrading.ts",
"types": "./src/colorGrading.ts"
},
"./visual-paint": {
"bun": "./src/visualPaint.ts",
"node": "./dist/visualPaint.js",
"import": "./src/visualPaint.ts",
"types": "./src/visualPaint.ts"
},
"./color-luts": {
"bun": "./src/colorLuts.ts",
"node": "./dist/colorLuts.js",
Expand Down Expand Up @@ -362,6 +368,10 @@
"import": "./dist/colorGrading.js",
"types": "./dist/colorGrading.d.ts"
},
"./visual-paint": {
"import": "./dist/visualPaint.js",
"types": "./dist/visualPaint.d.ts"
},
"./color-luts": {
"import": "./dist/colorLuts.js",
"types": "./dist/colorLuts.d.ts"
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/visualPaint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { cssColorAlpha, isTransparentColor } from "./visualPaint";

describe("cssColorAlpha", () => {
it.each([
["transparent", 0],
["rgba(255, 255, 255, 0)", 0],
["rgb(255 255 255 / 0%)", 0],
["hsl(0 0% 100% / 25%)", 0.25],
["rgb(1, 2, 3)", 1],
["#fff", 1],
])("reads %s as alpha %s", (color, expected) => {
expect(cssColorAlpha(color)).toBe(expected);
});

it("treats every zero-alpha functional colour as transparent", () => {
expect(isTransparentColor("hsla(120, 50%, 50%, 0)")).toBe(true);
expect(isTransparentColor("rgb(255 255 255 / 0.01)")).toBe(false);
});
});
39 changes: 39 additions & 0 deletions packages/core/src/visualPaint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Return the alpha channel of a CSS functional colour.
*
* Browsers serialize computed colours as rgb()/rgba(), but accepting hsl()/hsla()
* too keeps this helper useful for test doubles and authored values. Unknown colour
* syntaxes are treated as opaque because they may still paint.
*
* Keep this function self-contained: the CLI serializes it into its standalone
* browser audit script so that browser-side paint checks share this exact parser.
*/
export function cssColorAlpha(value: string): number {
if (!value) return 1;
if (value.trim().toLowerCase() === "transparent") return 0;

const match = /^(?:rgba?|hsla?)\(([^)]*)\)$/i.exec(value.trim());
if (!match) return 1;

const body = match[1] ?? "";
let rawAlpha: string | undefined;
const slash = body.lastIndexOf("/");
if (slash >= 0) {
rawAlpha = body.slice(slash + 1).trim();
} else {
const commaParts = body.split(",");
if (commaParts.length === 4) rawAlpha = commaParts[3]?.trim();
}

if (!rawAlpha) return 1;
const percentage = rawAlpha.endsWith("%");
const parsed = Number.parseFloat(rawAlpha);
if (!Number.isFinite(parsed)) return 1;
const alpha = percentage ? parsed / 100 : parsed;
return Math.min(1, Math.max(0, alpha));
}

/** Whether a CSS colour contributes no painted pixels. */
export function isTransparentColor(value: string): boolean {
return !value || cssColorAlpha(value) === 0;
}
6 changes: 6 additions & 0 deletions packages/sdk/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@
"runtime": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts",
"environments": ["browser", "bun", "node"]
},
"./visual-paint": {
"source": "./src/visualPaint.ts",
"runtime": "./dist/visualPaint.js",
"types": "./dist/visualPaint.d.ts",
"environments": ["browser", "bun", "node"]
}
}
}
9 changes: 9 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
"bun": "./src/editing/affordances.ts",
"import": "./src/editing/affordances.ts",
"types": "./src/editing/affordances.ts"
},
"./visual-paint": {
"bun": "./src/visualPaint.ts",
"import": "./src/visualPaint.ts",
"types": "./src/visualPaint.ts"
}
},
"publishConfig": {
Expand Down Expand Up @@ -71,6 +76,10 @@
"./editing": {
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts"
},
"./visual-paint": {
"import": "./dist/visualPaint.js",
"types": "./dist/visualPaint.d.ts"
}
},
"main": "./dist/index.js",
Expand Down
22 changes: 1 addition & 21 deletions packages/sdk/src/adapters/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
DraftProps,
PaintQueryOptions,
} from "./types.js";
import { isTransparentColor } from "@hyperframes/core/visual-paint";
import type { EditOp, Composition } from "../types.js";
import { applyPatchesToDocument, applyOverrideSet } from "../engine/apply-patches.js";

Expand Down Expand Up @@ -514,27 +515,6 @@ export const INTRINSIC_PAINT_TAGS: ReadonlySet<string> = new Set([
"svg",
]);

/**
* Does this computed colour put down no ink?
*
* The `transparent` keyword computes to `rgba(0, 0, 0, 0)`, but ANY colour can carry a zero
* alpha — `rgba(255, 255, 255, 0)` is exactly as invisible and is what you get from fading a
* white background out. Matching known spellings misses those, so the alpha is read instead.
*/
function isTransparentColor(value: string): boolean {
if (!value || value === "transparent") return true;
// rgb/hsl and their -a forms all carry alpha as the fourth component. Computed
// background-color is serialized to rgb() by every engine we target, but matching both
// costs one alternation and removes the dependency on that.
const inner = /^(?:rgba?|hsla?)\(([^)]*)\)$/.exec(value)?.[1];
if (inner === undefined) return false;
// Handles both the legacy comma form and the `rgb(r g b / a)` slash form.
const parts = inner.split(/[\s,/]+/).filter(Boolean);
// An rgb() with no alpha component is fully opaque.
const alpha = parts[3];
return alpha !== undefined && Number.parseFloat(alpha) === 0;
}

const BORDER_SIDES = ["top", "right", "bottom", "left"] as const;

/**
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ export type {
export { createMemoryAdapter } from "./adapters/memory.js";
export { createHeadlessAdapter } from "./adapters/headless.js";
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";
export { cssColorAlpha, isTransparentColor } from "./visualPaint.js";
8 changes: 8 additions & 0 deletions packages/sdk/src/visualPaint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Shared visual-paint primitives for SDK hosts such as Studio.
*
* The parser itself lives in Core so the CLI can serialize it into its dependency-free
* browser audit without depending on the SDK package. SDK remains the public home for
* host-facing paint semantics.
*/
export { cssColorAlpha, isTransparentColor } from "@hyperframes/core/visual-paint";
Loading
Loading