actual-css 0.11.0 → 0.12.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.
- package/CHANGELOG.md +73 -0
- package/README.md +29 -5
- package/components.json +15 -17
- package/dist/actual.full.css +403 -227
- package/dist/actual.full.min.css +1 -1
- package/docs/brand/favicon.svg +5 -0
- package/docs/brand/lockup.svg +12 -0
- package/docs/brand/logo-dark.svg +9 -0
- package/docs/brand/logo-light.svg +9 -0
- package/docs/brand/logo.svg +11 -0
- package/docs/brand/mark.svg +11 -0
- package/docs/navigation.json +4 -2
- package/docs/pages/components/alert.md +2 -10
- package/docs/pages/components/badge.md +3 -9
- package/docs/pages/components/bleed.md +5 -4
- package/docs/pages/components/card.md +31 -2
- package/docs/pages/components/close.md +9 -3
- package/docs/pages/components/dialog.md +248 -31
- package/docs/pages/components/drawer.md +2 -2
- package/docs/pages/components/meter.md +2 -0
- package/docs/pages/components/navbar.md +8 -9
- package/docs/pages/components/table.md +3 -3
- package/docs/pages/enhancements/flyout.md +2 -2
- package/docs/pages/enhancements/scrollspy.md +1 -1
- package/docs/pages/enhancements/status-bar.md +2 -2
- package/docs/pages/examples/overview.md +3 -1
- package/docs/pages/forms/date-picker.md +90 -0
- package/docs/pages/forms/detached-actions.md +37 -30
- package/docs/pages/forms/form-actions.md +61 -28
- package/docs/pages/forms/input-policy.md +1 -1
- package/docs/pages/forms/overview.md +6 -5
- package/docs/pages/forms/sticky-actions.md +85 -83
- package/docs/pages/forms/validation.md +5 -5
- package/docs/pages/foundations/philosophy.md +5 -3
- package/docs/pages/foundations/theming.md +15 -4
- package/docs/pages/foundations/tokens.md +50 -33
- package/docs/pages/foundations/typography.md +4 -7
- package/docs/pages/guides/bootstrap.md +2 -2
- package/docs/pages/guides/design-tools.md +36 -0
- package/docs/pages/guides/integrating-actual-css.md +5 -1
- package/docs/pages/guides/introduction.md +3 -4
- package/docs/pages/guides/modular-import.md +3 -0
- package/docs/pages/guides/progressive-enhancement.md +2 -2
- package/docs/pages/guides/tailwind.md +70 -0
- package/docs/pages/guides/upgrading.md +74 -0
- package/docs/pages/layout/cluster.md +28 -2
- package/docs/pages/layout/overview.md +1 -1
- package/docs/pages/layout/stack.md +21 -16
- package/docs/pages/layout/topbar.md +1 -1
- package/docs/pages/patterns/navigation-list.md +16 -46
- package/docs/pages/patterns/overview.md +3 -4
- package/docs/pages/utilities/flex.md +30 -20
- package/docs/pages/utilities/sizing-wrapping.md +1 -1
- package/docs/pages/utilities/spacing.md +4 -3
- package/package.json +3 -2
- package/reserved-classes.json +3 -3
- package/skills/actual-css/references/enhancements.md +38 -0
- package/src/cli/actual-css.js +75 -6
- package/src/css/components/avatar.css +16 -9
- package/src/css/components/badge.css +1 -5
- package/src/css/components/bleed.css +5 -5
- package/src/css/components/button.css +17 -10
- package/src/css/components/card.css +18 -10
- package/src/css/components/chat.css +4 -5
- package/src/css/components/close.css +17 -6
- package/src/css/components/drawer.css +19 -3
- package/src/css/components/flyout.css +3 -0
- package/src/css/components/index.css +0 -1
- package/src/css/components/meter.css +5 -2
- package/src/css/components/modal.css +157 -69
- package/src/css/components/navbar.css +14 -25
- package/src/css/core/base.css +1 -1
- package/src/css/core/reset.css +2 -1
- package/src/css/core/theme.css +4 -0
- package/src/css/core/tokens.css +28 -10
- package/src/css/core/variants.css +41 -14
- package/src/css/forms/form-actions.css +15 -10
- package/src/css/forms/native.css +5 -1
- package/src/css/layout/cluster.css +11 -0
- package/src/css/layout/topbar.css +12 -6
- package/src/css/utilities/base.css +5 -0
- package/src/css/utilities/extra.css +3 -7
- package/src/tooling/color.js +65 -0
- package/src/tooling/design-components.js +286 -0
- package/src/tooling/design-export.js +69 -0
- package/src/tooling/design-tokens.js +474 -0
- package/src/tooling/penpot-plugin/manifest.json +7 -0
- package/src/tooling/penpot-plugin/plugin.js +272 -0
- package/src/tooling/penpot-plugin/ui.html +58 -0
- package/src/tooling/theme-contrast.js +209 -0
- package/docs/pages/patterns/actions.md +0 -52
- package/src/css/components/actions.css +0 -27
package/src/css/forms/native.css
CHANGED
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
the whole control, not just the filename text. */
|
|
20
20
|
min-block-size: var(--control-size);
|
|
21
21
|
padding-block: var(--space-10);
|
|
22
|
-
|
|
22
|
+
/* Border-box height: both borders come out of the padding, as on .btn. */
|
|
23
|
+
padding-block: max(
|
|
24
|
+
var(--space-10),
|
|
25
|
+
calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
|
|
26
|
+
);
|
|
23
27
|
padding-inline: var(--space-20);
|
|
24
28
|
border: var(--border-width) solid var(--border);
|
|
25
29
|
border-radius: var(--radius-sm);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* --cluster-wrap
|
|
10
10
|
*
|
|
11
11
|
* Child contract: direct-child margins are reset, spacing comes from --gap.
|
|
12
|
+
* A ul/ol/menu keeps its semantics but loses list chrome (see below).
|
|
12
13
|
*/
|
|
13
14
|
.cluster {
|
|
14
15
|
display: flex;
|
|
@@ -22,3 +23,13 @@
|
|
|
22
23
|
.cluster > * {
|
|
23
24
|
margin: 0;
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
/* Horizontal, wrapping layout is incompatible with list markers and native
|
|
28
|
+
indentation: a ul/ol/menu applied with .cluster stays a list in the DOM, but
|
|
29
|
+
its visual chrome would be accidental. .stack intentionally leaves this
|
|
30
|
+
alone — a vertical list can keep its numbers. */
|
|
31
|
+
:where(ul, ol, menu).cluster {
|
|
32
|
+
margin: 0;
|
|
33
|
+
padding: 0;
|
|
34
|
+
list-style: none;
|
|
35
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Sticky, frosted top bar shell for app/dashboard shells (persistent
|
|
3
3
|
* sidebar + topbar layouts). Owns positioning, stacking, and the frosted
|
|
4
|
-
* background
|
|
5
|
-
*
|
|
4
|
+
* background; it also lays its children out as a default row that a layout
|
|
5
|
+
* primitive on the same element (such as .cluster) replaces. Row content
|
|
6
|
+
* (search, breadcrumb, actions) stays local to the page.
|
|
6
7
|
*
|
|
7
8
|
* Docs: docs/pages/layout/topbar.md
|
|
8
9
|
*
|
|
@@ -16,16 +17,21 @@
|
|
|
16
17
|
inset-block-start: 0;
|
|
17
18
|
z-index: var(--z-sticky);
|
|
18
19
|
isolation: isolate;
|
|
19
|
-
display: flex;
|
|
20
|
-
flex-direction: row;
|
|
21
|
-
align-items: center;
|
|
22
|
-
gap: var(--gap);
|
|
23
20
|
padding-block: var(--space-30);
|
|
24
21
|
padding-inline: var(--space-40);
|
|
25
22
|
border-block-end: var(--border-width) solid var(--border);
|
|
26
23
|
background: var(--surface);
|
|
27
24
|
}
|
|
28
25
|
|
|
26
|
+
/* The row is a default at zero specificity: .topbar and the primitives share
|
|
27
|
+
layout/, so at class weight their file order alone would pick the winner. */
|
|
28
|
+
:where(.topbar) {
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: row;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: var(--gap);
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
/* Both conditions are required: the translucent background contains var(),
|
|
30
36
|
so a browser with backdrop-filter but not color-mix (e.g. Safari < 16.2)
|
|
31
37
|
would accept the declaration at parse time, fail it at computed-value
|
|
@@ -10,9 +10,6 @@
|
|
|
10
10
|
* - utilities consume the current context instead of defining their own -sm/-lg scale.
|
|
11
11
|
* - optional utilities extend core utilities, they do not re-express them.
|
|
12
12
|
* - follow verbose .{property}-{value} naming.
|
|
13
|
-
*
|
|
14
|
-
* Internal:
|
|
15
|
-
* --cluster-justify (local relay consumed by .cluster)
|
|
16
13
|
*/
|
|
17
14
|
|
|
18
15
|
/* Display */
|
|
@@ -87,23 +84,22 @@
|
|
|
87
84
|
flex-wrap: nowrap;
|
|
88
85
|
}
|
|
89
86
|
|
|
87
|
+
/* The property is written directly, never relayed through the cluster hook:
|
|
88
|
+
a relay would inherit into every .cluster nested inside. Utilities load last,
|
|
89
|
+
so the direct declaration already beats .cluster at equal specificity. */
|
|
90
90
|
.justify-content-start {
|
|
91
|
-
--cluster-justify: flex-start;
|
|
92
91
|
justify-content: flex-start;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
94
|
.justify-content-center {
|
|
96
|
-
--cluster-justify: center;
|
|
97
95
|
justify-content: center;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
.justify-content-end {
|
|
101
|
-
--cluster-justify: flex-end;
|
|
102
99
|
justify-content: flex-end;
|
|
103
100
|
}
|
|
104
101
|
|
|
105
102
|
.justify-content-space-between {
|
|
106
|
-
--cluster-justify: space-between;
|
|
107
103
|
justify-content: space-between;
|
|
108
104
|
}
|
|
109
105
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* sRGB color math shared by `actual-css contrast`, the repository reports and
|
|
3
|
+
* the browser tests.
|
|
4
|
+
* Colors are [r, g, b, a] byte arrays as returned by RASTERIZE in the page;
|
|
5
|
+
* plain [r, g, b] triplets are accepted and treated as opaque.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Page-side rasterizer, injected into a view.evaluate() program as
|
|
10
|
+
* `const rgba = ${RASTERIZE};`. getComputedStyle returns color-mix() and
|
|
11
|
+
* light-dark() results as oklab() or color(), so a 1x1 canvas turns any
|
|
12
|
+
* computed color into sRGB bytes. Alpha is kept so contrast() can refuse a
|
|
13
|
+
* translucent color instead of measuring it as if it were opaque.
|
|
14
|
+
*/
|
|
15
|
+
export const RASTERIZE = `(() => {
|
|
16
|
+
const canvas = Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
|
|
17
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
18
|
+
return (value) => {
|
|
19
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
20
|
+
ctx.fillStyle = value;
|
|
21
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
22
|
+
return [...ctx.getImageData(0, 0, 1, 1).data];
|
|
23
|
+
};
|
|
24
|
+
})()`;
|
|
25
|
+
|
|
26
|
+
/* 0.04045 is the sRGB (IEC 61966-2-1) threshold and the one WCAG 2.2 defines.
|
|
27
|
+
WCAG 2.0/2.1 printed 0.03928, from an older sRGB draft. No 8-bit channel
|
|
28
|
+
falls between the two (10/255 = 0.0392, 11/255 = 0.0431), so ratios are
|
|
29
|
+
identical either way. */
|
|
30
|
+
const toLinear = (byte) => {
|
|
31
|
+
const c = byte / 255;
|
|
32
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/* WCAG 2 relative luminance. */
|
|
36
|
+
export const luminance = ([r, g, b]) =>
|
|
37
|
+
0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
38
|
+
|
|
39
|
+
/* WCAG 2 contrast ratio, 1 to 21. The formula has no alpha: a translucent
|
|
40
|
+
color must be composited over its real background before it is measured. */
|
|
41
|
+
export function contrast(a, b) {
|
|
42
|
+
if (a[3] < 255 || b[3] < 255) {
|
|
43
|
+
throw new Error(`contrast() needs opaque colors, got [${a}] and [${b}]`);
|
|
44
|
+
}
|
|
45
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
46
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* OKLCH of an sRGB color: L 0-1, C chroma, h degrees. */
|
|
50
|
+
export function oklch([r, g, b]) {
|
|
51
|
+
const [lr, lg, lb] = [r, g, b].map(toLinear);
|
|
52
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
53
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
54
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
55
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
56
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
57
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
58
|
+
return { L, C: Math.hypot(A, B), h: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Shortest arc between two hues, in degrees. */
|
|
62
|
+
export const hueDistance = (a, b) => {
|
|
63
|
+
const d = Math.abs(a - b) % 360;
|
|
64
|
+
return d > 180 ? 360 - d : d;
|
|
65
|
+
};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Design-tool component export: renders every variant of a component in the
|
|
3
|
+
* theme's islands, measures the computed box, and binds each value to a token.
|
|
4
|
+
*
|
|
5
|
+
* Nothing is modelled: the browser answers what a `.btn.danger.soft.lg`
|
|
6
|
+
* looks like, so a theme's overrides and every derived value come along for
|
|
7
|
+
* free. A component is described as one box and its texts — the box's layout
|
|
8
|
+
* (hug or fill, fixed or content height, row or column, centred or not) is
|
|
9
|
+
* read from the rendered element too, so the plugin only replays it.
|
|
10
|
+
*
|
|
11
|
+
* Colors bind to the theme token that paints the same value in every mode; a
|
|
12
|
+
* color no theme token paints (a soft fill, a soft border) becomes a recipe
|
|
13
|
+
* token named `<variant>-<intent>-<role>`, so the design tool still switches
|
|
14
|
+
* it with the mode. Lengths stay measured px: binding them too (radius,
|
|
15
|
+
* padding, height, type — about 1,800 bindings over 221 variants) froze
|
|
16
|
+
* Penpot, and a theme's own lengths already arrive through the measurement.
|
|
17
|
+
*/
|
|
18
|
+
import { hexOf, inPage, parseSrgb } from "./design-tokens.js";
|
|
19
|
+
|
|
20
|
+
/* Every list starts with its default: the first combination is the one a
|
|
21
|
+
design tool picks for a new instance. */
|
|
22
|
+
const INTENTS = ["default", "primary", "secondary", "success", "warning", "danger", "neutral"];
|
|
23
|
+
const SIZES = ["md", "sm", "lg"];
|
|
24
|
+
const BUTTON_VARIANTS = ["solid", "soft", "outline", "surface", "ghost", "link"];
|
|
25
|
+
|
|
26
|
+
const classes = (...names) => names.filter((n) => n && n !== "default" && n !== "md").join(" ");
|
|
27
|
+
|
|
28
|
+
/* One entry per design-tool component. Every combination of `properties` is
|
|
29
|
+
measured and built, and each property becomes a variant property, in the
|
|
30
|
+
order given — Intent, Variant, Size, the way an author stacks the classes.
|
|
31
|
+
`html` renders one combination: classes are the public API, so the markup
|
|
32
|
+
is what an author writes. "default" and "md" add no class. A soft-by-default
|
|
33
|
+
component (badge, alert) still measures its default as `soft`, so its
|
|
34
|
+
recipes share the button's names (`soft-danger-bg`) instead of doubling
|
|
35
|
+
them; the recipe guard below fails if the two ever paint differently. */
|
|
36
|
+
export const COMPONENTS = [
|
|
37
|
+
{
|
|
38
|
+
name: "Button",
|
|
39
|
+
properties: { Intent: INTENTS, Variant: BUTTON_VARIANTS, Size: SIZES },
|
|
40
|
+
html: ({ Intent, Variant, Size }) =>
|
|
41
|
+
`<button class="btn ${classes(Intent, Variant, Size)}" type="button">Button</button>`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "Input",
|
|
45
|
+
properties: { Size: SIZES },
|
|
46
|
+
html: ({ Size }) => `<input class="input ${classes(Size)}" value="Input" aria-label="Input">`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "Badge",
|
|
50
|
+
properties: { Intent: INTENTS, Variant: ["soft", "solid", "outline"], Size: SIZES },
|
|
51
|
+
html: ({ Intent, Variant, Size }) =>
|
|
52
|
+
`<span class="badge ${classes(Intent, Variant, Size)}">Badge</span>`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "Alert",
|
|
56
|
+
properties: { Intent: INTENTS, Variant: ["soft", "solid", "outline", "surface"] },
|
|
57
|
+
html: ({ Intent, Variant }) =>
|
|
58
|
+
`<div class="alert ${classes(Intent, Variant)}" role="status">Alert message</div>`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "Card",
|
|
62
|
+
properties: {},
|
|
63
|
+
html: () => '<article class="card"><h3>Card title</h3><p>Card content goes here.</p></article>',
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/* Wide enough for a card, narrow enough that a fill-width component (input,
|
|
68
|
+
alert) reads as a component rather than a page-wide bar. */
|
|
69
|
+
const SPECIMEN_WIDTH = "20rem";
|
|
70
|
+
|
|
71
|
+
/* Runs in the page: every specimen in every island, computed values only.
|
|
72
|
+
A specimen is one box plus its texts: the element's own text, an input's
|
|
73
|
+
value, or each child element (a card's title and body). */
|
|
74
|
+
function measureSpecimens({ css, islands, specimens, width }) {
|
|
75
|
+
const style = document.createElement("style");
|
|
76
|
+
style.textContent = css;
|
|
77
|
+
document.head.append(style);
|
|
78
|
+
const probe = document.createElement("div");
|
|
79
|
+
document.body.append(probe);
|
|
80
|
+
const srgb = (value) => {
|
|
81
|
+
probe.style.color = `color-mix(in srgb, ${value}, ${value})`;
|
|
82
|
+
return getComputedStyle(probe).color;
|
|
83
|
+
};
|
|
84
|
+
const px = (value) => Number.parseFloat(value) || 0;
|
|
85
|
+
|
|
86
|
+
const text = (cs, rect, label) => ({
|
|
87
|
+
label,
|
|
88
|
+
width: rect.width,
|
|
89
|
+
height: rect.height,
|
|
90
|
+
color: srgb(cs.color),
|
|
91
|
+
fontSize: px(cs.fontSize),
|
|
92
|
+
fontWeight: Number(cs.fontWeight),
|
|
93
|
+
lineHeight: cs.lineHeight === "normal" ? 1.2 : px(cs.lineHeight) / px(cs.fontSize),
|
|
94
|
+
underline: cs.textDecorationLine.includes("underline"),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const texts = (el, cs, rect) => {
|
|
98
|
+
if (el instanceof HTMLInputElement) {
|
|
99
|
+
const inner = {
|
|
100
|
+
width:
|
|
101
|
+
rect.width -
|
|
102
|
+
px(cs.paddingLeft) -
|
|
103
|
+
px(cs.paddingRight) -
|
|
104
|
+
px(cs.borderLeftWidth) -
|
|
105
|
+
px(cs.borderRightWidth),
|
|
106
|
+
height: cs.lineHeight === "normal" ? px(cs.fontSize) * 1.2 : px(cs.lineHeight),
|
|
107
|
+
};
|
|
108
|
+
return [{ ...text(cs, inner, el.value), rect: inner }];
|
|
109
|
+
}
|
|
110
|
+
if (el.children.length > 0) {
|
|
111
|
+
return [...el.children].map((child) => {
|
|
112
|
+
const box = child.getBoundingClientRect();
|
|
113
|
+
return { ...text(getComputedStyle(child), box, child.textContent.trim()), rect: box };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const range = document.createRange();
|
|
117
|
+
range.selectNodeContents(el);
|
|
118
|
+
const box = range.getBoundingClientRect();
|
|
119
|
+
return [{ ...text(cs, box, el.textContent.trim()), rect: box }];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return islands.map(({ attribute, scheme }) => {
|
|
123
|
+
const island = document.createElement("div");
|
|
124
|
+
if (attribute) island.dataset.theme = attribute;
|
|
125
|
+
if (scheme) island.style.colorScheme = scheme;
|
|
126
|
+
// What body gives the page: an island does not reset `color`, and the
|
|
127
|
+
// currentColor variants (outline, ghost, link) would otherwise inherit
|
|
128
|
+
// the ink of body's scheme instead of this island's.
|
|
129
|
+
island.style.color = "var(--text)";
|
|
130
|
+
island.innerHTML = specimens
|
|
131
|
+
.map((html) => `<div style="inline-size: ${width}">${html}</div>`)
|
|
132
|
+
.join("");
|
|
133
|
+
document.body.append(island);
|
|
134
|
+
|
|
135
|
+
const measured = [...island.children].map((wrap) => {
|
|
136
|
+
const el = wrap.firstElementChild;
|
|
137
|
+
const cs = getComputedStyle(el);
|
|
138
|
+
const rect = el.getBoundingClientRect();
|
|
139
|
+
const children = texts(el, cs, rect);
|
|
140
|
+
const flex = cs.display.includes("flex");
|
|
141
|
+
const column = flex ? cs.flexDirection.startsWith("column") : children.length > 1;
|
|
142
|
+
const [a, b] = children;
|
|
143
|
+
return {
|
|
144
|
+
width: rect.width,
|
|
145
|
+
height: rect.height,
|
|
146
|
+
// Narrower than its container: the component sizes to its content.
|
|
147
|
+
hug: rect.width < wrap.getBoundingClientRect().width - 0.5,
|
|
148
|
+
// A minimum height is a control size; otherwise content decides.
|
|
149
|
+
fixedHeight: px(cs.minHeight) > 0,
|
|
150
|
+
dir: column ? "column" : "row",
|
|
151
|
+
justify: cs.justifyContent === "center" ? "center" : "start",
|
|
152
|
+
gap: b ? Math.max(0, column ? b.rect.top - a.rect.bottom : b.rect.left - a.rect.right) : 0,
|
|
153
|
+
padding: {
|
|
154
|
+
top: px(cs.paddingTop),
|
|
155
|
+
right: px(cs.paddingRight),
|
|
156
|
+
bottom: px(cs.paddingBottom),
|
|
157
|
+
left: px(cs.paddingLeft),
|
|
158
|
+
},
|
|
159
|
+
borderWidth: px(cs.borderTopWidth),
|
|
160
|
+
radius: px(cs.borderTopLeftRadius),
|
|
161
|
+
bg: srgb(cs.backgroundColor),
|
|
162
|
+
border: srgb(cs.borderTopColor),
|
|
163
|
+
children: children.map(({ rect: _, ...child }) => child),
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
island.remove();
|
|
167
|
+
return measured;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function combinations(properties) {
|
|
172
|
+
return Object.entries(properties).reduce(
|
|
173
|
+
(acc, [name, values]) => acc.flatMap((props) => values.map((v) => ({ ...props, [name]: v }))),
|
|
174
|
+
[{}],
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/*
|
|
179
|
+
* Measures `components` for the islands of a resolved theme. Returns the
|
|
180
|
+
* recipe colors per mode (same shape as linked theme colors) and the spec a
|
|
181
|
+
* design-tool plugin builds from: per variant, the box and its texts in px,
|
|
182
|
+
* and each color's value for the first mode plus the token it binds to.
|
|
183
|
+
*/
|
|
184
|
+
export async function measureComponents({ css, resolved, linked, components = COMPONENTS }) {
|
|
185
|
+
const modes = Object.keys(resolved.modes);
|
|
186
|
+
const specimens = components.flatMap((component) =>
|
|
187
|
+
combinations(component.properties).map((props) => ({ component, props })),
|
|
188
|
+
);
|
|
189
|
+
const measured = await inPage(measureSpecimens, {
|
|
190
|
+
css,
|
|
191
|
+
islands: resolved.islands,
|
|
192
|
+
specimens: specimens.map(({ component, props }) => component.html(props)),
|
|
193
|
+
width: SPECIMEN_WIDTH,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Theme colors keyed by their value in every mode; aliases are skipped so a
|
|
197
|
+
// color binds to the concrete token (`text`, not `heading`).
|
|
198
|
+
const themeByValue = new Map();
|
|
199
|
+
for (const name of Object.keys(linked.modes[modes[0]])) {
|
|
200
|
+
if (linked.modes[modes[0]][name].alias) continue;
|
|
201
|
+
const key = modes.map((mode) => hexOf(resolved.modes[mode][name])).join(" ");
|
|
202
|
+
if (!themeByValue.has(key)) themeByValue.set(key, name);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const recipes = Object.fromEntries(modes.map((mode) => [mode, {}]));
|
|
206
|
+
// `perMode` is one computed color per mode.
|
|
207
|
+
const bindColor = (perMode, recipeName) => {
|
|
208
|
+
const colors = perMode.map(parseSrgb);
|
|
209
|
+
if (colors.every((c) => c.alpha === 0)) return null;
|
|
210
|
+
const hexes = colors.map(hexOf);
|
|
211
|
+
let token = themeByValue.get(hexes.join(" "));
|
|
212
|
+
if (!token) {
|
|
213
|
+
token = recipeName;
|
|
214
|
+
modes.forEach((mode, m) => {
|
|
215
|
+
const known = recipes[mode][token];
|
|
216
|
+
// A recipe name carries no size and no component: if two of them
|
|
217
|
+
// ever paint different colors, the name has to grow a dimension.
|
|
218
|
+
if (known && known.hex !== hexes[m]) {
|
|
219
|
+
throw new Error(`Recipe ${token} paints ${known.hex} and ${hexes[m]} in ${mode}`);
|
|
220
|
+
}
|
|
221
|
+
recipes[mode][token] = { color: colors[m], hex: hexes[m] };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const [first] = colors;
|
|
225
|
+
return { hex: hexOf({ ...first, alpha: 1 }), opacity: first.alpha, token };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const describe = (component, props, i) => {
|
|
229
|
+
const [m] = measured.map((island) => island[i]);
|
|
230
|
+
const each = (read) => measured.map((island) => read(island[i]));
|
|
231
|
+
// Intent and variant name the recipe; a component with neither uses its
|
|
232
|
+
// own name, so two plain components never share one.
|
|
233
|
+
const scope = [props.Variant, props.Intent].filter(Boolean);
|
|
234
|
+
const recipe = (role) =>
|
|
235
|
+
[...(scope.length ? scope : [component.name]), role].join("-").toLowerCase();
|
|
236
|
+
return {
|
|
237
|
+
props,
|
|
238
|
+
box: {
|
|
239
|
+
width: Math.ceil(m.width),
|
|
240
|
+
height: m.height,
|
|
241
|
+
hug: m.hug,
|
|
242
|
+
fixedHeight: m.fixedHeight,
|
|
243
|
+
dir: m.dir,
|
|
244
|
+
justify: m.justify,
|
|
245
|
+
gap: m.gap,
|
|
246
|
+
padding: m.padding,
|
|
247
|
+
radius: m.radius,
|
|
248
|
+
borderWidth: m.borderWidth,
|
|
249
|
+
fill: bindColor(
|
|
250
|
+
each((s) => s.bg),
|
|
251
|
+
recipe("bg"),
|
|
252
|
+
),
|
|
253
|
+
stroke:
|
|
254
|
+
m.borderWidth > 0
|
|
255
|
+
? bindColor(
|
|
256
|
+
each((s) => s.border),
|
|
257
|
+
recipe("border"),
|
|
258
|
+
)
|
|
259
|
+
: null,
|
|
260
|
+
},
|
|
261
|
+
texts: m.children.map((child, c) => ({
|
|
262
|
+
label: child.label,
|
|
263
|
+
width: Math.ceil(child.width),
|
|
264
|
+
height: Math.ceil(child.height),
|
|
265
|
+
lineHeight: Math.round(child.lineHeight * 1000) / 1000,
|
|
266
|
+
fontSize: child.fontSize,
|
|
267
|
+
fontWeight: child.fontWeight,
|
|
268
|
+
underline: child.underline,
|
|
269
|
+
color: bindColor(
|
|
270
|
+
each((s) => s.children[c].color),
|
|
271
|
+
recipe(c === 0 ? "fg" : `fg-${c + 1}`),
|
|
272
|
+
),
|
|
273
|
+
})),
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const spec = components.map((component) => ({
|
|
278
|
+
name: component.name,
|
|
279
|
+
properties: Object.keys(component.properties),
|
|
280
|
+
variants: specimens.flatMap(({ component: owner, props }, i) =>
|
|
281
|
+
owner === component ? [describe(component, props, i)] : [],
|
|
282
|
+
),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
return { recipes, spec: { components: spec } };
|
|
286
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Design-tool export for one theme: Figma and Penpot tokens, the measured
|
|
3
|
+
* component spec, and the Penpot plugin that builds it. Shared by the CLI
|
|
4
|
+
* (`actual-css design`) and the repository script (`bun run export:design`).
|
|
5
|
+
* Bun only: values are read in headless Chrome through Bun.WebView.
|
|
6
|
+
*/
|
|
7
|
+
import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { inlineImports } from "./css-bundle.js";
|
|
11
|
+
import { measureComponents } from "./design-components.js";
|
|
12
|
+
import {
|
|
13
|
+
coreTokenCss,
|
|
14
|
+
linkTokens,
|
|
15
|
+
resolveTokens,
|
|
16
|
+
themeNameOf,
|
|
17
|
+
toFigma,
|
|
18
|
+
toPenpot,
|
|
19
|
+
} from "./design-tokens.js";
|
|
20
|
+
|
|
21
|
+
const FRAMEWORK = fileURLToPath(new URL("../css/actual.full.css", import.meta.url));
|
|
22
|
+
const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* Writes, under `out`:
|
|
26
|
+
* figma/foundations.tokens.json, figma/<scheme>.tokens.json
|
|
27
|
+
* penpot/tokens.json
|
|
28
|
+
* penpot/plugin/ (manifest.json, plugin.js, ui.html, spec.json)
|
|
29
|
+
* Without `themeFile`, exports the default theme. `name` overrides the
|
|
30
|
+
* data-theme name read from the theme file. Returns the written paths.
|
|
31
|
+
*/
|
|
32
|
+
export async function exportDesign({ themeFile, name, out }) {
|
|
33
|
+
const themeCss = themeFile ? await inlineImports(themeFile) : "";
|
|
34
|
+
const theme = themeFile ? (name ?? themeNameOf(themeCss)) : null;
|
|
35
|
+
const css = `${await inlineImports(FRAMEWORK)}\n${themeCss}`;
|
|
36
|
+
|
|
37
|
+
const resolved = await resolveTokens({
|
|
38
|
+
css,
|
|
39
|
+
theme,
|
|
40
|
+
aliasSources: [await coreTokenCss(), themeFile ? await readFile(themeFile, "utf8") : ""],
|
|
41
|
+
});
|
|
42
|
+
const linked = linkTokens(resolved);
|
|
43
|
+
const { recipes, spec } = await measureComponents({ css, resolved, linked });
|
|
44
|
+
|
|
45
|
+
const documents = {
|
|
46
|
+
...Object.fromEntries(
|
|
47
|
+
Object.entries(toFigma(linked, recipes)).map(([file, doc]) => [
|
|
48
|
+
`figma/${file}.tokens.json`,
|
|
49
|
+
doc,
|
|
50
|
+
]),
|
|
51
|
+
),
|
|
52
|
+
"penpot/tokens.json": toPenpot(linked, recipes),
|
|
53
|
+
"penpot/plugin/spec.json": spec,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const written = [];
|
|
57
|
+
for (const [path, doc] of Object.entries(documents)) {
|
|
58
|
+
const file = join(out, path);
|
|
59
|
+
await mkdir(dirname(file), { recursive: true });
|
|
60
|
+
await writeFile(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
61
|
+
written.push(file);
|
|
62
|
+
}
|
|
63
|
+
for (const entry of await readdir(PLUGIN)) {
|
|
64
|
+
const file = join(out, "penpot", "plugin", entry);
|
|
65
|
+
await copyFile(join(PLUGIN, entry), file);
|
|
66
|
+
written.push(file);
|
|
67
|
+
}
|
|
68
|
+
return written;
|
|
69
|
+
}
|