chore: migrate package sources from Aphrody

This commit is contained in:
aphrody-dev committed 2026-10-12 08:23:43 +02:00
1 parent dbd4641c40
commit d97c932687
10 files changed
+531 -2

No files matched your search

+21 -2
View File
@@ -1,2 +1,21 @@
# aphrody-m3-front
Aphrody package @aphrody/m3-front
<!-- SPDX-License-Identifier: Apache-2.0 -->
# `@aphrody/m3-front`
Framework-agnostic front layer over `@aphrody/material-web` and `@aphrody/m3-tokens`. No React, no build step.
```ts
import "@aphrody/m3-front"; // defines <m3-theme>
import { registerM3, observeWindowClass } from "@aphrody/m3-front";
await registerM3("all"); // lazy md-* catalog ("aphrody" for the extensions)
observeWindowClass(); // data-m3-width / data-m3-height / data-m3-nav on <html>
```
```html
<m3-theme seed="#6750a4" mode="auto" variant="tonalSpot"> ... md-* elements ... </m3-theme>
```
`<m3-theme>` scopes the dynamic colour roles to its subtree, follows `prefers-color-scheme` in `auto`, and
fires `m3-theme-change`. `registerM3Component("button/filled-button")` loads one element.
`bun test test/` (happy-dom), `bun run typecheck`.
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["@aphrody/m3-config/test/dom"]
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@aphrody/m3-front",
"version": "1.4.4",
"description": "Framework-agnostic Material 3 front layer: <m3-theme> web component, lazy material-web registration and window-class observation on top of @aphrody/material-web and @aphrody/m3-tokens.",
"license": "Apache-2.0",
"author": "aphrody-code",
"repository": {
"type": "git",
"url": "git+https://github.com/aphrody-labs/aphrody-m3.git",
"directory": "packages/m3-front"
},
"files": [
"src"
],
"type": "module",
"sideEffects": [
"./src/theme-element.ts"
],
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./theme-element": "./src/theme-element.ts",
"./register": "./src/register.ts",
"./window-class": "./src/window-class.ts",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "bun test test/",
"typecheck": "bun check -p tsconfig.json --noEmit",
"lint": "oxlint src test"
},
"dependencies": {
"@aphrody/m3-tokens": "workspace:*",
"@aphrody/material-web": "catalog:"
},
"devDependencies": {
"@aphrody/bun": "^3.3.0",
"@aphrody/m3-config": "workspace:*",
"typescript": "catalog:"
},
"engines": {
"bun": ">=1.4.4"
}
}
+5
View File
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
export { M3ThemeElement, defineM3Theme, type ThemeMode } from "./theme-element.ts";
export { registerM3, registerM3Component, whenDefined, type M3Catalog } from "./register.ts";
export { observeWindowClass, windowClassFor, type WindowClassState } from "./window-class.ts";
export { cssFromSeed, schemeFromSeed, applyDynamicColor } from "@aphrody/m3-tokens/dynamic-color";
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: Apache-2.0
// Lazy registration of the @aphrody/material-web element catalogs. Nothing is imported until asked,
// so the front layer stays SSR-safe and tree-shakable.
export type M3Catalog = "all" | "aphrody";
const CATALOGS: Record<M3Catalog, () => Promise<unknown>> = {
all: () => import("@aphrody/material-web/all.js"),
aphrody: () => import("@aphrody/material-web/aphrody-components.js"),
};
/** Load whole catalogs: `all` (upstream md-*) and `aphrody` (Aphrody extension elements). */
export async function registerM3(...catalogs: M3Catalog[]): Promise<void> {
for (const catalog of catalogs) {
if (!Object.hasOwn(CATALOGS, catalog)) throw new Error(`invalid M3 catalog "${catalog}"`);
}
await Promise.all(catalogs.map((c) => CATALOGS[c]()));
}
/**
* Load single components by material-web path, e.g. `"button/filled-button"` or `"switch/switch"`.
* Only a-z, 0-9, `-` and one `/` are accepted.
*/
export async function registerM3Component(...paths: string[]): Promise<void> {
for (const path of paths) {
if (!/^[a-z0-9-]+\/[a-z0-9-]+$/.test(path)) throw new Error(`invalid component path "${path}"`);
}
await Promise.all(
paths.map((path) => import(/* @vite-ignore */ `@aphrody/material-web/${path}.js`)),
);
}
/** Resolve once every tag is defined. */
export async function whenDefined(tags: readonly string[]): Promise<void> {
await Promise.all(tags.map((t) => customElements.whenDefined(t)));
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: Apache-2.0
// <m3-theme seed="#6750a4" mode="auto" variant="tonalSpot" contrast="0"> scopes a dynamic M3 colour
// scheme to its subtree (display: contents) with no framework: any <md-*> inside re-themes live.
import { schemeFromSeed, type SchemeVariant } from "@aphrody/m3-tokens/dynamic-color";
export type ThemeMode = "light" | "dark" | "auto";
// Importing the pure front helpers must also work in server renderers without a DOM.
// The browser receives the real HTMLElement; the server never constructs or registers elements.
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- The SSR fallback is only a constructor base and never instantiated.
const HTMLElementBase = globalThis.HTMLElement ?? (class {} as typeof HTMLElement);
export class M3ThemeElement extends HTMLElementBase {
static observedAttributes = ["seed", "mode", "variant", "contrast"];
#applied: string[] = [];
#media: MediaQueryList | null = null;
#listener = (): void => {
if (this.mode === "auto") this.apply();
};
connectedCallback(): void {
this.style.display = "contents";
this.#media =
this.ownerDocument.defaultView?.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
this.#media?.addEventListener("change", this.#listener);
this.apply();
}
disconnectedCallback(): void {
this.#media?.removeEventListener("change", this.#listener);
this.#media = null;
this.clear();
this.style.removeProperty("color-scheme");
delete this.dataset["resolvedMode"];
}
attributeChangedCallback(): void {
if (this.isConnected) this.apply();
}
get seed(): string {
return this.getAttribute("seed") ?? "#6750a4";
}
set seed(v: string) {
this.setAttribute("seed", v);
}
get mode(): ThemeMode {
const m = this.getAttribute("mode");
return m === "light" || m === "dark" ? m : "auto";
}
set mode(v: ThemeMode) {
this.setAttribute("mode", v);
}
get variant(): SchemeVariant {
return (this.getAttribute("variant") ?? "tonalSpot") as SchemeVariant;
}
set variant(v: SchemeVariant) {
this.setAttribute("variant", v);
}
get contrast(): number {
return Number(this.getAttribute("contrast") ?? 0);
}
set contrast(v: number) {
this.setAttribute("contrast", String(v));
}
/** The mode in effect (auto resolves through prefers-color-scheme). */
get resolvedMode(): "light" | "dark" {
if (this.mode !== "auto") return this.mode;
return this.#media?.matches ? "dark" : "light";
}
apply(): void {
const dark = this.resolvedMode === "dark";
// Derive before changing styles: rejected inputs keep the last complete scheme.
const roles = schemeFromSeed(this.seed, {
dark,
variant: this.variant,
contrastLevel: this.contrast,
});
this.clear();
for (const [k, val] of Object.entries(roles)) {
this.style.setProperty(k, val);
this.#applied.push(k);
}
this.style.setProperty("color-scheme", dark ? "dark" : "light");
this.dataset["resolvedMode"] = dark ? "dark" : "light";
this.dispatchEvent(
new CustomEvent("m3-theme-change", {
detail: { seed: this.seed, dark, variant: this.variant, contrastLevel: this.contrast },
bubbles: true,
composed: true,
}),
);
}
private clear(): void {
for (const k of this.#applied) this.style.removeProperty(k);
this.#applied = [];
}
}
/** Define `<m3-theme>` or a custom tag (idempotent and safe to import during SSR). */
export function defineM3Theme(tag = "m3-theme"): void {
const registry = globalThis.customElements;
if (!registry || registry.get(tag)) return;
// A CustomElementRegistry cannot associate one constructor with multiple tags.
registry.define(tag, tag === "m3-theme" ? M3ThemeElement : class extends M3ThemeElement {});
}
defineM3Theme();
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: Apache-2.0
import {
classifyHeight,
classifyWidth,
navigationFor,
panesFor,
type NavigationKind,
type WindowHeightClass,
type WindowSizeClass,
} from "@aphrody/m3-tokens/breakpoints";
export interface WindowClassState {
width: WindowSizeClass;
height: WindowHeightClass;
navigation: NavigationKind;
panes: 1 | 2 | 3;
}
export function windowClassFor(widthDp: number, heightDp: number): WindowClassState {
const width = classifyWidth(widthDp);
return {
width,
height: classifyHeight(heightDp),
navigation: navigationFor(width),
panes: panesFor(width),
};
}
/**
* Observe the window size classes and mirror them on `target` as `data-m3-width`, `data-m3-height`,
* `data-m3-nav`. Calls `onChange` when a class changes. Returns a disposer.
*/
export function observeWindowClass(
onChange?: (s: WindowClassState) => void,
target: HTMLElement = document.documentElement,
win: Window = window,
): () => void {
let last = "";
const update = (): void => {
const s = windowClassFor(win.innerWidth, win.innerHeight);
target.dataset["m3Width"] = s.width;
target.dataset["m3Height"] = s.height;
target.dataset["m3Nav"] = s.navigation;
const key = `${s.width}/${s.height}`;
if (key !== last) {
last = key;
onChange?.(s);
}
};
update();
win.addEventListener("resize", update);
return () => win.removeEventListener("resize", update);
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, test } from "bun:test";
import {
M3ThemeElement,
observeWindowClass,
registerM3Component,
windowClassFor,
} from "../src/index.ts";
describe("<m3-theme>", () => {
test("is registered and scopes the colour roles to its subtree", () => {
expect(customElements.get("m3-theme")).toBe(M3ThemeElement);
const el = document.createElement("m3-theme") as M3ThemeElement;
el.setAttribute("seed", "#6750a4");
el.setAttribute("mode", "light");
document.body.append(el);
expect(el.style.getPropertyValue("--md-sys-color-primary")).toMatch(/^#[0-9a-f]{6}$/);
expect(el.dataset["resolvedMode"]).toBe("light");
const lightSurface = el.style.getPropertyValue("--md-sys-color-surface");
el.mode = "dark";
expect(el.dataset["resolvedMode"]).toBe("dark");
expect(el.style.getPropertyValue("--md-sys-color-surface")).not.toBe(lightSurface);
expect(document.documentElement.style.getPropertyValue("--md-sys-color-primary")).toBe("");
el.remove();
expect(el.style.getPropertyValue("--md-sys-color-primary")).toBe("");
});
test("emits m3-theme-change", () => {
const el = document.createElement("m3-theme") as M3ThemeElement;
let seen = 0;
el.addEventListener("m3-theme-change", () => seen++);
document.body.append(el);
el.seed = "#336699";
expect(seen).toBeGreaterThanOrEqual(2);
el.remove();
});
});
describe("window classes", () => {
test("classifies width and height", () => {
expect(windowClassFor(500, 400)).toEqual({
width: "compact",
height: "compact",
navigation: "bar",
panes: 1,
});
expect(windowClassFor(1300, 950)).toEqual({
width: "large",
height: "expanded",
navigation: "rail-expanded",
panes: 2,
});
});
test("mirrors classes on the target and disposes", () => {
const target = document.createElement("div");
const seen: string[] = [];
const dispose = observeWindowClass((s) => seen.push(s.width), target, {
innerWidth: 700,
innerHeight: 500,
addEventListener() {},
removeEventListener() {},
} as unknown as Window);
expect(target.dataset["m3Width"]).toBe("medium");
expect(target.dataset["m3Height"]).toBe("medium");
expect(target.dataset["m3Nav"]).toBe("rail-collapsed");
expect(seen).toEqual(["medium"]);
dispose();
});
});
describe("registration", () => {
test("rejects unsafe component paths", async () => {
await expect(registerM3Component("../evil")).rejects.toThrow("invalid component path");
});
});
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, describe, expect, test } from "bun:test";
import { schemeFromSeed } from "@aphrody/m3-tokens/dynamic-color";
import { defineM3Theme, M3ThemeElement, observeWindowClass, registerM3 } from "../src/index.ts";
const originalMatchMedia = window.matchMedia;
afterEach(() => {
document.body.replaceChildren();
window.matchMedia = originalMatchMedia;
});
function colorPreference(dark = false) {
const listeners = new Set<() => void>();
const media = {
matches: dark,
addEventListener(_type: string, listener: () => void) {
listeners.add(listener);
},
removeEventListener(_type: string, listener: () => void) {
listeners.delete(listener);
},
};
window.matchMedia = () => media as unknown as MediaQueryList;
return {
listeners,
change(value: boolean) {
media.matches = value;
for (const listener of listeners) listener();
},
};
}
describe("front production contract", () => {
test("imports all public front helpers in a server without DOM globals", async () => {
const child = Bun.spawn(
[
process.execPath,
"-e",
'const front = await import("@aphrody/m3-front"); front.defineM3Theme(); console.log(front.windowClassFor(600, 480).width);',
],
{
windowsHide: true,
cwd: Bun.fileURLToPath(new URL("..", import.meta.url)),
stdout: "pipe",
stderr: "pipe",
},
);
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
expect(stderr).toBe("");
expect(code).toBe(0);
expect(stdout.trim()).toBe("medium");
});
test("registers independent custom tag aliases idempotently", () => {
defineM3Theme("m3-custom-theme");
const ctor = customElements.get("m3-custom-theme");
defineM3Theme("m3-custom-theme");
expect(customElements.get("m3-custom-theme")).toBe(ctor);
const element = document.createElement("m3-custom-theme");
expect(element).toBeInstanceOf(M3ThemeElement);
document.body.append(element);
expect(element.style.getPropertyValue("--md-sys-color-primary")).toMatch(/^#[0-9a-f]{6}$/);
});
test("reacts to contrast and variant and announces complete roles across shadow boundaries", () => {
colorPreference();
const host = document.createElement("div");
document.body.append(host);
const shadow = host.attachShadow({ mode: "open" });
const element = document.createElement("m3-theme") as M3ThemeElement;
element.dir = "rtl";
const changes: Array<{ variant: string; contrastLevel: number }> = [];
const onChange = (event: Event) => changes.push((event as CustomEvent).detail);
document.addEventListener("m3-theme-change", onChange);
try {
shadow.append(element);
element.variant = "fidelity";
element.contrast = 1;
const roles = schemeFromSeed(element.seed, { variant: "fidelity", contrastLevel: 1 });
for (const [role, value] of Object.entries(roles)) {
expect(element.style.getPropertyValue(role)).toBe(value);
}
expect(element.dir).toBe("rtl");
expect(changes.at(-1)).toMatchObject({ variant: "fidelity", contrastLevel: 1 });
} finally {
document.removeEventListener("m3-theme-change", onChange);
}
});
test("only auto mode follows system changes and reconnects with one live subscription", () => {
const preference = colorPreference();
const element = document.createElement("m3-theme") as M3ThemeElement;
let events = 0;
element.addEventListener("m3-theme-change", () => events++);
document.body.append(element);
expect(preference.listeners.size).toBe(1);
preference.change(true);
expect(element.resolvedMode).toBe("dark");
element.mode = "light";
const beforeChange = events;
preference.change(false);
expect(events).toBe(beforeChange);
element.mode = "auto";
element.remove();
expect(preference.listeners.size).toBe(0);
expect(element.style.getPropertyValue("--md-sys-color-primary")).toBe("");
expect(element.style.getPropertyValue("color-scheme")).toBe("");
expect(element.dataset["resolvedMode"]).toBeUndefined();
document.body.append(element);
expect(preference.listeners.size).toBe(1);
preference.change(true);
expect(element.dataset["resolvedMode"]).toBe("dark");
});
test("rejects invalid contrast atomically instead of silently resetting it", () => {
const element = document.createElement("m3-theme") as M3ThemeElement;
document.body.append(element);
const style = element.style.cssText;
let events = 0;
element.addEventListener("m3-theme-change", () => events++);
expect(() => element.setAttribute("contrast", "invalid")).toThrow("contrast");
expect(element.style.cssText).toBe(style);
expect(events).toBe(0);
element.contrast = 0.5;
expect(element.contrast).toBe(0.5);
expect(events).toBe(1);
});
test("adaptive observation publishes changes once and removes its exact listener", () => {
const target = document.createElement("div");
let width = 599;
let resize: (() => void) | undefined;
let removed: (() => void) | undefined;
const win = {
get innerWidth() {
return width;
},
innerHeight: 900,
addEventListener(_type: string, listener: () => void) {
resize = listener;
},
removeEventListener(_type: string, listener: () => void) {
removed = listener;
},
};
const states: string[] = [];
const dispose = observeWindowClass(
(state) => states.push(state.width),
target,
win as unknown as Window,
);
width = 600;
resize?.();
resize?.();
width = 840;
resize?.();
expect(states).toEqual(["compact", "medium", "expanded"]);
expect(target.dataset["m3Nav"]).toBe("rail-collapsed");
dispose();
expect(removed).toBe(resize);
});
test("rejects prototype names as catalog keys", async () => {
await expect(registerM3("__proto__" as "all")).rejects.toThrow("invalid M3 catalog");
await expect(registerM3("constructor" as "all")).rejects.toThrow("invalid M3 catalog");
});
});
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "@aphrody/m3-config/tsconfig.json",
"compilerOptions": { "types": ["bun", "node"] },
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}