actual-css 0.13.0 → 0.14.1

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +8 -8
  3. package/components.json +116 -10
  4. package/dist/actual.full.css +652 -77
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/widget-primitives.md +4 -0
  8. package/docs/navigation.json +2 -0
  9. package/docs/pages/components/alert.md +12 -2
  10. package/docs/pages/components/button.md +3 -2
  11. package/docs/pages/components/list.md +6 -3
  12. package/docs/pages/components/segmented.md +83 -0
  13. package/docs/pages/components/steps.md +4 -2
  14. package/docs/pages/enhancements/flyout.md +9 -3
  15. package/docs/pages/enhancements/overview.md +2 -1
  16. package/docs/pages/enhancements/tabs.md +7 -2
  17. package/docs/pages/enhancements/tooltip.md +3 -1
  18. package/docs/pages/examples/overview.md +5 -3
  19. package/docs/pages/forms/color-swatches.md +131 -0
  20. package/docs/pages/forms/combobox.md +2 -2
  21. package/docs/pages/forms/controls.md +6 -3
  22. package/docs/pages/forms/joined-controls.md +6 -2
  23. package/docs/pages/forms/overview.md +1 -0
  24. package/docs/pages/forms/switch.md +4 -2
  25. package/docs/pages/foundations/theming.md +1 -1
  26. package/docs/pages/foundations/tokens.md +54 -23
  27. package/docs/pages/guides/design-tools.md +13 -7
  28. package/docs/pages/guides/introduction.md +1 -1
  29. package/docs/pages/layout/scroll-snap.md +4 -2
  30. package/package.json +5 -4
  31. package/reserved-classes.json +3 -0
  32. package/src/cli/actual-css.js +3 -3
  33. package/src/css/components/alert.css +65 -14
  34. package/src/css/components/app-nav.css +17 -9
  35. package/src/css/components/badge.css +23 -9
  36. package/src/css/components/button.css +17 -2
  37. package/src/css/components/close.css +6 -3
  38. package/src/css/components/index.css +1 -0
  39. package/src/css/components/list.css +18 -3
  40. package/src/css/components/navbar.css +5 -2
  41. package/src/css/components/segmented.css +186 -0
  42. package/src/css/components/steps.css +6 -3
  43. package/src/css/components/tab.css +1 -1
  44. package/src/css/core/focus.css +3 -0
  45. package/src/css/core/tokens.css +20 -4
  46. package/src/css/core/variants.css +16 -5
  47. package/src/css/forms/color-swatch.css +163 -0
  48. package/src/css/forms/form-actions.css +9 -0
  49. package/src/css/forms/index.css +1 -0
  50. package/src/css/forms/native.css +50 -20
  51. package/src/css/forms/switch.css +5 -2
  52. package/src/css/layout/scroll-snap.css +30 -0
  53. package/src/css/layout/topbar.css +10 -0
  54. package/src/js/enhance.js +14 -0
  55. package/src/js/floating-space.js +26 -0
  56. package/src/js/flyout.js +3 -1
  57. package/src/js/focus.js +16 -1
  58. package/src/js/parse-config.js +5 -2
  59. package/src/js/surface.js +10 -0
  60. package/src/js/tooltip.js +2 -2
  61. package/src/tooling/color.js +14 -1
  62. package/src/tooling/css-bundle.js +0 -5
  63. package/src/tooling/design-components.js +9 -5
  64. package/src/tooling/design-export.js +8 -0
  65. package/src/tooling/design-tokens.js +163 -43
  66. package/src/tooling/theme-contrast.js +151 -22
@@ -13,16 +13,36 @@
13
13
  * --scroll-snap-align
14
14
  * --scroll-snap-item-size
15
15
  * --scroll-snap-padding
16
+ *
17
+ * Internal:
18
+ * --scroll-snap-focus-bleed
16
19
  */
17
20
  .scroll-snap {
18
21
  --scroll-snap-gap: var(--gap);
19
22
  --scroll-snap-align: start;
20
23
  --scroll-snap-item-size: auto;
24
+ /* Declared here, not as a :root alias: an inherited alias arrives already
25
+ computed, so a --focus-ring-width override on the rail would not reach it. */
26
+ --scroll-snap-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
21
27
 
22
28
  display: flex;
23
29
  flex-direction: row;
24
30
  gap: var(--scroll-snap-gap);
31
+ /* Both axes clip: `auto` on one computes the other to `auto`, so a ring on
32
+ the top or bottom item would be lost too. Every side reserves the ring's
33
+ own width; the block reserve returns to the flow through a negative
34
+ margin, while the inline reserve stays as the rail's focus gutter.
35
+ Bleeding the inline reserve outward instead would make the border box
36
+ `containing block + 2 * reserve` wide, which at the viewport edge grows
37
+ the page: a rail cannot be flush with arbitrary surrounding content and
38
+ free of page overflow at once. */
39
+ margin-block: calc(var(--scroll-snap-focus-bleed) * -1);
40
+ padding-block: var(--scroll-snap-focus-bleed);
41
+ padding-inline: var(--scroll-snap-focus-bleed);
25
42
  overflow-x: auto;
43
+ /* One scroll axis, stated: a fractional content height could otherwise raise
44
+ a phantom vertical scrollbar under one font or zoom step. */
45
+ overflow-y: hidden;
26
46
  }
27
47
 
28
48
  .scroll-snap > * {
@@ -32,7 +52,17 @@
32
52
  @supports (scroll-snap-type: x mandatory) {
33
53
  .scroll-snap {
34
54
  overscroll-behavior-inline: contain;
55
+ /* Fallback only, never the ring's protector: padding-inline above already
56
+ guarantees paint. A unitless `0` drops the clamp below but keeps this
57
+ declaration, so the worst case is a snapped item flush with the gutter,
58
+ not a clipped ring. */
35
59
  scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
60
+ /* A conforming <length> stays at least the focus reserve, so scroll and
61
+ snap stop with the gutter's air. */
62
+ scroll-padding-inline: max(
63
+ var(--scroll-snap-padding, var(--scroll-snap-gap)),
64
+ var(--scroll-snap-focus-bleed)
65
+ );
36
66
  /* x then inline: an engine that lacks the logical keyword keeps x.
37
67
  proximity is the default, so a short rail cannot trap the pointer. */
38
68
  scroll-snap-type: x proximity;
@@ -45,3 +45,13 @@
45
45
  -webkit-backdrop-filter: blur(0.75rem);
46
46
  }
47
47
  }
48
+
49
+ /* A reduce override, not a no-preference gate: a browser without the feature
50
+ evaluates it false and keeps the frosted bar instead of losing it. */
51
+ @media (prefers-reduced-transparency: reduce) {
52
+ .topbar {
53
+ background: var(--surface);
54
+ backdrop-filter: none;
55
+ -webkit-backdrop-filter: none;
56
+ }
57
+ }
package/src/js/enhance.js CHANGED
@@ -31,6 +31,20 @@
31
31
  const registries = new WeakMap();
32
32
  const ownedNames = new WeakMap();
33
33
 
34
+ // Each evaluation owns its registries, so a second copy (the dist bundle plus
35
+ // source modules, or two bundles) registers every behavior twice and fails
36
+ // silently — a flyout opens then closes. Every built-in imports this module,
37
+ // so the marker catches any duplicated runtime once, here.
38
+ const RUNTIME = Symbol.for("actual-css.runtime");
39
+ if (typeof document !== "undefined") {
40
+ if (globalThis[RUNTIME]) {
41
+ console.warn(
42
+ "actual-css: a second copy of the JavaScript runtime is loaded. Load the full bundle or the source modules, never both.",
43
+ );
44
+ }
45
+ globalThis[RUNTIME] = true;
46
+ }
47
+
34
48
  // Node type constants as literals, not the realm's Node global, so nodes from
35
49
  // another document/window compare correctly.
36
50
  const ELEMENT_NODE = 1;
@@ -29,6 +29,32 @@ function isViewportAnchored(anchor) {
29
29
  return false;
30
30
  }
31
31
 
32
+ // Mirrors the placements @lekoala/floating accepts; it throws on any other
33
+ // value, and from an autoUpdate callback that throw would repeat on every
34
+ // scroll. Markup is validated here instead, so a typo warns once.
35
+ const PLACEMENTS = new Set(
36
+ ["top", "bottom", "left", "right"].flatMap((side) => [side, `${side}-start`, `${side}-end`]),
37
+ );
38
+ const warnedPlacements = new WeakSet();
39
+
40
+ /**
41
+ * The author's placement attribute, or null when absent or unknown.
42
+ *
43
+ * @param {Element | null | undefined} el
44
+ * @param {string} attribute
45
+ * @returns {string | null}
46
+ */
47
+ export function readPlacement(el, attribute) {
48
+ const value = el?.getAttribute(attribute);
49
+ if (!value) return null;
50
+ if (PLACEMENTS.has(value)) return value;
51
+ if (!warnedPlacements.has(el)) {
52
+ warnedPlacements.add(el);
53
+ console.warn(`Unknown ${attribute}="${value}" ignored`, el);
54
+ }
55
+ return null;
56
+ }
57
+
32
58
  /**
33
59
  * @param {Element | null | undefined} anchor
34
60
  * @returns {{ space: "viewport" | "document", position: "fixed" | "absolute" }}
package/src/js/flyout.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { registerEnhancement } from "./enhance.js";
2
+ import { readPlacement } from "./floating-space.js";
2
3
  import { focusFirstDescendant } from "./focus.js";
3
4
  import { shouldIgnoreKey } from "./keys.js";
4
5
  import { connectMenu, focusFirstMenuItem, focusLastMenuItem } from "./menu.js";
@@ -9,7 +10,8 @@ const triggerMap = new WeakMap();
9
10
  function readFlyoutOptions(panel) {
10
11
  const opts = { autoClose: "true" };
11
12
  const ds = panel.dataset;
12
- if (ds.flyoutPlacement) opts.placement = ds.flyoutPlacement;
13
+ const placement = readPlacement(panel, "data-flyout-placement");
14
+ if (placement) opts.placement = placement;
13
15
  if (ds.flyoutDistance != null) {
14
16
  const d = Number.parseFloat(ds.flyoutDistance);
15
17
  if (Number.isFinite(d)) opts.distance = d;
package/src/js/focus.js CHANGED
@@ -44,8 +44,23 @@ export function getFocusable(root) {
44
44
  return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
45
45
  }
46
46
 
47
+ /* Tab reaches a radio group at its checked radio, or at its first one when none
48
+ is checked; the other radios are arrow-key stops only. Entering a panel on
49
+ an unchecked radio put focus on a value the user had not picked. */
50
+ function isRadioTabStop(el) {
51
+ if (el.type !== "radio" || el.checked || !el.name) return true;
52
+ // form.elements also holds controls associated through form="…".
53
+ const candidates = el.form
54
+ ? [...el.form.elements]
55
+ : [...el.getRootNode().querySelectorAll('input[type="radio"]')];
56
+ return !candidates.some(
57
+ (radio) =>
58
+ radio.type === "radio" && radio.name === el.name && radio.form === el.form && radio.checked,
59
+ );
60
+ }
61
+
47
62
  export function focusFirstDescendant(root) {
48
- const target = getFocusable(root)[0];
63
+ const target = getFocusable(root).filter(isRadioTabStop)[0];
49
64
  target?.focus();
50
65
  return !!target;
51
66
  }
@@ -11,10 +11,13 @@
11
11
  */
12
12
 
13
13
  function relax(source) {
14
+ // Double-quoted strings are matched first and copied verbatim, so text
15
+ // such as `http:` inside them is never mistaken for an unquoted key.
14
16
  return source.replace(
15
- /([a-zA-Z_$][\w$-]*)\s*:|'((?:\\'|[^'])*)'/g,
16
- (_match, key, stringContent) => {
17
+ /"(?:\\.|[^"\\])*"|([a-zA-Z_$][\w$-]*)\s*:|'((?:\\'|[^'])*)'/g,
18
+ (match, key, stringContent) => {
17
19
  if (key) return `"${key}":`;
20
+ if (stringContent === undefined) return match;
18
21
  const unescaped = stringContent.replace(/\\'/g, "'");
19
22
  const escaped = unescaped.replace(/"/g, '\\"');
20
23
  return `"${escaped}"`;
package/src/js/surface.js CHANGED
@@ -386,8 +386,17 @@ export function disconnectSurface(menu) {
386
386
  }
387
387
  }
388
388
 
389
+ /* Arrow keys in a radio group check the next radio by dispatching a click
390
+ (detail 0, no pointer). That click is navigation, not a pick: closing on it
391
+ shut a palette on the first arrow press, before the user could reach the
392
+ color they wanted. Space dispatches the same click and is left alone too. */
393
+ function isKeyboardRadioMove(e) {
394
+ return e.detail === 0 && e.target?.matches?.('input[type="radio"]') === true;
395
+ }
396
+
389
397
  function onDocumentClick(e) {
390
398
  const path = e.composedPath();
399
+ const radioMove = isKeyboardRadioMove(e);
391
400
 
392
401
  for (const menu of openSurfaces) {
393
402
  if (menu.ownerDocument !== e.currentTarget) continue;
@@ -396,6 +405,7 @@ function onDocumentClick(e) {
396
405
 
397
406
  const menuIndex = path.indexOf(menu);
398
407
  const isInside = menuIndex >= 0;
408
+ if (isInside && radioMove) continue;
399
409
  const hasCloseTrigger =
400
410
  isInside &&
401
411
  path
package/src/js/tooltip.js CHANGED
@@ -51,7 +51,7 @@
51
51
  import { autoUpdate, reposition } from "@lekoala/floating";
52
52
  import enhance from "./enhance.js";
53
53
  import { registerEscapeDismissal } from "./escape.js";
54
- import { positionModeFor } from "./floating-space.js";
54
+ import { positionModeFor, readPlacement } from "./floating-space.js";
55
55
  import { CLASSES } from "./selectors.js";
56
56
 
57
57
  const SHOW_DELAY_MS = 150;
@@ -144,7 +144,7 @@ export function isTooltipVisible(tip) {
144
144
  * the tip's own placement never enters into it.
145
145
  */
146
146
  function placementFor(ref) {
147
- return ref?.getAttribute("data-tooltip-placement") || "top";
147
+ return readPlacement(ref, "data-tooltip-placement") ?? "top";
148
148
  }
149
149
 
150
150
  function hasOption(trigger, name) {
@@ -10,7 +10,8 @@
10
10
  * `const rgba = ${RASTERIZE};`. getComputedStyle returns color-mix() and
11
11
  * light-dark() results as oklab() or color(), so a 1x1 canvas turns any
12
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.
13
+ * translucent color instead of measuring it as if it were opaque; composite()
14
+ * resolves it over its backdrop first.
14
15
  */
15
16
  export const RASTERIZE = `(() => {
16
17
  const canvas = Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
@@ -46,6 +47,18 @@ export function contrast(a, b) {
46
47
  return (hi + 0.05) / (lo + 0.05);
47
48
  }
48
49
 
50
+ /* `top` painted over `base`, the way the page composites a fill: source-over
51
+ on gamma-encoded sRGB bytes. The result is opaque when `base` is, so a
52
+ translucent soft fill becomes measurable once its backdrop is known. */
53
+ export function composite(top, base) {
54
+ const a = (top[3] ?? 255) / 255;
55
+ const b = (base[3] ?? 255) / 255;
56
+ const alpha = a + b * (1 - a);
57
+ if (alpha === 0) return [0, 0, 0, 0];
58
+ const channel = (i) => Math.round((top[i] * a + base[i] * b * (1 - a)) / alpha);
59
+ return [channel(0), channel(1), channel(2), Math.round(alpha * 255)];
60
+ }
61
+
49
62
  /* OKLCH of an sRGB color: L 0-1, C chroma, h degrees. */
50
63
  export function oklch([r, g, b]) {
51
64
  const [lr, lg, lb] = [r, g, b].map(toLinear);
@@ -298,11 +298,6 @@ export function bundledCssIssues(css) {
298
298
  const issues = [];
299
299
 
300
300
  if (css.includes("@import")) issues.push("contains unresolved @import");
301
- if (css.includes("--lightningcss")) issues.push("contains --lightningcss-* variables");
302
- if (/color-mix\(in srgb/.test(css)) issues.push("contains color-mix(in srgb, ...)");
303
- if (/@media[^{]*\(\s*width\s*[<>]=/.test(css)) {
304
- issues.push("contains transpiled media query range syntax");
305
- }
306
301
 
307
302
  return issues;
308
303
  }
@@ -15,7 +15,7 @@
15
15
  * padding, height, type — about 1,800 bindings over 221 variants) froze
16
16
  * Penpot, and a theme's own lengths already arrive through the measurement.
17
17
  */
18
- import { hexOf, inPage, parseSrgb } from "./design-tokens.js";
18
+ import { hexOf, hexRgbOf, inPage, parseSrgb } from "./design-tokens.js";
19
19
 
20
20
  /* Every list starts with its default: the first combination is the one a
21
21
  design tool picks for a new instance. */
@@ -214,11 +214,15 @@ export async function measureComponents({ css, resolved, linked, components = CO
214
214
  modes.forEach((mode, m) => {
215
215
  const known = recipes[mode][token];
216
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}`);
217
+ // ever paint different colors, the name has to grow a dimension. The
218
+ // comparison is on the CSS form (alpha included), the stored hex is the
219
+ // 6-digit DTCG form.
220
+ if (known && hexOf(known.color) !== hexes[m]) {
221
+ throw new Error(
222
+ `Recipe ${token} paints ${hexOf(known.color)} and ${hexes[m]} in ${mode}`,
223
+ );
220
224
  }
221
- recipes[mode][token] = { color: colors[m], hex: hexes[m] };
225
+ recipes[mode][token] = { color: colors[m], hex: hexRgbOf(colors[m]) };
222
226
  });
223
227
  }
224
228
  const [first] = colors;
@@ -14,6 +14,7 @@ import {
14
14
  linkTokens,
15
15
  resolveTokens,
16
16
  themeNameOf,
17
+ toDtcg,
17
18
  toFigma,
18
19
  toPenpot,
19
20
  } from "./design-tokens.js";
@@ -24,6 +25,7 @@ const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
24
25
  /*
25
26
  * Writes, under `out`:
26
27
  * figma/foundations.tokens.json, figma/<scheme>.tokens.json
28
+ * dtcg/foundations.tokens.json, dtcg/<scheme>.tokens.json
27
29
  * penpot/tokens.json
28
30
  * penpot/plugin/ (manifest.json, plugin.js, ui.html, spec.json)
29
31
  * Without `themeFile`, exports the default theme. `name` overrides the
@@ -49,6 +51,12 @@ export async function exportDesign({ themeFile, name, out }) {
49
51
  doc,
50
52
  ]),
51
53
  ),
54
+ ...Object.fromEntries(
55
+ Object.entries(toDtcg(linked, recipes)).map(([file, doc]) => [
56
+ `dtcg/${file}.tokens.json`,
57
+ doc,
58
+ ]),
59
+ ),
52
60
  "penpot/tokens.json": toPenpot(linked, recipes),
53
61
  "penpot/plugin/spec.json": spec,
54
62
  };