@maple-kit/lint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { captureContext } from "@maple-kit/core/overlay";
2
+ import { describeElement } from "@maple-kit/core/anchor";
3
+ //#region src/rendered/collect.ts
4
+ const INTERACTIVE_TAGS = [
5
+ "a",
6
+ "button",
7
+ "input",
8
+ "select",
9
+ "summary",
10
+ "textarea"
11
+ ];
12
+ const INTERACTIVE_ROLES = [
13
+ "button",
14
+ "checkbox",
15
+ "link",
16
+ "menuitem",
17
+ "switch",
18
+ "tab"
19
+ ];
20
+ const TEXT_CAP = 80;
21
+ function isInteractive(element) {
22
+ const tag = element.tagName.toLowerCase();
23
+ if (INTERACTIVE_TAGS.includes(tag)) return true;
24
+ const role = element.getAttribute("role");
25
+ if (role !== null && INTERACTIVE_ROLES.includes(role)) return true;
26
+ const tabIndex = element.getAttribute("tabindex");
27
+ return tabIndex !== null && Number.parseInt(tabIndex, 10) >= 0;
28
+ }
29
+ function isOpaque(color) {
30
+ if (color === "transparent") return false;
31
+ const alpha = /rgba?\([^)]*[,/]\s*([\d.]+)\s*\)/.exec(color);
32
+ return alpha === null || Number.parseFloat(alpha[1]) > 0;
33
+ }
34
+ function backdropOf(element) {
35
+ let node = element;
36
+ while (node !== null) {
37
+ const background = getComputedStyle(node).backgroundColor;
38
+ if (isOpaque(background)) return background;
39
+ node = node.parentElement;
40
+ }
41
+ return "rgb(255, 255, 255)";
42
+ }
43
+ function keyframeProperties(names) {
44
+ const wanted = names.split(",").map((name) => name.trim());
45
+ if (wanted.every((name) => name === "none" || name === "")) return [];
46
+ const found = /* @__PURE__ */ new Set();
47
+ for (const sheet of [...document.styleSheets]) {
48
+ let rules;
49
+ try {
50
+ rules = sheet.cssRules;
51
+ } catch {
52
+ continue;
53
+ }
54
+ collectKeyframes(rules, wanted, found);
55
+ }
56
+ return [...found];
57
+ }
58
+ function collectKeyframes(rules, wanted, found) {
59
+ for (const rule of [...rules]) {
60
+ if (!(rule instanceof CSSKeyframesRule) || !wanted.includes(rule.name)) continue;
61
+ for (const frame of [...rule.cssRules]) {
62
+ if (!(frame instanceof CSSKeyframeRule)) continue;
63
+ for (const property of [...frame.style]) found.add(property);
64
+ }
65
+ }
66
+ }
67
+ function paintsOwnText(element) {
68
+ return [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim() !== "");
69
+ }
70
+ function recordOf(element) {
71
+ const style = getComputedStyle(element);
72
+ const box = element.getBoundingClientRect();
73
+ return {
74
+ anchor: describeElement(element),
75
+ tag: element.tagName.toLowerCase(),
76
+ text: (element.textContent ?? "").trim().slice(0, TEXT_CAP),
77
+ paintsText: paintsOwnText(element),
78
+ interactive: isInteractive(element),
79
+ color: style.color,
80
+ backgroundColor: style.backgroundColor,
81
+ backdrop: backdropOf(element),
82
+ fontSize: Number.parseFloat(style.fontSize),
83
+ fontWeight: Number.parseFloat(style.fontWeight),
84
+ width: box.width,
85
+ height: box.height,
86
+ transitionProperty: style.transitionProperty,
87
+ transitionDuration: style.transitionDuration,
88
+ animationName: style.animationName,
89
+ animationDuration: style.animationDuration,
90
+ animationProperties: keyframeProperties(style.animationName)
91
+ };
92
+ }
93
+ function readPage() {
94
+ const elements = [...document.querySelectorAll("[data-maple-src]")];
95
+ return {
96
+ context: captureContext(),
97
+ records: elements.map(recordOf)
98
+ };
99
+ }
100
+ //#endregion
101
+ export { readPage };
@@ -0,0 +1,30 @@
1
+ import { Finding, Severity } from "../types.js";
2
+ import { StyleRecord } from "./collect.js";
3
+ import { TokenSet } from "../tokens.js";
4
+ //#region src/rendered/rules.d.ts
5
+ /** What a rule is, for the docs table and for a host overriding a severity. */
6
+ export interface RuleDefinition {
7
+ readonly id: string;
8
+ readonly severity: Severity;
9
+ /** One line, the same sentence the docs table shows. */
10
+ readonly summary: string;
11
+ }
12
+ /** The properties motion is allowed on: the two the compositor can animate. */
13
+ export declare const MOTION_SAFE: readonly ["opacity", "transform"];
14
+ /** Smallest touch target that is not a finding, from WCAG 2.5.8 AA. */
15
+ export declare const MIN_TOUCH_TARGET = 24;
16
+ /** Every rendered rule, in the order the docs list them. */
17
+ export declare const RENDERED_RULES: readonly RuleDefinition[];
18
+ /**
19
+ * Every rule that reads one pass of the page, called once per viewport by the
20
+ * driver. These are the rendered tier's own rules only: a judged rule reaches a
21
+ * model through a connector, and lands with #126 rather than here.
22
+ */
23
+ export declare function renderedFindings(records: readonly StyleRecord[], tokens: TokenSet): readonly Finding[];
24
+ /**
25
+ * Colours the page painted that no rule could judge, distinct and in the order
26
+ * they were met. A run reports these rather than quietly checking less than it
27
+ * was asked to.
28
+ */
29
+ export declare function unreadableColors(records: readonly StyleRecord[]): readonly string[];
30
+ //#endregion
@@ -0,0 +1,131 @@
1
+ import { colorKey, contrastRatio, isUnreadableColor, over, parseColor } from "../color.js";
2
+ //#region src/rendered/rules.ts
3
+ const MOTION_SAFE = ["opacity", "transform"];
4
+ const MIN_TOUCH_TARGET = 24;
5
+ const DOCS = "https://github.com/maple-kit/maple/blob/main/docs/lint.md";
6
+ const RENDERED_RULES = [
7
+ {
8
+ id: "maple/rendered-color-token",
9
+ severity: "error",
10
+ summary: "Colours come from the token set."
11
+ },
12
+ {
13
+ id: "maple/rendered-type-scale",
14
+ severity: "error",
15
+ summary: "Font sizes come from the type scale."
16
+ },
17
+ {
18
+ id: "maple/rendered-touch-target",
19
+ severity: "error",
20
+ summary: `Interactive elements are at least 24px on both axes.`
21
+ },
22
+ {
23
+ id: "maple/rendered-contrast",
24
+ severity: "error",
25
+ summary: "Text meets WCAG AA contrast."
26
+ },
27
+ {
28
+ id: "maple/rendered-motion-property",
29
+ severity: "error",
30
+ summary: "Motion animates only opacity and transform."
31
+ },
32
+ {
33
+ id: "maple/rendered-reduced-motion",
34
+ severity: "error",
35
+ summary: "Motion stops under prefers-reduced-motion."
36
+ }
37
+ ];
38
+ const SEVERITY = new Map(RENDERED_RULES.map((rule) => [rule.id, rule.severity]));
39
+ function finding(rule, record, message) {
40
+ return {
41
+ rule,
42
+ tier: "rendered",
43
+ severity: SEVERITY.get(rule) ?? "warn",
44
+ message,
45
+ anchor: record.anchor,
46
+ url: `${DOCS}#${rule.replace("maple/", "")}`
47
+ };
48
+ }
49
+ function isTokenColor(color, tokens) {
50
+ return tokens.colors.has(colorKey(color)) || tokens.colors.has(colorKey({
51
+ ...color,
52
+ a: 1
53
+ }));
54
+ }
55
+ function colorFindings(record, tokens) {
56
+ const rule = "maple/rendered-color-token";
57
+ if (tokens.colors.size === 0) return [];
58
+ return [["text colour", record.color], ["background", record.backgroundColor]].flatMap(([what, value]) => {
59
+ const color = parseColor(value);
60
+ if (!color || color.a === 0 || isTokenColor(color, tokens)) return [];
61
+ return [finding(rule, record, `The ${what} ${value} is not a token.`)];
62
+ });
63
+ }
64
+ function typeScaleFindings(record, tokens) {
65
+ if (tokens.fontSizes.size === 0 || tokens.fontSizes.has(record.fontSize)) return [];
66
+ const scale = [...tokens.fontSizes].sort((first, second) => first - second).join("px, ");
67
+ return [finding("maple/rendered-type-scale", record, `The font size ${record.fontSize}px is off the scale (${scale}px).`)];
68
+ }
69
+ function touchTargetFindings(record) {
70
+ const tooSmall = record.width < 24 || record.height < 24;
71
+ if (!record.interactive || !tooSmall || record.width === 0 || record.height === 0) return [];
72
+ const size = `${Math.round(record.width)}×${Math.round(record.height)}px`;
73
+ return [finding("maple/rendered-touch-target", record, `This ${record.tag} is ${size}, under the 24px touch target.`)];
74
+ }
75
+ function isLargeText(record) {
76
+ return record.fontSize >= 24 || record.fontSize >= 18.66 && record.fontWeight >= 700;
77
+ }
78
+ function contrastFindings(record) {
79
+ if (!record.paintsText || record.text === "") return [];
80
+ const foreground = parseColor(record.color);
81
+ const backdrop = parseColor(record.backdrop);
82
+ if (!foreground || !backdrop) return [];
83
+ const ratio = contrastRatio(over(foreground, backdrop), backdrop);
84
+ const required = isLargeText(record) ? 3 : 4.5;
85
+ if (ratio >= required) return [];
86
+ return [finding("maple/rendered-contrast", record, `Contrast is ${ratio.toFixed(2)}:1, under the ${required}:1 this text needs.`)];
87
+ }
88
+ function transitioned(record) {
89
+ return record.transitionProperty.split(",").map((property) => property.trim()).filter((property) => property !== "" && property !== "none");
90
+ }
91
+ function isStill(duration) {
92
+ return duration.split(",").every((value) => Number.parseFloat(value) === 0 || Number.isNaN(Number.parseFloat(value)));
93
+ }
94
+ function motionPropertyFindings(record) {
95
+ const rule = "maple/rendered-motion-property";
96
+ const safe = (property) => MOTION_SAFE.includes(property);
97
+ const moving = isStill(record.transitionDuration) ? [] : transitioned(record);
98
+ const animated = isStill(record.animationDuration) ? [] : record.animationProperties;
99
+ const offending = [.../* @__PURE__ */ new Set([...moving, ...animated])].filter((property) => !safe(property));
100
+ if (offending.length === 0) return [];
101
+ return [finding(rule, record, `Motion is on ${offending.join(", ")}, not opacity or transform.`)];
102
+ }
103
+ function renderedFindings(records, tokens) {
104
+ return records.flatMap((record) => [
105
+ ...colorFindings(record, tokens),
106
+ ...typeScaleFindings(record, tokens),
107
+ ...touchTargetFindings(record),
108
+ ...contrastFindings(record),
109
+ ...motionPropertyFindings(record)
110
+ ]);
111
+ }
112
+ function unreadableColors(records) {
113
+ const found = /* @__PURE__ */ new Set();
114
+ for (const record of records) for (const value of [
115
+ record.color,
116
+ record.backgroundColor,
117
+ record.backdrop
118
+ ]) if (isUnreadableColor(value)) found.add(value);
119
+ return [...found];
120
+ }
121
+ function movesBeyondFade(record) {
122
+ const moving = isStill(record.transitionDuration) ? [] : transitioned(record);
123
+ const animated = isStill(record.animationDuration) ? [] : record.animationProperties;
124
+ const properties = [.../* @__PURE__ */ new Set([...moving, ...animated])];
125
+ return properties.length > 0 && properties.some((property) => property !== "opacity");
126
+ }
127
+ function reducedMotionFindings(records) {
128
+ return records.filter((record) => movesBeyondFade(record)).map((record) => finding("maple/rendered-reduced-motion", record, "This still moves under prefers-reduced-motion."));
129
+ }
130
+ //#endregion
131
+ export { MIN_TOUCH_TARGET, MOTION_SAFE, RENDERED_RULES, colorFindings, contrastFindings, motionPropertyFindings, reducedMotionFindings, renderedFindings, touchTargetFindings, typeScaleFindings, unreadableColors };
@@ -0,0 +1,39 @@
1
+ //#region src/tokens.d.ts
2
+ /**
3
+ * The token set a rendered rule checks against, read from the same CSS files
4
+ * the static tier is configured with.
5
+ *
6
+ * A token file is read as text and scanned for custom-property declarations
7
+ * rather than parsed as a stylesheet: the rules only need the values, and a
8
+ * scan has no opinion about the selectors they were declared under.
9
+ */
10
+ /** Every value a rule is allowed to see, keyed the way the rule compares it. */
11
+ export interface TokenSet {
12
+ /** Declared colours, as `colorKey` strings. */
13
+ readonly colors: ReadonlySet<string>;
14
+ /** Declared lengths that read as a font size, in px. */
15
+ readonly fontSizes: ReadonlySet<number>;
16
+ /** Custom-property names, for a message that can name the token. */
17
+ readonly names: ReadonlyMap<string, string>;
18
+ /**
19
+ * Declarations meant to be a colour that could not be read, by name. Every
20
+ * element painted from one would be judged against a set lacking it.
21
+ */
22
+ readonly unreadable: ReadonlyMap<string, string>;
23
+ }
24
+ /** How a rem in a token file converts to the px a computed style reports. */
25
+ export declare const ROOT_FONT_SIZE = 16;
26
+ /** Converts a CSS length to px, or undefined when it is not one. */
27
+ export declare function lengthToPx(value: string, rootFontSize?: number): number | undefined;
28
+ /**
29
+ * Scans CSS text for custom properties. A theme layer that declares
30
+ * `--surface: var(--grey-100)` contributes the colour it resolves to, because
31
+ * an element painted from it computes to that colour and nothing else.
32
+ */
33
+ export declare function parseTokens(css: string, rootFontSize?: number): TokenSet;
34
+ /**
35
+ * Reads every configured token file as one sheet, so a theme file that refers
36
+ * to a base file's token resolves the way the browser resolves it.
37
+ */
38
+ export declare function readTokenFiles(paths: readonly string[], rootFontSize?: number): Promise<TokenSet>;
39
+ //#endregion
package/dist/tokens.js ADDED
@@ -0,0 +1,49 @@
1
+ import { colorKey, isUnreadableColor, parseColor } from "./color.js";
2
+ import { readFile } from "node:fs/promises";
3
+ //#region src/tokens.ts
4
+ const ROOT_FONT_SIZE = 16;
5
+ const DECLARATION = /(--\w[\w-]*)\s*:([^;}]+)/g;
6
+ const REFERENCE = /^var\(\s*(--[\w-]+)\s*(?:,([^)]*))?\)$/;
7
+ const MAX_INDIRECTION = 8;
8
+ const LENGTH = /^(-?[\d.]+)(px|rem|em)$/;
9
+ function lengthToPx(value, rootFontSize = 16) {
10
+ const match = LENGTH.exec(value.trim());
11
+ if (!match) return void 0;
12
+ const size = Number.parseFloat(match[1]);
13
+ return match[2] === "px" ? size : size * rootFontSize;
14
+ }
15
+ function resolve(value, names, depth = 0) {
16
+ const reference = REFERENCE.exec(value.trim());
17
+ if (!reference || depth >= MAX_INDIRECTION) return value;
18
+ const next = names.get(reference[1]) ?? reference[2]?.trim();
19
+ return next === void 0 ? value : resolve(next, names, depth + 1);
20
+ }
21
+ function parseTokens(css, rootFontSize = 16) {
22
+ const colors = /* @__PURE__ */ new Set();
23
+ const fontSizes = /* @__PURE__ */ new Set();
24
+ const names = /* @__PURE__ */ new Map();
25
+ const unreadable = /* @__PURE__ */ new Map();
26
+ for (const [, name, raw] of css.matchAll(DECLARATION)) names.set(name, raw.trim());
27
+ for (const [name, declared] of names) {
28
+ const value = resolve(declared, names);
29
+ const color = parseColor(value);
30
+ if (color) colors.add(colorKey(color));
31
+ else if (isUnreadableColor(value)) unreadable.set(name, value);
32
+ const length = lengthToPx(value, rootFontSize);
33
+ if (length !== void 0 && isTypeToken(name)) fontSizes.add(length);
34
+ }
35
+ return {
36
+ colors,
37
+ fontSizes,
38
+ names,
39
+ unreadable
40
+ };
41
+ }
42
+ function isTypeToken(name) {
43
+ return /(^|-)(text|font|type)(-|$)/.test(name);
44
+ }
45
+ async function readTokenFiles(paths, rootFontSize = 16) {
46
+ return parseTokens((await Promise.all(paths.map((path) => readFile(path, "utf8")))).join("\n"), rootFontSize);
47
+ }
48
+ //#endregion
49
+ export { ROOT_FONT_SIZE, lengthToPx, parseTokens, readTokenFiles };
@@ -0,0 +1,26 @@
1
+ import { Anchor } from "@maple-kit/core/anchor";
2
+ //#region src/types.d.ts
3
+ /** Which tier found it: how much the finding can be trusted to be exact. */
4
+ export type Tier = "judged" | "rendered" | "static";
5
+ /**
6
+ * How a host is asked to treat it. Deterministic tiers may block; a judged
7
+ * finding defaults to advice, because a model's opinion is not a gate.
8
+ */
9
+ export type Severity = "advice" | "error" | "warn";
10
+ /** One thing a rule found, at one place on the page. */
11
+ export interface Finding {
12
+ /** Rule id, `maple/` for a predefined rule. */
13
+ readonly rule: string;
14
+ readonly tier: Tier;
15
+ readonly severity: Severity;
16
+ /** One sentence, in the reviewer's words, naming the value that is wrong. */
17
+ readonly message: string;
18
+ /**
19
+ * Where it is, in the cascade's own type. A finding and a comment mean the
20
+ * same thing by "where", which is what lets one be pinned beside the other.
21
+ */
22
+ readonly anchor: Anchor;
23
+ /** Where the rule is documented. */
24
+ readonly url?: string;
25
+ }
26
+ //#endregion
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@maple-kit/lint",
3
+ "version": "0.1.0",
4
+ "description": "Design-system lint for Maple: rendered-tier rules over a live preview",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/maple-kit/maple.git",
11
+ "directory": "packages/lint"
12
+ },
13
+ "homepage": "https://github.com/maple-kit/maple/tree/main/packages/lint",
14
+ "engines": {
15
+ "node": ">=24.0.0"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE",
20
+ "NOTICE",
21
+ "README.md"
22
+ ],
23
+ "exports": {
24
+ ".": "./dist/index.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "dependencies": {
28
+ "@maple-kit/core": "0.10.0"
29
+ },
30
+ "peerDependencies": {
31
+ "playwright": "^1.50.0"
32
+ },
33
+ "devDependencies": {
34
+ "playwright": "1.63.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsdown",
41
+ "clean": "rm -rf dist *.tsbuildinfo"
42
+ }
43
+ }