@ctrl-ui/react 0.8.0 → 0.8.2
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.
- package/README.md +1 -0
- package/dist/blocks/coding-agent.js +3 -1
- package/dist/blocks/file-explorer.js +3 -1
- package/dist/blocks/settings.js +3 -1
- package/dist/control-props.d.ts +2 -1
- package/dist/email/email.d.ts +28 -0
- package/dist/email/email.js +79 -0
- package/dist/email/length.d.ts +4 -0
- package/dist/email/length.js +34 -0
- package/dist/email/templates.d.ts +51 -0
- package/dist/email/templates.js +228 -0
- package/dist/email/theme.d.ts +35 -0
- package/dist/email/theme.js +113 -0
- package/dist/extensions/create-track-highlight.d.ts +0 -2
- package/dist/extensions/create-track-highlight.js +49 -8
- package/dist/extensions/supports-anchor-transitions.d.ts +4 -0
- package/dist/extensions/supports-anchor-transitions.js +31 -0
- package/dist/extensions/track-highlight.d.ts +0 -6
- package/dist/extensions/track-highlight.js +25 -9
- package/dist/knob-contracts/button-knobs.d.ts +1 -1
- package/dist/knob-contracts/button-knobs.js +4 -1
- package/dist/knob-contracts/progressive-blur-knobs.d.ts +5 -0
- package/dist/knob-contracts/progressive-blur-knobs.js +9 -0
- package/dist/scripts/contrast-eval.d.ts +46 -0
- package/dist/scripts/contrast-eval.js +327 -0
- package/dist/skin.config.js +9 -1
- package/dist/skin.d.ts +11 -9
- package/dist/skin.js +0 -1
- package/dist/styles/index.css +3 -1
- package/dist/styles/recipes/button-group.css +46 -0
- package/dist/styles/recipes/button.css +53 -5
- package/dist/styles/recipes/progressive-blur.css +145 -0
- package/dist/styles/recipes/scroll-area.css +140 -5
- package/dist/styles/recipes/sidebar-menu.css +156 -0
- package/dist/styles/recipes/sidebar.css +25 -69
- package/dist/styles/recipes/table-of-contents.css +1 -1
- package/dist/styles/recipes/track-highlight.css +126 -2
- package/dist/styles/recipes/tree.css +4 -0
- package/dist/ui/button-group.d.ts +3 -1
- package/dist/ui/button-group.js +21 -18
- package/dist/ui/button.d.ts +3 -1
- package/dist/ui/button.js +13 -4
- package/dist/ui/checkbox-group.d.ts +5 -3
- package/dist/ui/checkbox-group.js +23 -6
- package/dist/ui/combobox.d.ts +2 -0
- package/dist/ui/dropdown-menu.d.ts +28 -21
- package/dist/ui/dropdown-menu.js +112 -13
- package/dist/ui/progressive-blur.d.ts +12 -0
- package/dist/ui/progressive-blur.js +47 -0
- package/dist/ui/scroll-area.d.ts +4 -1
- package/dist/ui/scroll-area.js +27 -4
- package/dist/ui/sidebar-menu.d.ts +49 -0
- package/dist/ui/sidebar-menu.js +160 -0
- package/dist/ui/sidebar-provider.d.ts +28 -0
- package/dist/ui/sidebar-provider.js +80 -0
- package/dist/ui/sidebar.d.ts +4 -61
- package/dist/ui/sidebar.js +23 -202
- package/dist/ui/table-of-contents.js +3 -11
- package/dist/ui/toggle.d.ts +3 -2
- package/dist/ui/toggle.js +22 -10
- package/dist/ui/tree.js +13 -19
- package/package.json +5 -1
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
const STABLE_EPSILON = .5;
|
|
3
3
|
const MAX_FOLLOW_FRAMES = 90;
|
|
4
4
|
function isHighlightable(node) {
|
|
5
|
-
return node instanceof HTMLElement && node.
|
|
5
|
+
return node instanceof HTMLElement && !node.matches(":disabled, [aria-disabled=\"true\"], [data-disabled]:not([data-disabled=\"false\"]), :has([role=\"checkbox\"][data-disabled])") && node.checkVisibility();
|
|
6
6
|
}
|
|
7
7
|
function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
8
8
|
const { itemSelector, activeSelector, range = false, followHover = true } = options;
|
|
9
|
+
highlight.setAttribute("data-measured", "");
|
|
9
10
|
let hovered = null;
|
|
11
|
+
let focused = null;
|
|
10
12
|
let rafId = 0;
|
|
11
13
|
let followFrames = 0;
|
|
12
14
|
const activeLayer = {
|
|
@@ -27,6 +29,9 @@ function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
|
27
29
|
h: 0
|
|
28
30
|
}
|
|
29
31
|
};
|
|
32
|
+
function belongsToTrack(item) {
|
|
33
|
+
return track.hasAttribute("data-track") ? item.closest("[data-track]") === track : track.contains(item);
|
|
34
|
+
}
|
|
30
35
|
function measureBox(target, trackRect) {
|
|
31
36
|
const rect = target.getBoundingClientRect();
|
|
32
37
|
return {
|
|
@@ -37,7 +42,8 @@ function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
|
37
42
|
};
|
|
38
43
|
}
|
|
39
44
|
function resolveActiveBox(trackRect) {
|
|
40
|
-
|
|
45
|
+
if (track.dataset.track === "hover") return null;
|
|
46
|
+
const actives = Array.from(track.querySelectorAll(activeSelector)).filter((item) => isHighlightable(item) && belongsToTrack(item));
|
|
41
47
|
const firstActive = actives[0];
|
|
42
48
|
if (!firstActive) return null;
|
|
43
49
|
if (!range) return measureBox(firstActive, trackRect);
|
|
@@ -58,8 +64,9 @@ function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
|
58
64
|
return box;
|
|
59
65
|
}
|
|
60
66
|
function resolveHoveredBox(trackRect) {
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
const target = focused ?? hovered;
|
|
68
|
+
if (!followHover || !target || !belongsToTrack(target) || !isHighlightable(target)) return null;
|
|
69
|
+
return measureBox(target, trackRect);
|
|
63
70
|
}
|
|
64
71
|
function place(element, state, next, visible) {
|
|
65
72
|
if (!next) {
|
|
@@ -85,7 +92,8 @@ function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
|
85
92
|
const trackRect = track.getBoundingClientRect();
|
|
86
93
|
const activeBox = resolveActiveBox(trackRect);
|
|
87
94
|
const hoveredBox = resolveHoveredBox(trackRect);
|
|
88
|
-
const
|
|
95
|
+
const target = focused ?? hovered;
|
|
96
|
+
const isAway = hoveredBox !== null && target !== null && !target.matches(activeSelector);
|
|
89
97
|
track.toggleAttribute("data-track-hover", isAway);
|
|
90
98
|
const activeMoved = place(highlight, activeLayer, hoverHighlight ? activeBox : hoveredBox ?? activeBox, Boolean(activeBox || hoveredBox));
|
|
91
99
|
if (!hoverHighlight) highlight.toggleAttribute("data-hover", isAway);
|
|
@@ -106,8 +114,9 @@ function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
|
106
114
|
const onPointerOver = (event) => {
|
|
107
115
|
if (event.pointerType === "touch" || !(event.target instanceof Element)) return;
|
|
108
116
|
const item = event.target.closest(itemSelector);
|
|
109
|
-
|
|
110
|
-
hovered
|
|
117
|
+
const nextHovered = isHighlightable(item) && belongsToTrack(item) ? item : null;
|
|
118
|
+
if (nextHovered === hovered) return;
|
|
119
|
+
hovered = nextHovered;
|
|
111
120
|
scheduleFollow();
|
|
112
121
|
};
|
|
113
122
|
const onPointerLeave = () => {
|
|
@@ -115,9 +124,23 @@ function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
|
115
124
|
hovered = null;
|
|
116
125
|
scheduleFollow();
|
|
117
126
|
};
|
|
127
|
+
const onFocusChange = () => {
|
|
128
|
+
const activeElement = document.activeElement;
|
|
129
|
+
const item = activeElement?.closest(itemSelector);
|
|
130
|
+
focused = item && belongsToTrack(item) && activeElement?.matches(":focus-visible") && isHighlightable(item) ? item : null;
|
|
131
|
+
scheduleFollow();
|
|
132
|
+
};
|
|
133
|
+
const onFocusOut = () => {
|
|
134
|
+
focused = null;
|
|
135
|
+
scheduleFollow();
|
|
136
|
+
};
|
|
118
137
|
if (followHover) {
|
|
138
|
+
if (matchMedia("(hover: hover)").matches) hovered = Array.from(track.querySelectorAll(itemSelector)).find((item) => item.matches(":hover") && isHighlightable(item) && belongsToTrack(item)) ?? null;
|
|
139
|
+
onFocusChange();
|
|
119
140
|
track.addEventListener("pointerover", onPointerOver);
|
|
120
141
|
track.addEventListener("pointerleave", onPointerLeave);
|
|
142
|
+
track.addEventListener("focusin", onFocusChange);
|
|
143
|
+
track.addEventListener("focusout", onFocusOut);
|
|
121
144
|
}
|
|
122
145
|
const resizeObserver = new ResizeObserver(() => scheduleFollow());
|
|
123
146
|
resizeObserver.observe(track);
|
|
@@ -130,17 +153,35 @@ function createTrackHighlight(track, highlight, options, hoverHighlight) {
|
|
|
130
153
|
"data-selected",
|
|
131
154
|
"aria-selected",
|
|
132
155
|
"data-active",
|
|
133
|
-
"data-state"
|
|
156
|
+
"data-state",
|
|
157
|
+
"data-disabled",
|
|
158
|
+
"aria-disabled",
|
|
159
|
+
"disabled",
|
|
160
|
+
"data-track"
|
|
134
161
|
]
|
|
135
162
|
});
|
|
136
163
|
scheduleFollow();
|
|
137
164
|
return () => {
|
|
165
|
+
highlight.removeAttribute("data-measured");
|
|
138
166
|
if (rafId) cancelAnimationFrame(rafId);
|
|
139
167
|
track.removeEventListener("pointerover", onPointerOver);
|
|
140
168
|
track.removeEventListener("pointerleave", onPointerLeave);
|
|
169
|
+
track.removeEventListener("focusin", onFocusChange);
|
|
170
|
+
track.removeEventListener("focusout", onFocusOut);
|
|
141
171
|
track.removeAttribute("data-track-hover");
|
|
142
172
|
resizeObserver.disconnect();
|
|
143
173
|
mutationObserver.disconnect();
|
|
174
|
+
for (const element of [highlight, hoverHighlight]) {
|
|
175
|
+
if (!element) continue;
|
|
176
|
+
for (const property of [
|
|
177
|
+
"top",
|
|
178
|
+
"left",
|
|
179
|
+
"right",
|
|
180
|
+
"bottom"
|
|
181
|
+
]) element.style.removeProperty(property);
|
|
182
|
+
element.removeAttribute("data-visible");
|
|
183
|
+
element.removeAttribute("data-hover");
|
|
184
|
+
}
|
|
144
185
|
};
|
|
145
186
|
}
|
|
146
187
|
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/extensions/supports-anchor-transitions.ts
|
|
2
|
+
let anchorTransitionsSupported;
|
|
3
|
+
function supportsAnchorTransitions() {
|
|
4
|
+
if (anchorTransitionsSupported !== void 0) return anchorTransitionsSupported;
|
|
5
|
+
if (!CSS.supports("(anchor-name: --_track-transition-probe) and (anchor-scope: --_track-transition-probe)")) {
|
|
6
|
+
anchorTransitionsSupported = false;
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const probeTrack = document.createElement("div");
|
|
10
|
+
probeTrack.style.cssText = "position:fixed;left:-10000px;top:0;visibility:hidden;anchor-scope:--_track-transition-probe";
|
|
11
|
+
const initialAnchor = document.createElement("div");
|
|
12
|
+
initialAnchor.style.cssText = "position:absolute;top:0;width:1px;height:1px;anchor-name:--_track-transition-probe";
|
|
13
|
+
const nextAnchor = document.createElement("div");
|
|
14
|
+
nextAnchor.style.cssText = "position:absolute;top:10px;width:1px;height:1px";
|
|
15
|
+
const probeHighlight = document.createElement("div");
|
|
16
|
+
probeHighlight.style.cssText = "position:absolute;top:anchor(--_track-transition-probe top);width:1px;height:1px";
|
|
17
|
+
probeHighlight.style.setProperty("transition", "top 1s linear", "important");
|
|
18
|
+
probeTrack.append(initialAnchor, nextAnchor, probeHighlight);
|
|
19
|
+
document.body.append(probeTrack);
|
|
20
|
+
try {
|
|
21
|
+
probeHighlight.offsetTop;
|
|
22
|
+
initialAnchor.style.anchorName = "none";
|
|
23
|
+
nextAnchor.style.anchorName = "--_track-transition-probe";
|
|
24
|
+
anchorTransitionsSupported = probeHighlight.getAnimations().length > 0;
|
|
25
|
+
return anchorTransitionsSupported;
|
|
26
|
+
} finally {
|
|
27
|
+
probeTrack.remove();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { supportsAnchorTransitions };
|
|
@@ -2,17 +2,11 @@ import { TrackHighlightKnobStyle } from "../knob-contracts/track-highlight-knobs
|
|
|
2
2
|
import { CSSProperties, ComponentProps, ReactNode, RefObject } from "react";
|
|
3
3
|
//#region src/extensions/track-highlight.d.ts
|
|
4
4
|
type TrackHighlightProps = Omit<ComponentProps<"div">, "children" | "ref" | "style"> & {
|
|
5
|
-
/** Track container (`relative isolate`) highlight is measured against. Defaults to highlight's parent element. */
|
|
6
5
|
trackRef?: RefObject<HTMLElement | null>;
|
|
7
|
-
/** Selector for highlightable rows, resolved within track. */
|
|
8
6
|
itemSelector?: string;
|
|
9
|
-
/** Selector for resting row (active/selected) used when nothing is hovered. */
|
|
10
7
|
activeSelector?: string;
|
|
11
|
-
/** Span box across union of every active row (first..last) instead of single row. */
|
|
12
8
|
range?: boolean;
|
|
13
|
-
/** Follow pointer to hovered row; turn off for purely selection/scroll-driven box. */
|
|
14
9
|
followHover?: boolean;
|
|
15
|
-
/** Non-empty = second layer that follows hover while primary layer stays pinned on selection. */
|
|
16
10
|
hoverClassName?: string;
|
|
17
11
|
children?: ReactNode;
|
|
18
12
|
style?: CSSProperties & TrackHighlightKnobStyle;
|
|
@@ -1,32 +1,47 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
-
import {
|
|
3
|
+
import { supportsAnchorTransitions } from "./supports-anchor-transitions.js";
|
|
4
4
|
import { useEffect, useRef } from "react";
|
|
5
5
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
//#region src/extensions/track-highlight.tsx
|
|
7
7
|
const trackHighlightStructureClasses = "pointer-events-none absolute -z-10";
|
|
8
|
-
|
|
8
|
+
const defaultItemSelector = "[data-track-item]";
|
|
9
|
+
const defaultActiveSelector = "[data-track-item][data-active=\"true\"]";
|
|
10
|
+
const loadTrackHighlight = () => import("./create-track-highlight.js");
|
|
11
|
+
function TrackHighlight({ trackRef, itemSelector = defaultItemSelector, activeSelector = defaultActiveSelector, range, followHover, hoverClassName, className, style, children, ...props }) {
|
|
9
12
|
const ref = useRef(null);
|
|
10
13
|
const hoverRef = useRef(null);
|
|
11
14
|
const layered = Boolean(hoverClassName);
|
|
15
|
+
const usesAnchorAnatomy = !trackRef && !range && !layered && followHover !== false && itemSelector === defaultItemSelector && activeSelector === defaultActiveSelector;
|
|
12
16
|
useEffect(() => {
|
|
13
17
|
const highlight = ref.current;
|
|
14
18
|
const track = trackRef?.current ?? highlight?.parentElement;
|
|
15
19
|
if (!track || !highlight) return;
|
|
20
|
+
if (usesAnchorAnatomy && track.hasAttribute("data-track") && supportsAnchorTransitions()) return;
|
|
16
21
|
const hoverHighlight = layered ? hoverRef.current ?? void 0 : void 0;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
let dispose;
|
|
24
|
+
loadTrackHighlight().then(({ createTrackHighlight }) => {
|
|
25
|
+
if (cancelled) return;
|
|
26
|
+
dispose = createTrackHighlight(track, highlight, {
|
|
27
|
+
itemSelector,
|
|
28
|
+
activeSelector,
|
|
29
|
+
range,
|
|
30
|
+
followHover
|
|
31
|
+
}, hoverHighlight);
|
|
32
|
+
});
|
|
33
|
+
return () => {
|
|
34
|
+
cancelled = true;
|
|
35
|
+
dispose?.();
|
|
36
|
+
};
|
|
23
37
|
}, [
|
|
24
38
|
trackRef,
|
|
25
39
|
itemSelector,
|
|
26
40
|
activeSelector,
|
|
27
41
|
range,
|
|
28
42
|
followHover,
|
|
29
|
-
layered
|
|
43
|
+
layered,
|
|
44
|
+
usesAnchorAnatomy
|
|
30
45
|
]);
|
|
31
46
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
|
|
32
47
|
...props,
|
|
@@ -34,6 +49,7 @@ function TrackHighlight({ trackRef, itemSelector = "[data-track-item]", activeSe
|
|
|
34
49
|
"data-control-ui": "track-highlight",
|
|
35
50
|
"data-control-family": "track-highlight",
|
|
36
51
|
"data-slot": "root",
|
|
52
|
+
"data-positioning": usesAnchorAnatomy ? "anchor" : void 0,
|
|
37
53
|
"aria-hidden": true,
|
|
38
54
|
style,
|
|
39
55
|
className: cn(trackHighlightStructureClasses, className),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/knob-contracts/button-knobs.d.ts
|
|
2
|
-
declare const buttonKnobs: readonly ["--cui-button-radius", "--cui-button-gap", "--cui-button-icon", "--cui-button-height", "--cui-button-padding-inline", "--cui-button-font-size", "--cui-button-background", "--cui-button-background-image", "--cui-button-foreground", "--cui-button-hover-background", "--cui-button-hover-foreground", "--cui-button-press-background", "--cui-button-press-scale", "--cui-button-active-background", "--cui-button-active-foreground", "--cui-button-active-hover-background", "--cui-button-shadow", "--cui-button-hover-shadow", "--cui-button-press-shadow", "--cui-button-active-shadow"];
|
|
2
|
+
declare const buttonKnobs: readonly ["--cui-button-radius", "--cui-button-gap", "--cui-button-icon", "--cui-button-height", "--cui-button-padding-inline", "--cui-button-font-size", "--cui-button-background", "--cui-button-background-image", "--cui-button-foreground", "--cui-button-hover-background", "--cui-button-hover-foreground", "--cui-button-press-background", "--cui-button-press-scale", "--cui-button-active-background", "--cui-button-active-foreground", "--cui-button-active-hover-background", "--cui-button-shadow", "--cui-button-hover-shadow", "--cui-button-press-shadow", "--cui-button-active-shadow", "--cui-button-open-background", "--cui-button-open-foreground", "--cui-button-open-shadow"];
|
|
3
3
|
type ButtonKnobStyle = Partial<Record<(typeof buttonKnobs)[number], string>>;
|
|
4
4
|
//#endregion
|
|
5
5
|
export { ButtonKnobStyle, buttonKnobs };
|
|
@@ -19,7 +19,10 @@ const buttonKnobs = [
|
|
|
19
19
|
"--cui-button-shadow",
|
|
20
20
|
"--cui-button-hover-shadow",
|
|
21
21
|
"--cui-button-press-shadow",
|
|
22
|
-
"--cui-button-active-shadow"
|
|
22
|
+
"--cui-button-active-shadow",
|
|
23
|
+
"--cui-button-open-background",
|
|
24
|
+
"--cui-button-open-foreground",
|
|
25
|
+
"--cui-button-open-shadow"
|
|
23
26
|
];
|
|
24
27
|
//#endregion
|
|
25
28
|
export { buttonKnobs };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//#region src/knob-contracts/progressive-blur-knobs.d.ts
|
|
2
|
+
declare const progressiveBlurKnobs: readonly ["--cui-progressive-blur-size", "--cui-progressive-blur-backdrop-blur", "--cui-progressive-blur-transition-duration", "--cui-progressive-blur-transition-delay"];
|
|
3
|
+
type ProgressiveBlurKnobStyle = Partial<Record<(typeof progressiveBlurKnobs)[number], string>>;
|
|
4
|
+
//#endregion
|
|
5
|
+
export { ProgressiveBlurKnobStyle, progressiveBlurKnobs };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//#region src/knob-contracts/progressive-blur-knobs.ts
|
|
2
|
+
const progressiveBlurKnobs = [
|
|
3
|
+
"--cui-progressive-blur-size",
|
|
4
|
+
"--cui-progressive-blur-backdrop-blur",
|
|
5
|
+
"--cui-progressive-blur-transition-duration",
|
|
6
|
+
"--cui-progressive-blur-transition-delay"
|
|
7
|
+
];
|
|
8
|
+
//#endregion
|
|
9
|
+
export { progressiveBlurKnobs };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/scripts/contrast-eval.d.mts
|
|
2
|
+
declare const stripComments: (css: any) => any;
|
|
3
|
+
declare function parseBlocks(css: any): {
|
|
4
|
+
prelude: any;
|
|
5
|
+
body: any;
|
|
6
|
+
}[];
|
|
7
|
+
declare function flattenBlocks(css: any): any;
|
|
8
|
+
declare function declarations(body: any): Map<any, any>;
|
|
9
|
+
/** Later source wins, so the order is Tailwind palette, core theme, skin theme, app overrides. */
|
|
10
|
+
declare function tokenMaps(cssSources: any): {
|
|
11
|
+
light: Map<any, any>;
|
|
12
|
+
dark: Map<any, any>;
|
|
13
|
+
};
|
|
14
|
+
declare function rgbToOklch(r255: any, g255: any, b255: any): {
|
|
15
|
+
L: number;
|
|
16
|
+
C: number;
|
|
17
|
+
H: number;
|
|
18
|
+
};
|
|
19
|
+
declare function oklchToRgb(L: any, C: any, H: any): {
|
|
20
|
+
r: number;
|
|
21
|
+
g: number;
|
|
22
|
+
b: number;
|
|
23
|
+
};
|
|
24
|
+
declare const luminance: ({ r, g, b }: {
|
|
25
|
+
b: any;
|
|
26
|
+
g: any;
|
|
27
|
+
r: any;
|
|
28
|
+
}) => number;
|
|
29
|
+
declare function contrastRatio(a: any, b: any): number;
|
|
30
|
+
declare function parseHex(value: any): {
|
|
31
|
+
r: number;
|
|
32
|
+
g: number;
|
|
33
|
+
b: number;
|
|
34
|
+
alpha: number;
|
|
35
|
+
} | null;
|
|
36
|
+
declare function resolveColor(value: any, tokens: any, seen?: Set<any>, depth?: number): any;
|
|
37
|
+
declare function composite(over: any, under: any): {
|
|
38
|
+
r: number;
|
|
39
|
+
g: number;
|
|
40
|
+
b: number;
|
|
41
|
+
alpha: any;
|
|
42
|
+
};
|
|
43
|
+
declare function evaluatePair(pair: any, tokens: any): any;
|
|
44
|
+
declare function evaluate(tokens: any, pairs: any): any;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { composite, contrastRatio, declarations, evaluate, evaluatePair, flattenBlocks, luminance, oklchToRgb, parseBlocks, parseHex, resolveColor, rgbToOklch, stripComments, tokenMaps };
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
//#region src/scripts/contrast-eval.mjs
|
|
2
|
+
const stripComments = (css) => css.replaceAll(/\/\*[\s\S]*?\*\//g, " ");
|
|
3
|
+
function parseBlocks(css) {
|
|
4
|
+
const blocks = [];
|
|
5
|
+
let depth = 0;
|
|
6
|
+
let preludeStart = 0;
|
|
7
|
+
let bodyStart = 0;
|
|
8
|
+
for (let index = 0; index < css.length; index += 1) {
|
|
9
|
+
const character = css[index];
|
|
10
|
+
if (character === "{") {
|
|
11
|
+
if (depth === 0) bodyStart = index + 1;
|
|
12
|
+
depth += 1;
|
|
13
|
+
} else if (character === "}") {
|
|
14
|
+
depth -= 1;
|
|
15
|
+
if (depth === 0) {
|
|
16
|
+
blocks.push({
|
|
17
|
+
prelude: css.slice(preludeStart, bodyStart - 1).trim(),
|
|
18
|
+
body: css.slice(bodyStart, index)
|
|
19
|
+
});
|
|
20
|
+
preludeStart = index + 1;
|
|
21
|
+
}
|
|
22
|
+
} else if (character === ";" && depth === 0) preludeStart = index + 1;
|
|
23
|
+
}
|
|
24
|
+
return blocks;
|
|
25
|
+
}
|
|
26
|
+
function flattenBlocks(css) {
|
|
27
|
+
return parseBlocks(css).flatMap((block) => /^@(layer|media|supports)\b/.test(block.prelude) ? flattenBlocks(block.body) : [block]);
|
|
28
|
+
}
|
|
29
|
+
function declarations(body) {
|
|
30
|
+
const properties = /* @__PURE__ */ new Map();
|
|
31
|
+
let depth = 0;
|
|
32
|
+
for (const statement of body.split(";")) {
|
|
33
|
+
const open = (statement.match(/{/g) ?? []).length;
|
|
34
|
+
const close = (statement.match(/}/g) ?? []).length;
|
|
35
|
+
if (depth === 0) {
|
|
36
|
+
const match = /^\s*(--[\w-]+)\s*:\s*([\s\S]*)$/.exec(statement);
|
|
37
|
+
if (match) properties.set(match[1], match[2].trim());
|
|
38
|
+
}
|
|
39
|
+
depth += open - close;
|
|
40
|
+
}
|
|
41
|
+
return properties;
|
|
42
|
+
}
|
|
43
|
+
const declaresTokens = (prelude) => prelude.includes("[data-skin") || prelude.startsWith("@theme") && !prelude.includes("inline");
|
|
44
|
+
function modesOf(prelude) {
|
|
45
|
+
if (/\.dark\b/.test(prelude.replaceAll(/:not\([^)]*\)/g, ""))) return ["dark"];
|
|
46
|
+
if (/:not\([^)]*\.dark\b/.test(prelude)) return ["light"];
|
|
47
|
+
return ["light", "dark"];
|
|
48
|
+
}
|
|
49
|
+
/** Later source wins, so the order is Tailwind palette, core theme, skin theme, app overrides. */
|
|
50
|
+
function tokenMaps(cssSources) {
|
|
51
|
+
const maps = {
|
|
52
|
+
light: /* @__PURE__ */ new Map(),
|
|
53
|
+
dark: /* @__PURE__ */ new Map()
|
|
54
|
+
};
|
|
55
|
+
for (const css of cssSources) for (const block of flattenBlocks(stripComments(css))) {
|
|
56
|
+
if (!declaresTokens(block.prelude)) continue;
|
|
57
|
+
const modes = modesOf(block.prelude);
|
|
58
|
+
for (const [name, value] of declarations(block.body)) for (const mode of modes) maps[mode].set(name, value);
|
|
59
|
+
}
|
|
60
|
+
return maps;
|
|
61
|
+
}
|
|
62
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
63
|
+
const srgbToLinear = (c) => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
|
|
64
|
+
const linearToSrgb = (c) => c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;
|
|
65
|
+
function rgbToOklch(r255, g255, b255) {
|
|
66
|
+
const r = srgbToLinear(clamp(r255, 0, 255) / 255);
|
|
67
|
+
const g = srgbToLinear(clamp(g255, 0, 255) / 255);
|
|
68
|
+
const b = srgbToLinear(clamp(b255, 0, 255) / 255);
|
|
69
|
+
const l = .4122214708 * r + .5363325363 * g + .0514459929 * b;
|
|
70
|
+
const m = .2119034982 * r + .6806995451 * g + .1073969566 * b;
|
|
71
|
+
const s = .0883024619 * r + .2817188376 * g + .6299787005 * b;
|
|
72
|
+
const l_ = Math.cbrt(l);
|
|
73
|
+
const m_ = Math.cbrt(m);
|
|
74
|
+
const s_ = Math.cbrt(s);
|
|
75
|
+
const L = .2104542553 * l_ + .793617785 * m_ - .0040720468 * s_;
|
|
76
|
+
const A = 1.9779984951 * l_ - 2.428592205 * m_ + .4505937099 * s_;
|
|
77
|
+
const B = .0259040371 * l_ + .7827717662 * m_ - .808675766 * s_;
|
|
78
|
+
const C = Math.hypot(A, B);
|
|
79
|
+
let H = Math.atan2(B, A) * 180 / Math.PI;
|
|
80
|
+
if (H < 0) H += 360;
|
|
81
|
+
return {
|
|
82
|
+
L,
|
|
83
|
+
C,
|
|
84
|
+
H
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function oklchToRgb(L, C, H) {
|
|
88
|
+
const hr = H * Math.PI / 180;
|
|
89
|
+
const a = C * Math.cos(hr);
|
|
90
|
+
const b = C * Math.sin(hr);
|
|
91
|
+
const l_ = L + .3963377774 * a + .2158037573 * b;
|
|
92
|
+
const m_ = L - .1055613458 * a - .0638541728 * b;
|
|
93
|
+
const s_ = L - .0894841775 * a - 1.291485548 * b;
|
|
94
|
+
const l = l_ ** 3;
|
|
95
|
+
const m = m_ ** 3;
|
|
96
|
+
const s = s_ ** 3;
|
|
97
|
+
const r = 4.0767416621 * l - 3.3077115913 * m + .2309699292 * s;
|
|
98
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s;
|
|
99
|
+
const bl = -.0041960863 * l - .7034186147 * m + 1.707614701 * s;
|
|
100
|
+
const to = (c) => clamp(Math.round(linearToSrgb(c) * 255), 0, 255);
|
|
101
|
+
return {
|
|
102
|
+
r: to(r),
|
|
103
|
+
g: to(g),
|
|
104
|
+
b: to(bl)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const channelLuminance = (c) => {
|
|
108
|
+
const s = c / 255;
|
|
109
|
+
return s <= .03928 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
|
|
110
|
+
};
|
|
111
|
+
const luminance = ({ r, g, b }) => .2126 * channelLuminance(r) + .7152 * channelLuminance(g) + .0722 * channelLuminance(b);
|
|
112
|
+
function contrastRatio(a, b) {
|
|
113
|
+
const la = luminance(a) + .05;
|
|
114
|
+
const lb = luminance(b) + .05;
|
|
115
|
+
return la > lb ? la / lb : lb / la;
|
|
116
|
+
}
|
|
117
|
+
function parseHex(value) {
|
|
118
|
+
const match = /^#([0-9a-f]{3,8})$/i.exec(value);
|
|
119
|
+
if (!match) return null;
|
|
120
|
+
const digits = match[1];
|
|
121
|
+
const expand = (pair) => Number.parseInt(pair.length === 1 ? pair + pair : pair, 16);
|
|
122
|
+
const step = digits.length <= 4 ? 1 : 2;
|
|
123
|
+
if (digits.length !== 3 && digits.length !== 4 && digits.length !== 6 && digits.length !== 8) return null;
|
|
124
|
+
const at = (index) => digits.slice(index * step, index * step + step);
|
|
125
|
+
const alphaDigits = digits.length === 4 || digits.length === 8 ? at(3) : null;
|
|
126
|
+
return {
|
|
127
|
+
r: expand(at(0)),
|
|
128
|
+
g: expand(at(1)),
|
|
129
|
+
b: expand(at(2)),
|
|
130
|
+
alpha: alphaDigits === null ? 1 : expand(alphaDigits) / 255
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)$/;
|
|
134
|
+
function scalar(text) {
|
|
135
|
+
const trimmed = text.trim();
|
|
136
|
+
if (trimmed.endsWith("%")) {
|
|
137
|
+
const percent = trimmed.slice(0, -1);
|
|
138
|
+
return NUMBER.test(percent) ? Number(percent) / 100 : null;
|
|
139
|
+
}
|
|
140
|
+
return NUMBER.test(trimmed) ? Number(trimmed) : null;
|
|
141
|
+
}
|
|
142
|
+
function splitTopLevel(text) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
let depth = 0;
|
|
145
|
+
let current = "";
|
|
146
|
+
const flush = () => {
|
|
147
|
+
if (current) parts.push(current);
|
|
148
|
+
current = "";
|
|
149
|
+
};
|
|
150
|
+
for (const character of text) {
|
|
151
|
+
if (character === "(") depth += 1;
|
|
152
|
+
else if (character === ")") depth -= 1;
|
|
153
|
+
if (depth > 0 || !/[\s/]/.test(character)) {
|
|
154
|
+
current += character;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
flush();
|
|
158
|
+
if (character === "/") parts.push("/");
|
|
159
|
+
}
|
|
160
|
+
flush();
|
|
161
|
+
return parts;
|
|
162
|
+
}
|
|
163
|
+
const unresolved = (reason) => ({ unresolved: reason });
|
|
164
|
+
const isUnresolved = (result) => Boolean(result?.unresolved);
|
|
165
|
+
/** `calc(<base> <op> <n>)`, `calc(<n> * var(--x))` and `calc(<base> * <n>)` — the only calc shapes the packs write. */
|
|
166
|
+
function evaluateCalc(expression, base, tokens, seen, depth) {
|
|
167
|
+
const inner = expression.slice(5, -1).trim();
|
|
168
|
+
const match = /^(\S+)\s*([+\-*])\s*(\S+)$/.exec(inner);
|
|
169
|
+
if (!match) return null;
|
|
170
|
+
const [, leftText, operator, rightText] = match;
|
|
171
|
+
const operand = (text) => {
|
|
172
|
+
if (text === "l" || text === "alpha") return base;
|
|
173
|
+
const number = scalar(text);
|
|
174
|
+
if (number !== null) return number;
|
|
175
|
+
const nested = resolveScalar(text, tokens, seen, depth + 1);
|
|
176
|
+
return typeof nested === "number" ? nested : null;
|
|
177
|
+
};
|
|
178
|
+
const left = operand(leftText);
|
|
179
|
+
const right = operand(rightText);
|
|
180
|
+
if (left === null || right === null) return null;
|
|
181
|
+
if (operator === "+") return left + right;
|
|
182
|
+
if (operator === "-") return left - right;
|
|
183
|
+
return left * right;
|
|
184
|
+
}
|
|
185
|
+
function resolveScalar(value, tokens, seen, depth) {
|
|
186
|
+
const trimmed = value.trim();
|
|
187
|
+
const number = scalar(trimmed);
|
|
188
|
+
if (number !== null) return number;
|
|
189
|
+
const variable = /^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/.exec(trimmed);
|
|
190
|
+
if (variable) {
|
|
191
|
+
if (depth > 16 || seen.has(variable[1])) return null;
|
|
192
|
+
const declared = tokens.get(variable[1]) ?? variable[2];
|
|
193
|
+
if (declared === void 0) return null;
|
|
194
|
+
return resolveScalar(declared, tokens, /* @__PURE__ */ new Set([...seen, variable[1]]), depth + 1);
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const literalChannel = (text, allowPercent) => allowPercent || !text.endsWith("%") ? scalar(text) : null;
|
|
199
|
+
const channelValue = (text, keyword, base, tokens, seen, depth) => {
|
|
200
|
+
if (text === keyword) return base;
|
|
201
|
+
if (text.startsWith("calc(")) return evaluateCalc(text, base, tokens, seen, depth);
|
|
202
|
+
return literalChannel(text, keyword === "l");
|
|
203
|
+
};
|
|
204
|
+
const alphaValue = (text, base, tokens, seen, depth) => text.startsWith("calc(") ? evaluateCalc(text, base, tokens, seen, depth) : resolveScalar(text, tokens, seen, depth);
|
|
205
|
+
const CHANNEL_KEYWORDS = [
|
|
206
|
+
"l",
|
|
207
|
+
"c",
|
|
208
|
+
"h"
|
|
209
|
+
];
|
|
210
|
+
function relativeOklch(inner, tokens, seen, depth) {
|
|
211
|
+
const parts = splitTopLevel(inner.slice(5).trim());
|
|
212
|
+
const slash = parts.indexOf("/");
|
|
213
|
+
const channels = slash === -1 ? parts : parts.slice(0, slash);
|
|
214
|
+
if (channels.length !== 4) return unresolved(`unsupported relative color \`oklch(${inner})\``);
|
|
215
|
+
const base = resolveColor(channels[0], tokens, seen, depth + 1);
|
|
216
|
+
if (isUnresolved(base)) return base;
|
|
217
|
+
const { L, C, H } = rgbToOklch(base.r, base.g, base.b);
|
|
218
|
+
const resolved = CHANNEL_KEYWORDS.map((keyword, index) => channelValue(channels[index + 1], keyword, [
|
|
219
|
+
L,
|
|
220
|
+
C,
|
|
221
|
+
H
|
|
222
|
+
][index], tokens, seen, depth));
|
|
223
|
+
if (resolved.some((value) => value === null)) return unresolved(`unsupported channel in \`oklch(${inner})\``);
|
|
224
|
+
const alphaText = slash === -1 ? null : parts.slice(slash + 1).join(" ");
|
|
225
|
+
const alpha = alphaText === null ? base.alpha : alphaValue(alphaText, base.alpha, tokens, seen, depth);
|
|
226
|
+
if (alpha === null) return unresolved(`unsupported alpha \`${alphaText}\``);
|
|
227
|
+
return {
|
|
228
|
+
...oklchToRgb(resolved[0], resolved[1], resolved[2]),
|
|
229
|
+
alpha: clamp(alpha, 0, 1)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function literalOklch(inner, tokens, seen, depth) {
|
|
233
|
+
const parts = splitTopLevel(inner);
|
|
234
|
+
const slash = parts.indexOf("/");
|
|
235
|
+
const channels = slash === -1 ? parts : parts.slice(0, slash);
|
|
236
|
+
if (channels.length !== 3) return unresolved(`unsupported \`oklch(${inner})\``);
|
|
237
|
+
const numbers = channels.map((part, index) => literalChannel(part, index === 0));
|
|
238
|
+
if (numbers.some((number) => number === null)) return unresolved(`unsupported \`oklch(${inner})\``);
|
|
239
|
+
const alphaText = slash === -1 ? null : parts.slice(slash + 1).join(" ");
|
|
240
|
+
const alpha = alphaText === null ? 1 : resolveScalar(alphaText, tokens, seen, depth);
|
|
241
|
+
if (alpha === null) return unresolved(`unsupported alpha in \`oklch(${inner})\``);
|
|
242
|
+
return {
|
|
243
|
+
...oklchToRgb(numbers[0], numbers[1], numbers[2]),
|
|
244
|
+
alpha: clamp(alpha, 0, 1)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function resolveColor(value, tokens, seen = /* @__PURE__ */ new Set(), depth = 0) {
|
|
248
|
+
const trimmed = String(value ?? "").trim();
|
|
249
|
+
if (depth > 16) return unresolved("token chain deeper than 16 hops");
|
|
250
|
+
if (trimmed === "") return unresolved("empty value");
|
|
251
|
+
if (trimmed === "transparent") return {
|
|
252
|
+
r: 0,
|
|
253
|
+
g: 0,
|
|
254
|
+
b: 0,
|
|
255
|
+
alpha: 0
|
|
256
|
+
};
|
|
257
|
+
const hex = parseHex(trimmed);
|
|
258
|
+
if (hex) return hex;
|
|
259
|
+
const variable = /^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/.exec(trimmed);
|
|
260
|
+
if (variable) {
|
|
261
|
+
if (seen.has(variable[1])) return unresolved(`\`${variable[1]}\` refers to itself`);
|
|
262
|
+
const declared = tokens.get(variable[1]) ?? variable[2];
|
|
263
|
+
if (declared === void 0) return unresolved(`\`${variable[1]}\` is declared by nobody`);
|
|
264
|
+
return resolveColor(declared, tokens, /* @__PURE__ */ new Set([...seen, variable[1]]), depth + 1);
|
|
265
|
+
}
|
|
266
|
+
const oklch = /^oklch\(([\s\S]*)\)$/.exec(trimmed);
|
|
267
|
+
if (oklch) {
|
|
268
|
+
const inner = oklch[1].trim();
|
|
269
|
+
return inner.startsWith("from ") ? relativeOklch(inner, tokens, seen, depth) : literalOklch(inner, tokens, seen, depth);
|
|
270
|
+
}
|
|
271
|
+
return unresolved(`unsupported value \`${trimmed}\``);
|
|
272
|
+
}
|
|
273
|
+
const WHITE = {
|
|
274
|
+
r: 255,
|
|
275
|
+
g: 255,
|
|
276
|
+
b: 255,
|
|
277
|
+
alpha: 1
|
|
278
|
+
};
|
|
279
|
+
function composite(over, under) {
|
|
280
|
+
const alpha = over.alpha + under.alpha * (1 - over.alpha);
|
|
281
|
+
if (alpha === 0) return {
|
|
282
|
+
r: 0,
|
|
283
|
+
g: 0,
|
|
284
|
+
b: 0,
|
|
285
|
+
alpha: 0
|
|
286
|
+
};
|
|
287
|
+
const channel = (key) => (over[key] * over.alpha + under[key] * under.alpha * (1 - over.alpha)) / alpha;
|
|
288
|
+
return {
|
|
289
|
+
r: channel("r"),
|
|
290
|
+
g: channel("g"),
|
|
291
|
+
b: channel("b"),
|
|
292
|
+
alpha
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function evaluatePair(pair, tokens) {
|
|
296
|
+
const layers = [
|
|
297
|
+
...pair.underlays ?? [],
|
|
298
|
+
pair.surface,
|
|
299
|
+
pair.background,
|
|
300
|
+
pair.foreground
|
|
301
|
+
];
|
|
302
|
+
let stack = WHITE;
|
|
303
|
+
let backdrop = WHITE;
|
|
304
|
+
for (const [index, token] of layers.entries()) {
|
|
305
|
+
const resolved = resolveColor(`var(${token})`, tokens);
|
|
306
|
+
if (isUnresolved(resolved)) return {
|
|
307
|
+
...pair,
|
|
308
|
+
ratio: null,
|
|
309
|
+
status: "unverified",
|
|
310
|
+
reason: `${token}: ${resolved.unresolved}`
|
|
311
|
+
};
|
|
312
|
+
if (index === layers.length - 1) backdrop = stack;
|
|
313
|
+
stack = composite(resolved, stack);
|
|
314
|
+
}
|
|
315
|
+
const ratio = contrastRatio(stack, backdrop);
|
|
316
|
+
return {
|
|
317
|
+
...pair,
|
|
318
|
+
ratio,
|
|
319
|
+
status: ratio >= pair.threshold ? "pass" : "fail",
|
|
320
|
+
reason: null
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function evaluate(tokens, pairs) {
|
|
324
|
+
return pairs.map((pair) => evaluatePair(pair, tokens));
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
export { composite, contrastRatio, declarations, evaluate, evaluatePair, flattenBlocks, luminance, oklchToRgb, parseBlocks, parseHex, resolveColor, rgbToOklch, stripComments, tokenMaps };
|