actual-css 0.11.0 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.1] - 2026-09-30
6
+
7
+ ### Added
8
+
9
+ - `dialog.modal > header` provides aligned icon/title/action chrome through `--modal-header-bg`; modal body wrappers expose `--modal-pad` and support `.bleed` bands.
10
+ - `actual-css design` (Bun) exports a theme to Figma and Penpot tokens; see `docs/pages/guides/design-tools.md`.
11
+ - `actual-css design` writes a Penpot plugin that builds Button, Input, Badge, Alert and Card bound to those tokens.
12
+ - Soft fills and borders export as recipe tokens named `<variant>-<intent>-<role>` (`soft-danger-bg`).
13
+ - `scripts/serve.js --cors` serves the repository to another origin, such as penpot.app loading a local plugin.
14
+ - `--meter-track` sets the `.meter` track, matching `--progress-track`.
15
+ - `--close-bg` sets the dialog corner `.close` background, so a dialog over a painted band can drop the default chip.
16
+ - `--close-color` and `--close-hover-color` set the X ink at rest and on hover, so a bare X can take a painted band's foreground.
17
+ - `--close-hover-overlay` routes the `.close` hover/active wash (matching `--btn-hover-overlay`); the modal/drawer corner sets it transparent so only the ink changes.
18
+
19
+ ### Changed
20
+
21
+ - `dialog.modal` is borderless: the raised surface and shadow define the panel, so a full-bleed header, footer, or hero band no longer meets a hard edge; a forced-colors border keeps the outline.
22
+
23
+ ### Fixed
24
+
25
+ - A corner `.close` that is a direct child of `dialog.modal > header` reserves inline room for itself; previously only the wrapper `<header>` did, so a long chrome title could run under it.
26
+ - `dialog.modal` derives one `--dialog-inner-radius` for the body wrapper, the header band, and the `.dialog-confirmation` footer, so an edge band keeps the panel corner instead of taking its own radius.
27
+ - The `.dialog-confirmation` action band spreads a two-action decision to its edges, keeping a cancel action apart from the committing one.
28
+ - `.dialog-confirmation` centers its leading icon on the title while keeping supporting copy in the same text column.
29
+ - `.btn` and the `.file` selector button take their borders out of the block padding, so they match `.input` height at every size instead of standing 2px taller outside a `.join`.
30
+ - `tests/browser/control-geometry.test.js` measures controls outside a `.join`, whose stretch had hidden the mismatch.
5
31
 
6
32
  ## [0.11.0] - 2026-09-30
7
33
 
package/README.md CHANGED
@@ -42,6 +42,10 @@ Flatten the composition for production with
42
42
  resolves package subpaths and relative files, then inlines them without
43
43
  transpiling modern CSS.
44
44
 
45
+ Hand your theme to designers with `bunx actual-css design --theme theme.css --out design/`:
46
+ Figma and Penpot tokens, plus a Penpot plugin that builds the core components.
47
+ See [Design tools](docs/pages/guides/design-tools.md).
48
+
45
49
  ### Full bundle (zero-config)
46
50
 
47
51
  The bare `actual-css` entrypoint and `actual-css/full` both resolve to the
package/components.json CHANGED
@@ -135,7 +135,8 @@
135
135
  "source": "src/css/components/bleed.css",
136
136
  "cssImport": "actual-css/css/components/bleed",
137
137
  "classes": [
138
- "bleed"
138
+ "bleed",
139
+ "stack"
139
140
  ],
140
141
  "publicHooks": [
141
142
  "--bleed-pad"
@@ -198,6 +199,7 @@
198
199
  "--btn-default-hover-bg",
199
200
  "--btn-border-block-end-color",
200
201
  "--btn-border-block-end-width",
202
+ "--btn-border-block",
201
203
  "--spinner-width"
202
204
  ],
203
205
  "states": [
@@ -262,7 +264,11 @@
262
264
  ],
263
265
  "publicHooks": [
264
266
  "--close-size",
265
- "--close-icon-size"
267
+ "--close-icon-size",
268
+ "--close-bg",
269
+ "--close-color",
270
+ "--close-hover-color",
271
+ "--close-hover-overlay"
266
272
  ]
267
273
  },
268
274
  {
@@ -444,6 +450,7 @@
444
450
  "meter"
445
451
  ],
446
452
  "publicHooks": [
453
+ "--meter-track",
447
454
  "--meter-value",
448
455
  "--bar-height"
449
456
  ]
@@ -463,11 +470,14 @@
463
470
  ],
464
471
  "publicHooks": [
465
472
  "--modal-size",
473
+ "--modal-pad",
474
+ "--modal-header-bg",
466
475
  "--dialog-viewport-gap",
467
476
  "--dialog-icon-size",
468
477
  "--dialog-icon-glyph-size"
469
478
  ],
470
479
  "internalHooks": [
480
+ "--dialog-inner-radius",
471
481
  "--dialog-focus-bleed",
472
482
  "--dialog-viewport-block",
473
483
  "--dialog-viewport-gap-default",
@@ -2448,7 +2448,11 @@ small {
2448
2448
  the whole control, not just the filename text. */
2449
2449
  min-block-size: var(--control-size);
2450
2450
  padding-block: var(--space-10);
2451
- padding-block: max(var(--space-10), calc((var(--control-size) - 1lh) / 2));
2451
+ /* Border-box height: both borders come out of the padding, as on .btn. */
2452
+ padding-block: max(
2453
+ var(--space-10),
2454
+ calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
2455
+ );
2452
2456
  padding-inline: var(--space-20);
2453
2457
  border: var(--border-width) solid var(--border);
2454
2458
  border-radius: var(--radius-sm);
@@ -3735,6 +3739,7 @@ small {
3735
3739
  * --btn-default-hover-bg
3736
3740
  * --btn-border-block-end-color
3737
3741
  * --btn-border-block-end-width
3742
+ * --btn-border-block
3738
3743
  * --spinner-width
3739
3744
  */
3740
3745
 
@@ -3760,6 +3765,10 @@ small {
3760
3765
  --btn-default-hover-bg: var(--btn-bg);
3761
3766
  --btn-radius: var(--radius);
3762
3767
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
3768
+ --btn-border-block: calc(
3769
+ var(--border-width) +
3770
+ var(--btn-border-block-end-width, var(--border-width))
3771
+ );
3763
3772
 
3764
3773
  /* Positioning context for indicators, overlays, and grouped states. */
3765
3774
  position: relative;
@@ -3769,7 +3778,13 @@ small {
3769
3778
  gap: var(--btn-gap, 0.375em);
3770
3779
  min-block-size: var(--btn-min-size);
3771
3780
  padding-block: var(--space-20);
3772
- padding-block: max(var(--space-10), calc((var(--btn-min-size) - 1lh) / 2));
3781
+ /* why: centres one line in --btn-min-size and keeps that rhythm when the
3782
+ label wraps. min-block-size is a border-box length, so both borders come
3783
+ out of it; without them .btn stood 2px taller than .input. */
3784
+ padding-block: max(
3785
+ var(--space-10),
3786
+ calc((var(--btn-min-size) - 1lh - var(--btn-border-block)) / 2)
3787
+ );
3773
3788
  padding-inline: var(--btn-pad-x);
3774
3789
  border: var(--border-width) solid var(--btn-border-color, var(--btn-border));
3775
3790
  border-block-end-color: var(
@@ -3866,6 +3881,8 @@ small {
3866
3881
  --ui-fg: var(--intent, currentColor);
3867
3882
  --ui-border: transparent;
3868
3883
  --btn-radius: var(--radius-sm);
3884
+ /* border: 0 below; the padding must not subtract a border it lacks. */
3885
+ --btn-border-block: 0px;
3869
3886
 
3870
3887
  min-block-size: auto;
3871
3888
  padding-inline: 0;
@@ -3957,8 +3974,17 @@ a.btn[aria-disabled="true"] {
3957
3974
  * Public hooks:
3958
3975
  * --close-size
3959
3976
  * --close-icon-size
3977
+ * --close-bg
3978
+ * --close-color
3979
+ * --close-hover-color
3980
+ * --close-hover-overlay
3960
3981
  */
3961
3982
  .close {
3983
+ /* Routes the hover/active wash, as --btn-hover-overlay does for .btn. A
3984
+ context that wants ink-only feedback (the dialog corner) sets it
3985
+ transparent. */
3986
+ --close-hover-overlay: var(--hover-overlay);
3987
+
3962
3988
  display: inline-grid;
3963
3989
  place-items: center;
3964
3990
  flex: none;
@@ -3985,12 +4011,12 @@ a.btn[aria-disabled="true"] {
3985
4011
  the control (the dialog corner's --surface-subtle) instead of replacing it. */
3986
4012
  @media (hover: hover) {
3987
4013
  .close:hover {
3988
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4014
+ box-shadow: inset 0 0 0 999px var(--close-hover-overlay);
3989
4015
  }
3990
4016
  }
3991
4017
 
3992
4018
  .close:active {
3993
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4019
+ box-shadow: inset 0 0 0 999px var(--close-hover-overlay);
3994
4020
  }
3995
4021
 
3996
4022
  .close:focus-visible {
@@ -4002,23 +4028,25 @@ a.btn[aria-disabled="true"] {
4002
4028
  surface rather than to the header or a scroll container inside it. Matched
4003
4029
  on the documented positions (the dialog, its form, its header, or a form
4004
4030
  inside that header) so a .close inside content — an alert in the body —
4005
- keeps its own place. */
4031
+ keeps its own place. The corner drops the wash: only the ink changes. */
4006
4032
  :where(dialog):is(.modal, .drawer) > .close,
4007
4033
  :where(dialog):is(.modal, .drawer) > :is(form, header) > .close,
4008
4034
  :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close {
4035
+ --close-hover-overlay: transparent;
4036
+
4009
4037
  position: absolute;
4010
4038
  z-index: 1;
4011
4039
  inset-block-start: var(--space-20);
4012
4040
  inset-inline-end: var(--space-20);
4013
- background: var(--surface-subtle);
4014
- color: var(--text-muted);
4041
+ background: var(--close-bg, var(--surface-subtle));
4042
+ color: var(--close-color, var(--text-muted));
4015
4043
  }
4016
4044
 
4017
4045
  @media (hover: hover) {
4018
4046
  :where(dialog):is(.modal, .drawer) > .close:hover,
4019
4047
  :where(dialog):is(.modal, .drawer) > :is(form, header) > .close:hover,
4020
4048
  :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close:hover {
4021
- color: var(--text);
4049
+ color: var(--close-hover-color, var(--text));
4022
4050
  }
4023
4051
  }
4024
4052
 
@@ -4703,6 +4731,8 @@ a.badge[aria-disabled="true"] {
4703
4731
  *
4704
4732
  * Public hooks:
4705
4733
  * --modal-size
4734
+ * --modal-pad
4735
+ * --modal-header-bg
4706
4736
  * --dialog-viewport-gap
4707
4737
  * --dialog-icon-size
4708
4738
  * --dialog-icon-glyph-size
@@ -4710,6 +4740,7 @@ a.badge[aria-disabled="true"] {
4710
4740
  * The corner close control is .close (components/close.css).
4711
4741
  *
4712
4742
  * Internal:
4743
+ * --dialog-inner-radius
4713
4744
  * --dialog-focus-bleed
4714
4745
  * --dialog-viewport-block
4715
4746
  * --dialog-viewport-gap-default
@@ -4728,6 +4759,11 @@ a.badge[aria-disabled="true"] {
4728
4759
  var(--dialog-viewport-gap, var(--dialog-viewport-gap-default))
4729
4760
  );
4730
4761
 
4762
+ /* The panel is borderless, so its inner and outer radius are the same. Kept
4763
+ as one name for the padded wrapper and any edge band (header,
4764
+ confirmation footer) so none of them drifts from the corner. */
4765
+ --dialog-inner-radius: var(--radius-lg);
4766
+
4731
4767
  /* Keep the surface anchored to the viewport in the top layer. This also
4732
4768
  provides the containing block for the absolutely positioned close button. */
4733
4769
  position: fixed;
@@ -4741,7 +4777,9 @@ a.badge[aria-disabled="true"] {
4741
4777
  dynamic token; only the modal pins the small viewport. */
4742
4778
  --dialog-viewport-block: var(--viewport-block);
4743
4779
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
4744
- border: var(--border-width) solid var(--border);
4780
+ /* Zero rather than absent: the UA stylesheet draws `border: solid` on
4781
+ dialog, and an authored edge reads as a hard seam against .bleed bands. */
4782
+ border: 0;
4745
4783
  border-radius: var(--radius-lg);
4746
4784
  background: var(--surface-raised);
4747
4785
  color: var(--text);
@@ -4763,14 +4801,73 @@ a.badge[aria-disabled="true"] {
4763
4801
  transform var(--duration) var(--ease-exit);
4764
4802
  }
4765
4803
 
4804
+ @media (forced-colors: active) {
4805
+ /* With no authored border the panel edge disappears once colors collapse.
4806
+ The browser forces the border color, so only the width is authored. */
4807
+ :where(dialog).modal {
4808
+ border: var(--border-width) solid CanvasText;
4809
+ }
4810
+ }
4811
+
4766
4812
  :where(dialog).modal > form,
4767
4813
  :where(dialog).modal > .stack {
4814
+ --surface-pad: var(--modal-pad, var(--space-40));
4815
+
4768
4816
  display: grid;
4769
4817
  gap: var(--space-40);
4770
4818
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
4819
+ border-radius: var(--dialog-inner-radius);
4771
4820
  overflow: auto;
4772
4821
  overscroll-behavior: contain;
4773
- padding: var(--space-40);
4822
+ padding: var(--surface-pad);
4823
+ }
4824
+
4825
+ /* Optional dialog chrome lives beside the padded body wrapper. Its leading
4826
+ region and trailing controls share one center axis; wrapping the close in
4827
+ the trailing cluster keeps it in flow instead of invoking the absolute
4828
+ corner treatment from close.css. */
4829
+ :where(dialog).modal > header {
4830
+ display: flex;
4831
+ align-items: center;
4832
+ justify-content: space-between;
4833
+ gap: var(--space-30);
4834
+ min-block-size: var(--close-size, var(--control-size));
4835
+ padding: var(--modal-pad, var(--space-40));
4836
+ border-start-start-radius: var(--dialog-inner-radius);
4837
+ border-start-end-radius: var(--dialog-inner-radius);
4838
+ background: var(--modal-header-bg, var(--surface-subtle));
4839
+ }
4840
+
4841
+ :where(dialog).modal > header > .media {
4842
+ flex: 1 1 auto;
4843
+ min-inline-size: 0;
4844
+ }
4845
+
4846
+ :where(dialog).modal > header > .media > :is(h1, h2, h3, h4, h5, h6) {
4847
+ display: flex;
4848
+ align-items: center;
4849
+ min-block-size: var(--close-size, var(--control-size));
4850
+ margin-block: 0;
4851
+ }
4852
+
4853
+ :where(dialog).modal > header > .cluster {
4854
+ --cluster-wrap: nowrap;
4855
+
4856
+ flex: none;
4857
+ }
4858
+
4859
+ @supports selector(dialog:has(> header)) {
4860
+ /* The header stays fixed while the body wrapper owns overflow. Without
4861
+ :has(), compact dialogs keep ordinary block flow as the fallback. */
4862
+ :where(dialog).modal[open]:has(> header) {
4863
+ display: grid;
4864
+ grid-template-rows: auto minmax(0, 1fr);
4865
+ }
4866
+
4867
+ :where(dialog).modal:has(> header) > :is(form, .stack) {
4868
+ min-block-size: 0;
4869
+ max-block-size: none;
4870
+ }
4774
4871
  }
4775
4872
 
4776
4873
  @supports (block-size: 100svh) {
@@ -4822,12 +4919,32 @@ a.badge[aria-disabled="true"] {
4822
4919
  margin-block-end: 0;
4823
4920
  }
4824
4921
 
4922
+ /* A media row inside the structural header is title anatomy, so its heading
4923
+ does not bring prose margins into the icon alignment. */
4924
+ :where(dialog).modal > :where(form, .stack) > header > .media > :is(h1, h2, h3, h4, h5, h6) {
4925
+ margin-block: 0;
4926
+ }
4927
+
4825
4928
  /* The close button is anchored to the dialog at top inline-end, out of the
4826
4929
  content flow; headers reserve inline room so titles never run under it.
4827
4930
  The form/.stack anatomy is structural, so it stays in :where() and never
4828
4931
  lends .stack's specificity to a bare <form>. */
4829
4932
  :where(dialog).modal > :where(form, .stack) > header {
4830
- padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
4933
+ padding-inline-end: calc(
4934
+ var(--close-size, var(--control-size)) +
4935
+ var(--space-20) +
4936
+ var(--space-30)
4937
+ );
4938
+ }
4939
+
4940
+ /* A corner .close that is a direct child of the chrome header needs the same
4941
+ reserve, but never below the header's own pad. Kept as its own rule so an
4942
+ unsupported :has() drops only this geometry, not the wrapper reserve. */
4943
+ :where(dialog).modal > header:has(> .close) {
4944
+ padding-inline-end: max(
4945
+ var(--modal-pad, var(--space-40)),
4946
+ calc(var(--close-size, var(--control-size)) + var(--space-20) + var(--space-30))
4947
+ );
4831
4948
  }
4832
4949
 
4833
4950
  :where(dialog).modal > :where(form, .stack) > footer {
@@ -4841,19 +4958,25 @@ a.badge[aria-disabled="true"] {
4841
4958
  action band. The media primitive owns icon/content alignment; this variant
4842
4959
  only removes the standard form inset so both regions can own their padding. */
4843
4960
  :where(dialog).modal.dialog-confirmation > form {
4961
+ --surface-pad: 0px;
4962
+
4844
4963
  gap: 0;
4845
4964
  padding: 0;
4846
4965
  }
4847
4966
 
4848
- :where(dialog).modal.dialog-confirmation > form > .media {
4967
+ :where(dialog).modal.dialog-confirmation > form > :is(.media, header) {
4849
4968
  gap: var(--space-40);
4850
4969
  padding: var(--space-50);
4851
4970
  }
4852
4971
 
4972
+ /* The action band splits a decision pair: the cancel-style action holds the
4973
+ leading edge and the committing action the trailing one, so the destructive
4974
+ choice is never adjacent to the dismiss. */
4853
4975
  :where(dialog).modal.dialog-confirmation > form > footer {
4976
+ justify-content: space-between;
4854
4977
  padding: var(--space-40) var(--space-50);
4855
- border-end-start-radius: calc(var(--radius-lg) - var(--border-width));
4856
- border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
4978
+ border-end-start-radius: var(--dialog-inner-radius);
4979
+ border-end-end-radius: var(--dialog-inner-radius);
4857
4980
  background: var(--surface-subtle);
4858
4981
  }
4859
4982
 
@@ -4890,8 +5013,29 @@ a.badge[aria-disabled="true"] {
4890
5013
  line-height: 1;
4891
5014
  }
4892
5015
 
5016
+ :where(dialog).modal.dialog-confirmation > form > .media > header {
5017
+ display: grid;
5018
+ gap: var(--space-20);
5019
+ }
5020
+
5021
+ :where(dialog).modal.dialog-confirmation > form > .media > header > * {
5022
+ margin-block: 0;
5023
+ }
5024
+
5025
+ /* The first heading occupies the icon row, so its text centers on the icon
5026
+ without a font-relative nudge. Following copy stays in the same column. */
5027
+ :where(dialog).modal.dialog-confirmation
5028
+ > form
5029
+ > .media
5030
+ > header
5031
+ > :is(h1, h2, h3, h4, h5, h6):first-child {
5032
+ display: flex;
5033
+ align-items: center;
5034
+ min-block-size: var(--dialog-icon-size, 3rem);
5035
+ }
5036
+
4893
5037
  @media (max-width: 30rem) {
4894
- :where(dialog).modal.dialog-confirmation > form > .media,
5038
+ :where(dialog).modal.dialog-confirmation > form > :is(.media, header),
4895
5039
  :where(dialog).modal.dialog-confirmation > form > footer {
4896
5040
  padding: var(--space-40);
4897
5041
  }
@@ -5217,9 +5361,9 @@ html.has-modal-open.had-scrollbar {
5217
5361
  *
5218
5362
  * A surface that insets its direct children relays that inset to them as
5219
5363
  * --surface-pad; a direct .bleed child cancels it and reaches the surface
5220
- * edge. Card and drawer pad at the container level, so they own the token
5221
- * there; the accordion pads its summary and its panel instead, so the panel
5222
- * owns it and the item does not.
5364
+ * edge. Card and drawer pad at the container level, while modal pads its inner
5365
+ * wrapper; each owns the token where that inset lives. The accordion pads its
5366
+ * summary and panel instead, so the panel owns it and the item does not.
5223
5367
  *
5224
5368
  * Why the token is handed to the children rather than declared on the surface:
5225
5369
  * custom properties inherit, so a surface-level declaration would also reach a
@@ -5240,11 +5384,11 @@ html.has-modal-open.had-scrollbar {
5240
5384
  * Framework plumbing:
5241
5385
  * --surface-pad (declared by the surface)
5242
5386
  */
5243
- :where(.card, .drawer, .accordion details > summary + *) > * * {
5387
+ :where(.card, .drawer, dialog.modal > :is(form, .stack), .accordion details > summary + *) > * * {
5244
5388
  --bleed-pad: initial;
5245
5389
  }
5246
5390
 
5247
- :where(.card, .drawer, .accordion details > summary + *) > * {
5391
+ :where(.card, .drawer, dialog.modal > :is(form, .stack), .accordion details > summary + *) > * {
5248
5392
  --bleed-pad: var(--surface-pad);
5249
5393
  }
5250
5394
 
@@ -8158,11 +8302,14 @@ a.list-item[aria-current]::before {
8158
8302
  * .sm/.lg are deliberately not supported.
8159
8303
  *
8160
8304
  * Public hooks:
8305
+ * --meter-track
8161
8306
  * --meter-value
8162
8307
  * --bar-height
8163
8308
  */
8164
8309
 
8165
8310
  .meter {
8311
+ --meter-track: var(--surface-subtle);
8312
+
8166
8313
  appearance: none;
8167
8314
  -webkit-appearance: none;
8168
8315
  display: block;
@@ -8171,7 +8318,7 @@ a.list-item[aria-current]::before {
8171
8318
  overflow: hidden;
8172
8319
  border: 0;
8173
8320
  border-radius: var(--radius-full);
8174
- background: var(--surface-subtle);
8321
+ background: var(--meter-track);
8175
8322
  }
8176
8323
 
8177
8324
  /* A non-native meter is a weighted flex container. The element type keeps the
@@ -8198,7 +8345,7 @@ a.list-item[aria-current]::before {
8198
8345
  block-size: var(--bar-height);
8199
8346
  border: 0;
8200
8347
  border-radius: var(--radius-full);
8201
- background: var(--surface-subtle);
8348
+ background: var(--meter-track);
8202
8349
  }
8203
8350
 
8204
8351
  .meter::-webkit-meter-optimum-value {