actual-css 0.9.1 → 0.10.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +67 -45
  3. package/components.json +33 -13
  4. package/dist/actual.full.css +607 -502
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/app-ui.md +1 -1
  8. package/docs/design-notes/column-layout.md +103 -520
  9. package/docs/design-notes/forced-colors.md +12 -1
  10. package/docs/design-notes/naming.md +1 -1
  11. package/docs/design-notes/surface-reparenting.md +8 -9
  12. package/docs/navigation.json +2 -0
  13. package/docs/pages/components/accordion.md +55 -1
  14. package/docs/pages/components/alert.md +9 -15
  15. package/docs/pages/components/badge.md +6 -7
  16. package/docs/pages/components/bleed.md +42 -0
  17. package/docs/pages/components/button.md +30 -4
  18. package/docs/pages/components/card.md +21 -86
  19. package/docs/pages/components/close.md +48 -0
  20. package/docs/pages/components/data-list.md +26 -1
  21. package/docs/pages/components/dialog.md +16 -16
  22. package/docs/pages/components/drawer.md +9 -9
  23. package/docs/pages/components/navbar.md +5 -5
  24. package/docs/pages/components/spinner.md +3 -3
  25. package/docs/pages/components/table.md +3 -2
  26. package/docs/pages/enhancements/overview.md +5 -0
  27. package/docs/pages/enhancements/tooltip.md +3 -1
  28. package/docs/pages/examples/overview.md +3 -1
  29. package/docs/pages/forms/choice-cards.md +39 -0
  30. package/docs/pages/forms/combobox.md +23 -11
  31. package/docs/pages/forms/input-policy.md +59 -0
  32. package/docs/pages/forms/joined-controls.md +2 -5
  33. package/docs/pages/forms/overview.md +6 -0
  34. package/docs/pages/forms/validation.md +37 -1
  35. package/docs/pages/foundations/theming.md +64 -18
  36. package/docs/pages/foundations/tokens.md +50 -13
  37. package/docs/pages/foundations/typography.md +10 -0
  38. package/docs/pages/guides/introduction.md +17 -24
  39. package/docs/pages/guides/modular-import.md +53 -6
  40. package/docs/pages/guides/progressive-enhancement.md +9 -5
  41. package/docs/pages/guides/tailwind.md +17 -5
  42. package/docs/pages/layout/column-layout.md +92 -54
  43. package/docs/pages/layout/media.md +1 -1
  44. package/docs/pages/layout/overview.md +1 -1
  45. package/docs/pages/utilities/overview.md +1 -1
  46. package/docs/pages/utilities/sizing-wrapping.md +5 -3
  47. package/llms.txt +24 -4
  48. package/package.json +7 -6
  49. package/reserved-classes.json +5 -30
  50. package/skills/actual-css/PROJECT.md.example +65 -0
  51. package/skills/actual-css/README.md +64 -0
  52. package/skills/actual-css/SKILL.md +234 -0
  53. package/skills/actual-css/references/accessibility.md +44 -0
  54. package/skills/actual-css/references/api-and-vocabulary.md +94 -0
  55. package/skills/actual-css/references/enhancements.md +59 -0
  56. package/skills/actual-css/references/forms.md +49 -0
  57. package/skills/actual-css/references/layout-and-theming.md +82 -0
  58. package/skills/actual-css/references/review-checklist.md +51 -0
  59. package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
  60. package/src/css/components/accordion.css +44 -7
  61. package/src/css/components/alert.css +23 -43
  62. package/src/css/components/app-nav.css +9 -0
  63. package/src/css/components/badge.css +11 -39
  64. package/src/css/components/bleed.css +79 -0
  65. package/src/css/components/button.css +6 -17
  66. package/src/css/components/card.css +7 -40
  67. package/src/css/components/close.css +91 -0
  68. package/src/css/components/data-list.css +18 -0
  69. package/src/css/components/drawer.css +15 -56
  70. package/src/css/components/fab.css +10 -0
  71. package/src/css/components/flyout.css +1 -0
  72. package/src/css/components/index.css +2 -0
  73. package/src/css/components/join.css +36 -20
  74. package/src/css/components/menu.css +12 -0
  75. package/src/css/components/modal.css +10 -57
  76. package/src/css/components/navbar.css +27 -0
  77. package/src/css/core/base.css +2 -0
  78. package/src/css/core/focus.css +9 -3
  79. package/src/css/core/theme.css +25 -43
  80. package/src/css/core/tokens.css +3 -1
  81. package/src/css/core/variants.css +15 -36
  82. package/src/css/forms/choice-card.css +74 -4
  83. package/src/css/forms/choice.css +2 -3
  84. package/src/css/forms/control.css +15 -8
  85. package/src/css/forms/custom-select.css +4 -4
  86. package/src/css/forms/form.css +6 -0
  87. package/src/css/forms/native.css +4 -4
  88. package/src/css/forms/range.css +23 -8
  89. package/src/css/forms/switch.css +2 -3
  90. package/src/css/layout/app-layout.css +1 -1
  91. package/src/css/layout/column-layout.css +26 -111
  92. package/src/css/utilities/extra.css +7 -6
  93. package/src/js/dialog.js +95 -24
  94. package/src/js/enhance.js +7 -3
  95. package/src/js/escape.js +1 -0
  96. package/src/js/flyout.js +10 -1
  97. package/src/js/focus-group.js +2 -1
  98. package/src/js/keys.js +14 -0
  99. package/src/js/menu.js +2 -1
  100. package/src/js/status.js +19 -0
  101. package/src/js/surface.js +12 -8
  102. package/src/js/tab.js +2 -1
  103. package/src/js/tooltip.js +18 -1
  104. package/src/js/validation.js +36 -6
  105. package/dist/actual.css +0 -863
  106. package/dist/actual.js +0 -1
  107. package/dist/actual.min.css +0 -1
@@ -6,16 +6,17 @@
6
6
 
7
7
  ## Class reference
8
8
 
9
- | Class | Kind | Description |
10
- | ---------------------- | --------- | ---------------------------------------------- |
11
- | `.modal` | Component | Centered surface on the native `<dialog>`. |
12
- | `.scrollable` | Variant | Header and footer stay while the body scrolls. |
13
- | `.dialog-confirmation` | Variant | Message above a full-width action band. |
14
- | `.dialog-icon` | Component | Circular intent-aware icon well. |
15
- | `.dialog-close` | Component | Icon-only close button, at the top end. |
16
-
17
- `.dialog-close` sits outside the content flow, so it does not take part in the
18
- dialog's own layout.
9
+ | Class | Kind | Description |
10
+ | ---------------------- | ----------- | ---------------------------------------------- |
11
+ | `.modal` | Component | Centered surface on the native `<dialog>`. |
12
+ | `.scrollable` | Variant | Header and footer stay while the body scrolls. |
13
+ | `.dialog-confirmation` | Variant | Message above a full-width action band. |
14
+ | `.dialog-icon` | Component | Circular intent-aware icon well. |
15
+ | `.close` | Composition | Icon-only close button, at the top end. |
16
+
17
+ A [`.close`](close.md) placed as a direct child of the dialog or of its form
18
+ sits outside the content flow, so it does not take part in the dialog's own
19
+ layout.
19
20
 
20
21
  ## Usage
21
22
 
@@ -138,7 +139,7 @@ has no action button; the header close button dismisses the dialog.
138
139
  id="details-dialog"
139
140
  closedby="any"
140
141
  data-dialog-dismissible>
141
- <button class="dialog-close"
142
+ <button class="close"
142
143
  type="button"
143
144
  commandfor="details-dialog"
144
145
  command="request-close"
@@ -243,7 +244,7 @@ promoted above it and still unclickable.
243
244
  id="overlay-dialog"
244
245
  closedby="any"
245
246
  data-dialog-dismissible>
246
- <button class="dialog-close"
247
+ <button class="close"
247
248
  type="button"
248
249
  commandfor="overlay-dialog"
249
250
  command="request-close"
@@ -381,7 +382,6 @@ Keep the button a direct child of `dialog.modal` so the scrolling content
381
382
  - `--dialog-viewport-gap` — distance kept between the dialog and the viewport edges.
382
383
  - `--dialog-icon-size` — diameter of the `.dialog-icon` circle.
383
384
  - `--dialog-icon-glyph-size` — size of the glyph centered inside `.dialog-icon`.
384
- - `--control-size` — inline and block size of the `.dialog-close` button. The
385
- header reserves `calc(var(--control-size) + var(--space-30))` on its inline
386
- end so the title never runs under the close.
387
- - `--dialog-close-icon-size` — size of the close glyph.
385
+ - `--close-size` / `--close-icon-size` — size of the corner `.close` and of its
386
+ X. The header reserves the close size plus `--space-30` on its inline end so
387
+ the title never runs under it.
@@ -6,10 +6,10 @@
6
6
 
7
7
  ## Class reference
8
8
 
9
- | Class | Kind | Description |
10
- | --------------- | --------- | ---------------------------------------------------------- |
11
- | `.drawer` | Component | Modal side-sheet built on the native `<dialog>` element. |
12
- | `.drawer-close` | Component | Icon-only close button that sits inside the drawer header. |
9
+ | Class | Kind | Description |
10
+ | --------- | ----------- | -------------------------------------------------------- |
11
+ | `.drawer` | Component | Modal side-sheet built on the native `<dialog>` element. |
12
+ | `.close` | Composition | Icon-only close button, at the top end. |
13
13
 
14
14
  ## Usage
15
15
 
@@ -56,7 +56,7 @@ your intended target `autofocus`; otherwise the body claims initial focus.
56
56
  <strong>Menu</strong>
57
57
 
58
58
  <form method="dialog">
59
- <button class="drawer-close" type="submit" aria-label="Close navigation"></button>
59
+ <button class="close" type="submit" aria-label="Close navigation"></button>
60
60
  </form>
61
61
  </header>
62
62
 
@@ -96,7 +96,7 @@ actions and the close button can close the drawer.
96
96
  <form method="dialog">
97
97
  <header>
98
98
  <strong>Settings</strong>
99
- <button class="drawer-close" type="submit" aria-label="Close settings"></button>
99
+ <button class="close" type="submit" aria-label="Close settings"></button>
100
100
  </header>
101
101
 
102
102
  <div class="stack">
@@ -123,6 +123,6 @@ useful for drawers that should not be dismissed accidentally.
123
123
 
124
124
  - `--drawer-size` — panel width.
125
125
  - `--drawer-pad` — panel padding.
126
- - `--control-size` — size of the `.drawer-close` button. The drawer header
127
- reserves `calc(var(--control-size) + var(--space-30))` on its inline end so
128
- the title never runs under the close.
126
+ - `--close-size` / `--close-icon-size` — size of the corner [`.close`](close.md)
127
+ and of its X. The header reserves the close size plus `--space-30` on its
128
+ inline end so the title never runs under it.
@@ -51,7 +51,7 @@ icons. See [App Navigation](app-navigation.md).
51
51
 
52
52
  For a vertical sidebar nav, use `.nav-list` inside the drawer or sidebar.
53
53
  Links inside `.nav-list` stretch to the available inline size, providing a
54
- full-row hit target for vertical navigation. The [Workspaces example](../examples/overview.md)
54
+ full-row hit target for vertical navigation. The [Workspaces example](../examples/overview.md#page-shapes)
55
55
  shows the sectioned-sidebar composition: grouped `.nav-list` blocks with
56
56
  section titles, a pinned account footer, and the native current state.
57
57
 
@@ -94,15 +94,15 @@ accessible name.
94
94
  <header>
95
95
  <strong>Menu</strong>
96
96
  <form method="dialog">
97
- <button class="drawer-close" type="submit" aria-label="Close navigation"></button>
97
+ <button class="close" type="submit" aria-label="Close navigation"></button>
98
98
  </form>
99
99
  </header>
100
100
 
101
101
  <nav>
102
102
  <ul class="nav-list stack">
103
- <li><a href="#" aria-current="page">Home</a></li>
104
- <li><a href="#">Docs</a></li>
105
- <li><a href="#">Components</a></li>
103
+ <li><a class="nav-link" href="#" aria-current="page">Home</a></li>
104
+ <li><a class="nav-link" href="#">Docs</a></li>
105
+ <li><a class="nav-link" href="#">Components</a></li>
106
106
  </ul>
107
107
  </nav>
108
108
  </dialog>
@@ -74,7 +74,7 @@ separate `.busy` class. The spinner is shown as a centered overlay over a faded
74
74
  surface; buttons keep their spinners inline instead (see Button).
75
75
 
76
76
  - Set `aria-busy="true"` on the container and add `<span class="spinner" aria-hidden="true"></span>` as its last direct child.
77
- - The overlay uses `--busy-overlay-bg`; an inverted surface (`.inverted`) already composes with it.
77
+ - The overlay uses `--busy-overlay-bg`; a card derives it from its own surface.
78
78
  - Decorative spinners stay `aria-hidden="true"`; announce progress on the region with `role="status"` and a label when needed.
79
79
 
80
80
  ```html demo
@@ -89,5 +89,5 @@ surface; buttons keep their spinners inline instead (see Button).
89
89
 
90
90
  - `--spinner-track` — the ring color.
91
91
  - `--spinner-value` — the moving gap; swap the two for an inverted spinner.
92
- - `--busy-overlay-bg` — background of the busy overlay. An inverted surface
93
- (`.inverted`) overrides it to blend with its own surface.
92
+ - `--busy-overlay-bg` — background of the busy overlay. Set it on a surface you
93
+ paint yourself so the overlay blends with that surface.
@@ -16,7 +16,7 @@
16
16
  | ---------------- | --------- | ------------------------------------------------------------ |
17
17
  | `.table-wrap` | Component | Scroll container and outer chrome (surface, border, radius). |
18
18
  | `.table` | Component | The table itself; row and cell rules. |
19
- | `.table.compact` | Density | Tighter cell padding; typography is unchanged. |
19
+ | `.table.compact` | Density | Tighter cell padding; font size and icons unchanged. |
20
20
 
21
21
  ## Basic usage
22
22
 
@@ -101,7 +101,8 @@ table width before the wrapper scrolls horizontally.
101
101
  ## Compact tables
102
102
 
103
103
  `.compact` lowers cell padding and establishes compact density for controls or
104
- spacing helpers inside cells. Typography and icons stay unchanged. It is the
104
+ spacing helpers inside cells. Font size and icons stay unchanged; the framework's
105
+ denser font width may apply when the typeface supports it. It is the
105
106
  same vocabulary as `.card.compact`.
106
107
 
107
108
  ```html demo
@@ -53,6 +53,11 @@ Enhancer modules self-register when imported. They do not require init calls and
53
53
  are safe to import during server-side rendering: outside a browser, registration
54
54
  is a no-op until the module is loaded again with a DOM.
55
55
 
56
+ Load the full bundle **or** the source modules, never both: each evaluation
57
+ owns its registry and document handlers, so a dist bundle mixed with
58
+ `actual-css/js/*` source subpaths (or two dist bundles) registers every
59
+ behavior twice and a flyout opens then immediately closes.
60
+
56
61
  ## Component pages
57
62
 
58
63
  Each interactive component has its own page documenting the exact markup its
@@ -8,6 +8,8 @@ and arrow position are written by JavaScript; the element is shown and hidden
8
8
  by promoting it to the top layer with `popover="manual"`.
9
9
 
10
10
  - Use `data-tooltip` on the trigger. With text (`data-tooltip="Help"`), the tooltip element is generated. Empty (`data-tooltip`) marks an explicit tooltip connected via `aria-describedby`.
11
+ - Generated text is a snapshot at show time: changing `data-tooltip` never rewrites a tooltip that is already shown, and an always-visible generated tooltip keeps its initial text. For content that must update dynamically, use an explicit tooltip. See the [tooltips bench](../examples/overview.md) (`demo/templates/tooltips.html`) for all six cases side by side.
12
+ - Generated text is always plain text (`textContent` only), so a shorthand value is safe for untrusted strings once they are in the DOM. An explicit tooltip is author-owned DOM: its sanitization belongs to whoever produces it. Escape the attribute server-side — a DOM already compromised at render cannot be repaired at read time. There is no `data-tooltip-html`, by design.
11
13
  - Tooltips are supplemental. Do not put required information or interactive controls inside them.
12
14
  - Show on hover and focus. Hide on Escape, blur, or pointer leave.
13
15
  - A trigger scrolled out of view takes its tooltip down and brings it back when it returns; only a dismissal ends the tooltip.
@@ -35,7 +37,7 @@ runtime when Actual should own showing and hiding.
35
37
 
36
38
  **Where the element lives is part of the contract.** An explicit tooltip stays
37
39
  exactly where you wrote it, so it keeps every scope that reaches it by
38
- inheritance — a theme island, a density scope, `.inverted`, your own scoped
40
+ inheritance — a theme island, a density scope, your own scoped
39
41
  custom properties. It also means placement is yours to get right: a tooltip
40
42
  used from inside a modal dialog must be authored inside that dialog, because
41
43
  the top layer does not lift an element out of a modal's inertness.
@@ -30,6 +30,7 @@ Whole pages, composed the way a real product would.
30
30
  - [Settings modal](../../demo/templates/settings-modal.html) - two-pane preferences dialog, with the local CSS it needs audited rule by rule
31
31
  - [Workspace settings](../../demo/templates/settings-pricing.html) - plans with visible native choices, preferences and a local segmented tab treatment
32
32
  - [Card compositions](../../demo/templates/card-compositions.html) - editorial, team, sign-in and horizontal cards composed from existing primitives
33
+ - [Register form](../../demo/templates/register-form.html) - registration page with choice cards, masks, the password toggle, `tel-prefix` validation, an OTP verification dialog, and status feedback
33
34
 
34
35
  ### Layout references
35
36
 
@@ -46,12 +47,13 @@ One mechanism at a time, with its contract and its pitfalls.
46
47
  Contracts you cannot read off a static screenshot: state, motion, focus, and
47
48
  extension points.
48
49
 
49
- - [Surface contracts](../../demo/templates/surfaces.html) - `.inverted`, cascade precedence, and the intent boundary
50
+ - [Contrast contexts](../../demo/templates/contrast-contexts.html) - the page, `data-theme` islands, a card island, a named theme and an application-painted band side by side, with every intent variant, a full form (combobox and date picker included) and focus line measured live, plus joined fields that need no focus adaptation
50
51
  - [Presence motion](../../demo/templates/motion.html) - status bar, tooltip, flyout, modal and drawer replayed side by side, with easing swaps and a reduced-motion simulation
51
52
  - [Keyboard controls](../../demo/templates/keyboard-controls.html) - hands-on keyboard support matrix and focus test bench
52
53
  - [Adaptive filter surface](../../demo/templates/adaptive-surface.html) - app-level flyout/drawer composition with one synchronized filter state
53
54
  - [Select intents](../../demo/templates/select-intents.html) - extending the enhancer with a project's own `data-enhance` behavior, injected markup included, with no framework change
54
55
  - [Popover transport probe](../../demo/templates/popover-transport.html) - self-scoring bench for the native popover transport go/no-go
56
+ - [Tooltips](../../demo/templates/tooltips.html) - generated text is a snapshot at show time: what updates, what stays, and the explicit-tip way for dynamic content
55
57
 
56
58
  ### Third-party components on Actual tokens
57
59
 
@@ -7,6 +7,7 @@
7
7
  | Class | Kind | Description |
8
8
  | ------------------------- | --------- | --------------------------------------------- |
9
9
  | `.choice-card` | Component | Card-style `<label>` around a native control. |
10
+ | `.chip` | Modifier | Compact pill form: filter chips, tags. |
10
11
  | `.primary` / `.secondary` | Intent | Accent for the selected state. |
11
12
 
12
13
  - Use `.choice-card` on a `<label>` wrapping a native `<input type="radio">` or `<input type="checkbox">`. The input is visually hidden; in browsers without `:has()` it stays visible so selection state never disappears.
@@ -93,11 +94,49 @@ follows through its `em` sizing. Padding is density, so use a `.compact` or
93
94
  `.spacious` context to change it. Intent classes (`.primary`, `.secondary`) are
94
95
  supported on the `.choice-card` element.
95
96
 
97
+ ## Chips
98
+
99
+ Add `.chip` for the compact pill form: filter chips, selectable tags, toggle
100
+ pills, multi-select facets. A radio group gives single-choice chips. A leading
101
+ dot turns into the check in a fixed slot, so checking never moves the label.
102
+ Use `.cluster` for a wrapping row. A chip is sized like a control: `.btn` and
103
+ `.input` height, `--control-pad-x` inline padding, `.sm` / `.lg` scale.
104
+
105
+ ```html demo
106
+ <fieldset class="field-group">
107
+ <legend class="field-label">Fruit</legend>
108
+ <div class="cluster">
109
+ <label class="choice-card chip">
110
+ <input type="checkbox" name="fruit" value="pear" checked />
111
+ Pear
112
+ </label>
113
+ <label class="choice-card chip">
114
+ <input type="checkbox" name="fruit" value="apple" />
115
+ Apple
116
+ </label>
117
+ <label class="choice-card chip">
118
+ <input type="checkbox" name="fruit" value="plum" disabled />
119
+ Plum
120
+ </label>
121
+ </div>
122
+ </fieldset>
123
+ ```
124
+
125
+ The dot is `::before`: a full-chip layer clipped to a `circle()` centred on
126
+ the check slot (`--choice-chip-dot`). A theme can grow it into a selected fill
127
+ by widening the clip, as the `material` theme does.
128
+
96
129
  ## CSS hooks
97
130
 
98
131
  - `--choice-card-pad` — inner padding.
99
132
  - `--choice-card-radius` — corner radius.
100
133
  - `--choice-card-check-size` — size of the check indicator.
134
+ - `--choice-card-border` / `--choice-card-bg` — resting border and background;
135
+ the checked state and hover rebind them, so a checked override needs
136
+ `:has(:checked)`.
137
+ - `--choice-chip-dot` — read-only: the chip's check-slot centre as a `circle()`
138
+ position. It is declared on the chip's `::before`, so read it in a
139
+ `.choice-card.chip…::before` rule.
101
140
 
102
141
  Plain `.check` / `.radio` controls expose two hooks:
103
142
 
@@ -47,7 +47,7 @@ contrast contracts apply to the combobox for free:
47
47
  .actual-combobox {
48
48
  --cb-bg: var(--surface);
49
49
  --cb-color: var(--text);
50
- --cb-border-color: var(--border);
50
+ --cb-border-color: var(--form-invalid-border, var(--border));
51
51
  --cb-border-radius: var(--radius);
52
52
  --cb-focus-color: var(--focus);
53
53
  --cb-muted-color: var(--text-muted);
@@ -57,15 +57,23 @@ contrast contracts apply to the combobox for free:
57
57
  --cb-option-min-height: var(--control-size);
58
58
  }
59
59
 
60
- .actual-combobox .cb-control:focus-within,
61
- .actual-combobox .cb-text-control:focus {
62
- outline: 2px solid transparent;
63
- box-shadow: var(--focus-ring-shadow);
60
+ .actual-combobox :is(.cb-text-control:focus-visible, .cb-control:has(.cb-input:focus-visible)) {
61
+ border-color: var(--form-invalid-border, var(--cb-border-color));
62
+ outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
63
+ outline-offset: calc(var(--focus-ring-width) * -1);
64
+ box-shadow: none;
64
65
  }
65
66
 
66
- .actual-combobox .cb-chip-remove:focus-visible {
67
- outline: 2px solid transparent;
68
- box-shadow: 0 0 0 2px var(--focus-ring);
67
+ .actual-combobox :is(.cb-text-control[aria-invalid="true"], .cb-control:has([aria-invalid="true"])) {
68
+ --form-invalid-border: var(--danger);
69
+ }
70
+
71
+ :where(.actual-combobox) combo-box {
72
+ border-radius: var(--cb-border-radius);
73
+ }
74
+
75
+ .actual-combobox :is(.cb-control, .cb-text-control) {
76
+ border-radius: inherit;
69
77
  }
70
78
 
71
79
  .actual-combobox .cb-chip {
@@ -79,9 +87,13 @@ contrast contracts apply to the combobox for free:
79
87
  }
80
88
  ```
81
89
 
82
- The composite focus ring follows any internal focus, not just the search
83
- input: focus a chip or its remove × and the control shows the same field ring,
84
- with the local `:focus-visible` indicator on the chip or its × on top.
90
+ While the search input is focused, the control takes the same inset outline as
91
+ `.input` in place of the library's accent border and outer halo; a focused chip
92
+ or remove × keeps the library's own inner ring. Being inset, the outline needs
93
+ no adaptation inside `.join`: `combo-box` owns the radius at zero specificity,
94
+ so the join gives it the group corners and the visible control inherits them. The invalid rule sets `--form-invalid-border`,
95
+ the hook `.field.danger` and `aria-invalid` already use, so border and outline
96
+ turn danger together.
85
97
 
86
98
  The same approach works for any widget that exposes a custom-property skin —
87
99
  keep the widget's behavior, restyle its tokens through the shared vocabulary.
@@ -2,6 +2,8 @@
2
2
 
3
3
  > Native input intent first. Actual's runtime filters values only when explicitly asked, and fixed-shape masks stay opt-in through `data-mask`.
4
4
 
5
+ **Related terms:** telephone, phone, tel-prefix.
6
+
5
7
  `inputmode` changes the virtual keyboard but does not constrain the value. Actual
6
8
  does not filter plain `inputmode` fields by default because numeric entry often
7
9
  needs temporary, signed, grouped, or locale-specific values while the user types.
@@ -89,3 +91,60 @@ Use `data-mask` when the input has a fixed shape. Tokens are `9` for a digit,
89
91
 
90
92
  Masks enforce shape, not domain validity. Use application validation when a date
91
93
  must reject impossible values such as `2026-13-40`.
94
+
95
+ ### Telephone input
96
+
97
+ Do not mask telephone numbers by default. Phone formatting varies by country,
98
+ device, copied source, and user convention. Preserve the entered value, apply
99
+ only coarse client-side policy, and normalize or validate the actual number on
100
+ the server.
101
+
102
+ `tel-prefix` is that coarse policy: a local number passes, and an explicit
103
+ international number must match one of the allowed prefixes. It analyzes a copy
104
+ of the value and never rewrites it.
105
+
106
+ ```html demo
107
+ <form class="needs-validation stack" data-enhance="validation" novalidate>
108
+ <label class="field">
109
+ <span class="field-label">Phone</span>
110
+ <input
111
+ class="input"
112
+ type="tel"
113
+ name="phone"
114
+ inputmode="tel"
115
+ autocomplete="tel"
116
+ data-validation-rules="tel-prefix +32"
117
+ aria-describedby="phone-help phone-error"
118
+ />
119
+ <span class="field-help" id="phone-help">
120
+ Local number or +32 international number.
121
+ </span>
122
+ <span class="field-error" id="phone-error">
123
+ Use a local number or an allowed international prefix.
124
+ </span>
125
+ </label>
126
+
127
+ <div class="form-actions">
128
+ <button class="btn primary" type="submit">Submit</button>
129
+ <button class="btn neutral outline" type="reset">Reset</button>
130
+ </div>
131
+ </form>
132
+ ```
133
+
134
+ | Input | `tel-prefix +32` |
135
+ | -------------------- | ---------------- |
136
+ | `0470 12 34 56` | passes |
137
+ | `02/123.45.67` | passes |
138
+ | `+32 470 12 34 56` | passes |
139
+ | `0032 470 12 34 56` | passes |
140
+ | `(+32) 470 12 34 56` | passes |
141
+ | `0033 6 12 34 56 78` | fails |
142
+ | `+33 6 12 34 56 78` | fails |
143
+
144
+ A local number passes in any usual notation, but its digits are required: once
145
+ the separators are ignored, letters and stray symbols fail, so a pasted word is
146
+ never read as a local number. An explicit international number is normalized
147
+ for comparison — `0032…`, `(+32)…`, and `+32 (0)…` all read as `+32` — and a
148
+ foreign prefix fails. With no prefix, only local input passes. This is an origin
149
+ policy, not phone validation: region detection, number semantics, and E.164
150
+ normalization stay on the server. See [validation](validation.md).
@@ -29,11 +29,8 @@ When composing source files manually, import `components/join.css` after the con
29
29
  </div>
30
30
  ```
31
31
 
32
- `.join` handles border-radius and border collapsing between adjacent children. It works with any direct child — `.input`, `.btn`, `.select`, or `.join-addon`.
33
- When an `.input`, `.textarea`, or `.select` receives keyboard-relevant focus,
34
- the focus ring surrounds the complete joined field. Attached buttons keep their
35
- own focus indicator when reached directly, so the actionable segment remains
36
- identifiable.
32
+ `.join` handles border-radius and border collapsing between adjacent children. It works with any direct child — `.input`, `.btn`, `.select`, or `.join-addon` — plus one wrapper level (e.g. a combobox element around the native control): the wrapper receives the group corners and the inner control inherits them via `border-radius: inherit`.
33
+ When an `.input`, `.textarea`, or `.select` receives keyboard focus, it keeps its own inset focus outline; the group never moves the ring onto itself, so the focused segment remains identifiable.
37
34
 
38
35
  ## Linting `role="group"`
39
36
 
@@ -165,6 +165,12 @@ A full form using the control classes, field wrappers, and form actions:
165
165
  </form>
166
166
  ```
167
167
 
168
+ ## Hooks
169
+
170
+ Set `--control-hover-border` to tune the hover border of `.input` and `.textarea`.
171
+ Set `--control-hover-bg` to keep a `.select` background steady on hover, or give
172
+ it a stronger fill.
173
+
168
174
  ## Notes
169
175
 
170
176
  - `required` is enough on native inputs. `aria-required="true"` is redundant and not used in these examples.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Inline error feedback that respects assistive technology and form state.
4
4
 
5
- **Related terms:** inline validation, field error.
5
+ **Related terms:** inline validation, field error, telephone input.
6
6
 
7
7
  ## Class reference
8
8
 
@@ -162,10 +162,46 @@ Custom rules live in `data-validation-rules` as a comma-separated list; each rul
162
162
  - `number` — Value is empty or a valid `Number`.
163
163
  - `digits` — Value is empty or ASCII digits only.
164
164
  - `alnum` — Value is empty or letters and digits only.
165
+ - `starts-with <token>…` — Value must begin with one of the literal tokens (`starts-with INV- CR-`). The value is trimmed before comparison; the match is case-sensitive.
166
+ - `ends-with <token>…` — Value must end with one of the literal tokens (`ends-with .pdf .csv`). Trimmed, case-sensitive.
165
167
  - `date` — Value is a valid date in `yyyy-mm-dd`, `dd/mm/yyyy`, `mm/dd/yyyy`, `dd.mm.yyyy`, `mm.dd.yyyy`, `dd-mm-yyyy`, or `mm-dd-yyyy` shape; rejects non-existent dates like `2026-02-29`. ISO remains the recommended wire format because servers can parse it without guessing.
168
+ - `tel-prefix <prefix>…` — A local number (its digits, with the usual separators `space ( ) . / -` ignored) passes; an explicit international number is normalized (`00` to `+`) and must match one of the literal prefixes (`tel-prefix +32`, `tel-prefix +32 +33 +352`). A value that is not dialing digits fails. With no prefix, only local input passes. The entered value is never modified.
169
+
170
+ `starts-with` and `ends-with` options are literal tokens: no quoting, no escaping, and a token cannot contain whitespace. Without a token they fail rather than passing silently. `tel-prefix` without a prefix is instead a deliberate local-only policy.
166
171
 
167
172
  Add your own with `FormValidator.registerRule(name, (value, el, ...opts) => boolean)`. The built-in rules treat empty values as valid so optional fields stay optional; custom rules should do the same when that behavior is wanted.
168
173
 
174
+ ```html demo
175
+ <form class="needs-validation stack" data-enhance="validation" novalidate>
176
+ <label class="field">
177
+ <span class="field-label">Invoice reference</span>
178
+ <input class="input" name="reference"
179
+ data-validation-rules="starts-with INV- CR-"
180
+ aria-describedby="reference-help reference-error"
181
+ required />
182
+ <span class="field-help" id="reference-help">Start with INV- or CR-.</span>
183
+ <span class="field-error" id="reference-error">Use an INV- or CR- reference.</span>
184
+ </label>
185
+
186
+ <label class="field">
187
+ <span class="field-label">Attachment filename</span>
188
+ <input class="input" name="attachment"
189
+ data-validation-rules="ends-with .pdf .csv"
190
+ aria-describedby="attachment-help attachment-error"
191
+ required />
192
+ <span class="field-help" id="attachment-help">A .pdf or .csv filename.</span>
193
+ <span class="field-error" id="attachment-error">Choose a .pdf or .csv file.</span>
194
+ </label>
195
+
196
+ <div class="form-actions">
197
+ <button class="btn primary" type="submit">Submit</button>
198
+ <button class="btn neutral outline" type="reset">Reset</button>
199
+ </div>
200
+ </form>
201
+ ```
202
+
203
+ More specific telephone policies — determining the actual region, validating number semantics, or producing E.164 — belong in application or server code, registered through `FormValidator.registerRule()`.
204
+
169
205
  The `date` rule pairs naturally with `data-mask` — the mask structures input, the rule validates meaning. Same rule, different formats:
170
206
 
171
207
  ```html demo
@@ -96,27 +96,73 @@ Set `--avatar-size` / `--avatar-radius`; tune overlap with `--avatar-stack-overl
96
96
 
97
97
  → Components · Avatar
98
98
 
99
- ### Make an inverted / contrasting surface
100
-
101
- Apply `.inverted` to create one contrasting surface context. It paints a bare
102
- block (`<header class="inverted">`, `<section class="inverted">`) with
103
- `--surface-solid` and `--surface`; contextual content and transparent treatments
104
- follow that foreground. Apply it directly to a component that consumes the
105
- shared `--ui-*` contract when that component itself is the contrasting surface.
106
-
107
- Surface-owning descendants keep their own component surface and establish a new
108
- foreground context. Use `data-theme="dark"`, `data-theme="light"`, or a named
109
- theme instead when an entire subtree — including forms, states, and nested
110
- surfaces — must use another palette. More specific component states or explicit
111
- surface variants (`.card.subtle`) still take precedence.
112
-
113
- → Components · Card · Foundations · Tokens (theme contract)
99
+ ### Make a dark (or light) section / inverted, inverse, contrasting band
100
+
101
+ Put `data-theme="dark"` (or `"light"`) on the section, footer, navbar or card.
102
+ The island recomputes every token — surfaces, text, fields, validation,
103
+ intents, focus and native controls — so a form or a `.primary.outline` inside
104
+ it reads correctly. In a branded application, `[data-theme="light"]` and
105
+ `[data-theme="dark"]` are the scheme roles of your brand: redefine them after
106
+ Actual CSS and the island follows your palette.
107
+
108
+ ```html
109
+ <footer data-theme="dark">…</footer>
110
+ <article class="card" data-theme="dark">…</article>
111
+ ```
112
+
113
+ The island recomputes tokens but paints nothing. A `.card` paints its own
114
+ surface; any other element needs `background: var(--surface)` and
115
+ `color: var(--text)`, or its ink lands on the page theme's background. A
116
+ `<legend>` straddles its fieldset's border, outside the fieldset's surface, so
117
+ put the island on a wrapper, not on the `<fieldset>`.
118
+
119
+ Actual has no class that inverts only part of a subtree. When all you need is
120
+ a painted band with neutral content and filled buttons, write it in
121
+ application CSS and keep forms and intent ink out of it:
122
+
123
+ ```css
124
+ .site-band {
125
+ --heading: var(--surface);
126
+ background: var(--surface-solid);
127
+ color: var(--surface);
128
+ }
129
+ ```
130
+
131
+ Transparent treatments (`.btn.outline`, `.ghost`, `.link`) and headings follow
132
+ that ink; fields, their labels and help text, and intent colors keep the page
133
+ palette, which nothing tunes for `--surface-solid`. Focus lines stay readable
134
+ there: the theme contract keeps `--focus` at 3:1 on `--surface-solid`.
135
+
136
+ → Foundations · Tokens (theme contract) · Examples · Contrast contexts
137
+
138
+ ### Replace the palette with my own brand
139
+
140
+ Override the whole minimal recolor set (Tokens · Theme contract), not just
141
+ `--primary`. Every neutral of the default theme — surfaces, text, border,
142
+ `--neutral`, `--hover-overlay`, `--shadow-color` — is tinted toward the default
143
+ primary and never derived from `--primary` at runtime, so a partial override
144
+ leaves the default identity in the greys. `report:theme-contrast` checks
145
+ contrast, not a leftover tint.
146
+
147
+ Soft ink is the other half. The default palette ships calibrated
148
+ `--*-soft-fg` hooks; any other theme falls back to the `--soft-fg-mix`
149
+ derivation, which is a single global percentage and therefore guarantees
150
+ nothing about your intents. Verify with `bun run report:theme-contrast`, then
151
+ lower `--soft-fg-mix` or declare the per-role hooks for the intents that miss.
152
+
153
+ → Foundations · Tokens (theme contract)
114
154
 
115
155
  ### Use or adapt a preset palette
116
156
 
117
157
  `src/css/themes/` holds example palettes (`ocean`, `spruce`, `neon`, `brutalist`, …) as
118
158
  reference material to copy into your own `[data-theme]` island — they are demo
119
159
  assets, not package entrypoints. Each sets the intent/surface/text tokens and
120
- lets the core derive everything else. In browsers with `color-mix()` support,
121
- `--focus-ring` follows each island's `--focus` automatically; re-declare it only
122
- for a deliberate visual override or a matching legacy fallback.
160
+ lets the core derive everything else.
161
+
162
+ The `bootstrap-v6` example follows Bootstrap 6's blue palette, stronger subtle
163
+ surfaces, filled badges, and steady select hover; its focus is the core solid
164
+ line. Use `.badge.soft` for a subtle badge. It is a visual theme, not a
165
+ Bootstrap compatibility layer. The `gradient` example keeps a Bootstrap 4 style
166
+ outer halo on focused fields, for comparison. The `material` example restyles
167
+ fields as filled underlines with pill buttons — tokens plus three small rules,
168
+ and `.join` still needs no adaptation.