@clicktap/ui 2.3.1 → 2.3.3

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 (58) hide show
  1. package/components/Accordion/Accordion.js +1 -1
  2. package/components/Badge/Badge.d.ts +2 -2
  3. package/components/Badge/Badge.js +1 -1
  4. package/components/Button/Button.js +1 -1
  5. package/components/Checkbox/Checkbox.js +1 -1
  6. package/components/Collection/CollectionFilterChips/CollectionFilterChips.js +1 -1
  7. package/components/Collection/CollectionFilters/CollectionFilters.js +1 -1
  8. package/components/Collection/CollectionToolbar/CollectionToolbar.js +1 -1
  9. package/components/Collection/EmptyResults/EmptyResults.js +1 -1
  10. package/components/Collection/FilterRenderer/Renderer/Swatch/SwatchText.js +1 -1
  11. package/components/Collection/ProductCardSwatches/ProductCardSwatches.js +1 -1
  12. package/components/Collection/ProductPrice/ProductPrice.js +1 -1
  13. package/components/ContextMenu/ContextMenu.js +1 -1
  14. package/components/CreditCardExpirationInput/CreditCardExpirationInput.js +1 -1
  15. package/components/CreditCardInput/CreditCardInput.js +1 -1
  16. package/components/DateInput/DateInput.js +1 -1
  17. package/components/Dialog/Dialog.js +1 -1
  18. package/components/Divider/Divider.js +1 -1
  19. package/components/DobInput/DobInput.js +1 -1
  20. package/components/Drawer/Drawer.d.ts +1 -1
  21. package/components/Drawer/Drawer.js +1 -1
  22. package/components/Drawer/Drawer.types.d.ts +7 -0
  23. package/components/FeatureGate/AccessDenied.js +1 -1
  24. package/components/FeatureGate/PermissionRequired.js +1 -1
  25. package/components/FeatureGate/UpgradePrompt.js +1 -1
  26. package/components/Input/Input.js +1 -1
  27. package/components/Link/Link.js +1 -1
  28. package/components/LinkAsButton/LinkAsButton.js +1 -1
  29. package/components/Loader/Pulse.js +1 -1
  30. package/components/Menu/ContextMenu.js +1 -1
  31. package/components/Meter/Meter.js +1 -1
  32. package/components/NumberInput/NumberInput.js +1 -1
  33. package/components/Pagination/Pagination.js +1 -1
  34. package/components/PasswordCheck/PasswordCheck.js +1 -1
  35. package/components/PhoneInput/PhoneInput.js +1 -1
  36. package/components/PinInput/PinInput.js +1 -1
  37. package/components/Progressbar/CircularProgressbar.js +1 -1
  38. package/components/Progressbar/LinearProgressbar.js +1 -1
  39. package/components/ResendCode/ResendCode.js +1 -1
  40. package/components/Select/ButtonSelect.js +1 -1
  41. package/components/Select/ComboBoxSelect.js +1 -1
  42. package/components/Select/MultiButtonSelect.js +1 -1
  43. package/components/Select/parts.js +1 -1
  44. package/components/Skeleton/Skeleton.js +1 -1
  45. package/components/Slider/Slider.js +1 -1
  46. package/components/Switch/Switch.js +1 -1
  47. package/components/Tabs/Tab.js +1 -1
  48. package/components/Tabs/TabList.js +1 -1
  49. package/components/TimeInput/TimeInput.js +1 -1
  50. package/components/ToggleButton/ToggleButton.js +1 -1
  51. package/components/Tooltip/Tooltip.js +1 -1
  52. package/components/UploadImage/UploadImage.js +1 -1
  53. package/docs/migrations/2026/09/ui-2026-09-23-drawer-viewport-clamp.md +90 -0
  54. package/docs/migrations/2026/09/ui-2026-09-23-interactive-elements-cursor-pointer.md +84 -0
  55. package/docs/migrations/2026/09/ui-2026-09-24-dialog-semantic-surface-tokens.md +81 -0
  56. package/docs/migrations/2026/09/ui-2026-09-24-semantic-color-token-lint-rule.md +283 -0
  57. package/docs/migrations/index.json +40 -0
  58. package/package.json +1 -1
@@ -0,0 +1,84 @@
1
+ # BUGFIX: interactive elements show a pointer cursor under Tailwind v4
2
+
3
+ **Date:** 2026-09-23
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `src/components/Button/Button.tsx`, `ResendCode/ResendCode.tsx`, `Select/ComboBoxSelect.tsx`, `Collection/EmptyResults/EmptyResults.tsx`, `Collection/CollectionFilters/CollectionFilters.tsx`.
6
+ **Classification:** BUGFIX
7
+ **Breaking:** No API change. **Visual:** hovering an enabled button in any of the affected components now shows a pointer cursor instead of the default arrow. Disabled states are unchanged (still `not-allowed` where they had it, still no explicit cursor where they didn't).
8
+ **Ticket:** CTAP-1874
9
+
10
+ ## Problem
11
+
12
+ Tailwind v4's Preflight changed the `<button>` reset from `cursor: pointer` to `cursor: default`, so
13
+ a class that used to be unnecessary now is. `Button` and `ResendCode` never set `cursor-pointer`
14
+ explicitly — they relied on the browser/Tailwind v3 default — so under Tailwind v4 they drew the
15
+ arrow cursor on hover instead of a hand. `Input`'s password-reveal toggle already set
16
+ `cursor-pointer` explicitly, which is what made this an inconsistency rather than a design decision:
17
+ the library already knew to do this, just not everywhere.
18
+
19
+ The ticket asked for a grep of the rest of the package for `<button` without a cursor class rather
20
+ than stopping at the two reported components. That turned up three more raw `<button>` elements
21
+ missing it: the combobox's dropdown-arrow toggle (`ComboBoxSelect`), and the "Clear search" / "Clear
22
+ all filters" text buttons in `EmptyResults` and `CollectionFilters` (the last of these appears at
23
+ three call sites: the search-clear icon, the desktop sidebar, and the mobile filter drawer).
24
+ `Pagination`, `ButtonSelect` and `MultiButtonSelect` already had `cursor-pointer` and needed no
25
+ change.
26
+
27
+ ## What changed
28
+
29
+ Added `cursor-pointer` to the enabled state of every affected button, alongside `disabled:cursor-not-allowed`
30
+ where a disabled state exists:
31
+
32
+ | Component | Before | After |
33
+ |---|---|---|
34
+ | `Button` | no cursor class | `cursor-pointer disabled:cursor-not-allowed` |
35
+ | `ResendCode` | `disabled:cursor-not-allowed` only | `cursor-pointer disabled:cursor-not-allowed` |
36
+ | `ComboBoxSelect` (arrow toggle) | no cursor class | `cursor-pointer disabled:cursor-not-allowed` |
37
+ | `EmptyResults` (both actions) | no cursor class | `cursor-pointer` (no disabled state on these buttons) |
38
+ | `CollectionFilters` (all three call sites) | no cursor class | `cursor-pointer` (no disabled state on these buttons) |
39
+
40
+ `Button` gaining `disabled:cursor-not-allowed` is new — it had no cursor handling in either state
41
+ before this fix — and matches the pattern already established by `ResendCode` and `Input`.
42
+
43
+ ## Consumer Impact
44
+
45
+ **No action needed to upgrade.** This only adds a `cursor-pointer` (and, for `Button`, a
46
+ `disabled:cursor-not-allowed`) utility to the base class list; nothing is removed, and `className`
47
+ already merges last via `cn`/tailwind-merge, so a consumer overriding cursor at the call site
48
+ continues to win.
49
+
50
+ **Drop any hand-rolled workaround.** If you were passing a `className`/`buttonClassName` purely to
51
+ restore the pointer cursor under Tailwind v4, remove it:
52
+
53
+ ```tsx
54
+ // BEFORE
55
+ <ResendCode buttonClassName="cursor-pointer underline" ... />
56
+
57
+ // AFTER
58
+ <ResendCode ... />
59
+ ```
60
+
61
+ ## Verification
62
+
63
+ New/updated specs, run via `nx run frontend-module-ui:test`:
64
+
65
+ - `Button.spec.tsx` — `cursor-pointer` asserted for all four `variant` aliases (`primary`,
66
+ `secondary`, `tertiary`, `link`).
67
+ - `ResendCode.test.tsx` — `cursor-pointer` asserted while enabled; `disabled:cursor-not-allowed`
68
+ still asserted while disabled.
69
+ - `Select/ComboBoxSelect.spec.tsx` (new) — `cursor-pointer` on the dropdown-arrow toggle, rendered
70
+ through the public `<Select searchable>`.
71
+ - `Collection/EmptyResults/EmptyResults.spec.tsx` (new) — `cursor-pointer` on both "Clear search" and
72
+ "Clear all filters".
73
+ - `Collection/CollectionFilters/CollectionFilters.spec.tsx` (new) — `cursor-pointer` on the
74
+ search-clear icon button and the desktop "Clear all filters" button.
75
+
76
+ All ten new/changed assertions were confirmed to fail against the pre-fix code (missing
77
+ `cursor-pointer` in the rendered className) before the fix landed, then pass after.
78
+
79
+ ⚠️ **Not independently re-verified against a live Tailwind v4 build.** The tests assert the utility
80
+ class is present in the rendered `className`; they cannot show the class actually resolves to a
81
+ hand cursor in a given consumer's compiled CSS (e.g. if a consumer's Tailwind config purges
82
+ `cursor-pointer` for some reason). The mechanism reported in the ticket — Preflight's `cursor:
83
+ default` reset on `<button>` under Tailwind v4 — was not independently reproduced in a browser as
84
+ part of this fix; it was taken from the ticket's own evidence.
@@ -0,0 +1,81 @@
1
+ # BUGFIX: `Dialog` paints from semantic tokens instead of literal `bg-white`
2
+
3
+ **Date:** 2026-09-24
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `src/components/Dialog/Dialog.tsx` — every `<Dialog>` that does not already pass its own background in `className`.
6
+ **Classification:** BUGFIX
7
+ **Breaking:** No. It is a change of default, and the default only moves where a consumer has re-themed the tokens: an app on the shipped light theme sees **no visual change at all** (`--color-surface` is `#fff`, the exact value `bg-white` produced, and `--color-fg` is the slate-900 such an app already inherited). A dark-themed app stops getting a white panel and gets its own surface. No API change, and the `className` escape hatch is untouched.
8
+ **Ticket:** CTAP-1902
9
+
10
+ ## Problem
11
+
12
+ `Dialog`'s base class list carried the literal `bg-white` and set no text color at all. In an app
13
+ whose semantic tokens resolve dark, the panel painted white while the text inherited the app's
14
+ light `--color-fg` — so the dialog was not merely off-brand, it was **illegible**.
15
+
16
+ The same class list already used `border-border-emphasis`, a semantic token, which is what makes
17
+ this read as a class the CTAP-1123 sweep missed rather than a deliberate choice. A reader could
18
+ not tell which colors in it were intentional.
19
+
20
+ Measured, rendering `<Dialog aria-label="probe">hi</Dialog>` with no `className` and reading the
21
+ resulting class attribute:
22
+
23
+ **Before**
24
+
25
+ ```
26
+ p-8 outline-0 max-w-max w-screen absolute top-2/4 left-2/4 -translate-x-1/2 -translate-y-1/2
27
+ max-h-[calc(100dvh-2rem)] overflow-y-auto shadow-[0_8px_24px_rgba(0,0,0,0.1)] rounded-lg
28
+ bg-white border border-solid border-border-emphasis
29
+ ```
30
+
31
+ **After**
32
+
33
+ ```
34
+ p-8 outline-0 max-w-max w-screen absolute top-2/4 left-2/4 -translate-x-1/2 -translate-y-1/2
35
+ max-h-[calc(100dvh-2rem)] overflow-y-auto bg-surface text-fg
36
+ shadow-[0_8px_24px_rgba(0,0,0,0.1)] rounded-lg border border-solid border-border-emphasis
37
+ ```
38
+
39
+ ## What changed
40
+
41
+ * `bg-white` → `bg-surface`, the token the rest of the package's surfaces use. `theme.css`
42
+ defines `--color-surface: #fff`, so the shipped default renders identically.
43
+ * `text-fg` added, so the panel's contrast is self-contained rather than inherited from whatever
44
+ encloses the dialog. `--color-fg` is the slate-900 those apps were already inheriting.
45
+ * The shadow stays the literal `shadow-[0_8px_24px_rgba(0,0,0,0.1)]`, now with a comment in the
46
+ source saying so is deliberate. `theme.css` publishes no `--shadow-*` token, and inventing one
47
+ from inside a bugfix would enlarge the package's public token surface; that belongs with the
48
+ theming work in CTAP-1834. Unlike the background it also degrades gracefully — a 10%-black drop
49
+ shadow goes near-invisible on a dark ground rather than wrong.
50
+
51
+ `Dialog.spec.tsx` is new and permanent. Beyond asserting the two tokens it fails on **any**
52
+ literal palette utility reaching the rendered panel, so a later edit cannot quietly reintroduce
53
+ one here. It verified the failure against the pre-fix source before the fix landed.
54
+
55
+ ## Consumer action
56
+
57
+ None.
58
+
59
+ A consumer that passes its own background or foreground still wins outright — `cn`/tailwind-merge
60
+ drops the base class, and that is covered by tests now. Measured:
61
+
62
+ ```
63
+ <Dialog className="bg-admin-surface"> → … text-fg shadow-[…] rounded-lg
64
+ border border-solid border-border-emphasis bg-admin-surface
65
+ <Dialog className="text-admin-fg"> → … bg-surface shadow-[…] … text-admin-fg
66
+ ```
67
+
68
+ `text-fg` is a text-**color** for tailwind-merge, not a font size, so it does not collide with a
69
+ caller's `text-lg` or with the package's own `typography-*` scale.
70
+
71
+ An app that wants the old unconditional white keeps it by overriding the token
72
+ (`--color-surface: #fff`, which is already the default) or by passing `className="bg-white"`.
73
+
74
+ ## Still open
75
+
76
+ The rest of the package is not swept. A survey of `libs/frontend-modules/ui/src` (excluding
77
+ stories and tests) found **139 literal palette utilities across 54 component files** after this
78
+ fix — `Collection` (31), `FeatureGate` (10), `Tabs` (8), `Pagination` (7), `TimeInput` (6),
79
+ `Select` (6), `DateInput` (6) lead it. A lint rule that fails on literal color utilities was
80
+ considered here and deliberately deferred: turning it on means fixing or annotating all 139, which
81
+ is its own piece of work. It should be a separate ticket, landing the rule and the sweep together.
@@ -0,0 +1,283 @@
1
+ # IMPROVEMENT: literal palette colors are a lint error in `@clicktap/ui`, and the 136 existing ones are resolved
2
+
3
+ **Date:** 2026-09-24
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** 54 files under `src/components/**` — every component listed in "What changed" below — plus the new root-level `eslint.rules.no-literal-palette-color.mjs` and the two eslint configs that reference it.
6
+ **Classification:** IMPROVEMENT
7
+ **Breaking:** No API change, and no prop, export or class-name contract moves. **Visual:** 76 of the 118 swaps resolve to the exact same light-theme value and render identically. The other 42 do not — they are listed component by component under "Rendering changes" and are all one step along a neutral ramp, except `UploadImage`, whose hover background **appears for the first time** because the class it had was a typo.
8
+ **Ticket:** CTAP-1904
9
+
10
+ ## Problem
11
+
12
+ Four tickets described the same defect: a component styled with a literal Tailwind palette utility
13
+ instead of a semantic token. It is invisible in a light-themed app and wrong — sometimes illegible —
14
+ in a re-themed one. CTAP-1123 did the original sweep, CTAP-1304 fixed `BreadcrumbLink`, CTAP-1834
15
+ asked for the token set, CTAP-1902 fixed `Dialog`'s `bg-white`.
16
+
17
+ Review missed it three times running, so the convention needed a machine check. And a rule without
18
+ the sweep would fail 54 files on day one; a sweep without the rule would regress, which is exactly
19
+ the history above. Both land here.
20
+
21
+ ## The survey
22
+
23
+ Measured on this branch's merge-base with `master`, over `src/**/*.{ts,tsx}`, excluding
24
+ `*.stories.*` / `*.test.*` / `*.spec.*`:
25
+
26
+ | | Count |
27
+ |---|---|
28
+ | Textual matches | 140 across 55 files |
29
+ | …of which sit inside a **comment** (a docblock `@example`, a note naming a class in prose) | 4 |
30
+ | **Literals in code — what the rule reports** | **136 across 54 files** |
31
+ | Matches in stories / tests / specs, which the rule excludes on purpose | 28 across 6 files |
32
+
33
+ The ticket's survey said 139 across 54 files. The difference is accounted for: it was measured after
34
+ CTAP-1902's `Dialog` fix, which is on PR #3030 and not yet in `master`, so `Dialog`'s `bg-white` is
35
+ the 140th match here; and the ticket counted textually, where an AST rule does not see the four
36
+ matches that live in comments. The ticket's "34 hits in stories/tests" is 28 by this regex — the
37
+ gap is arbitrary-value classes such as `bg-[#222]`, which are deliberately out of scope.
38
+
39
+ Resolution of the 136:
40
+
41
+ | | Count |
42
+ |---|---|
43
+ | Swapped to a semantic token | 118 |
44
+ | Annotated as deliberate, with a reason | 17 |
45
+ | Deleted as dead code (`shadow-slate-200` with no `shadow` utility beside it, so it painted nothing) | 1 |
46
+
47
+ ## The rule
48
+
49
+ `clicktap/no-literal-palette-color`, defined in `eslint.rules.no-literal-palette-color.mjs` at the
50
+ workspace root and turned on for `libs/frontend-modules/ui/src/**` only.
51
+
52
+ **Why a custom rule.** `no-restricted-syntax` — the mechanism the repo already uses for the
53
+ `z.string().trim()` convention — can match a string against a regex, but its message is a fixed
54
+ string: it can say the line has a literal palette color and not *which* one. A `cn()` call in this
55
+ package routinely carries a dozen classes over several lines, so naming the offender is the
56
+ difference between a usable error and a hunt. An AST rule also skips comments for free, which is
57
+ what keeps a docblock explaining this very convention from being an error.
58
+ `eslint-plugin-tailwindcss` is not a dependency of this workspace and has no "these colors are off
59
+ limits" rule, so it would have meant a new dependency *and* this rule.
60
+
61
+ **What it flags:** `bg-`, `text-`, `border-`, `ring-`, `ring-offset-`, `fill-`, `stroke-`, `from-`,
62
+ `via-`, `to-`, `divide-`, `outline-`, `decoration-`, `shadow-`, `caret-`, `accent-`, `placeholder-`
63
+ — with an optional side/axis segment (`border-t-`, `divide-y-`) — followed by `white`, `black`, or
64
+ one of the 22 named ramps with a numeric step. Variant prefixes and the important modifier pass
65
+ through (`dark:hover:!bg-white` is reported as `bg-white`), and the opacity modifier is reported as
66
+ part of the class (`bg-black/30`).
67
+
68
+ **What it does not flag, deliberately:**
69
+
70
+ - **`transparent`, `current`, `inherit` are not colors.** They carry no theme decision and repaint
71
+ themselves, so `bg-transparent` and `text-current` stay legal. The ticket asked for this call to
72
+ be stated: it is stated.
73
+ - **Arbitrary values** — `shadow-[0_8px_24px_rgba(0,0,0,0.1)]`, `bg-[#222]`. `theme.css` publishes
74
+ no `--shadow-*` token, so flagging them would produce hits with nowhere to go. Minting those
75
+ tokens is CTAP-1834's business.
76
+ - **Stories, tests, specs and fixtures.** Excluded twice over: by the config block's `ignores`, and
77
+ by a filename check inside the rule itself, so it holds however the rule is wired up.
78
+ - **Comments.** The rule visits string and template nodes only.
79
+
80
+ **Where it runs.** In both `libs/frontend-modules/ui/eslint.config.mjs` and the root
81
+ `eslint.config.mjs`, for the same reason `eslint.rules.trim-form-strings.mjs` lives in both:
82
+ `lint-staged` resolves the **root** config on commit while `nx run frontend-module-ui:lint` resolves
83
+ the package's, and a rule known to only one passes one and fails the other. Both wirings were
84
+ verified live. The `files` glob is what keeps the rule inside the library: `nx run frontend:lint`
85
+ and `nx run admin:lint` report zero hits from it, which is intended — the rule is about what the
86
+ library hardcodes, not about what an app writes for its own pages.
87
+
88
+ ## The annotation convention
89
+
90
+ Some literals are right, so the rule has an opt-out that requires a reason:
91
+
92
+ ```tsx
93
+ // theme-literal: a scrim is black in every theme; its opacity is the lever, not its hue
94
+ 'bg-black/30 backdrop-blur-sm',
95
+ ```
96
+
97
+ The marker covers its own line and the next line of **code** — it resolves forward past a second
98
+ comment line and past the `}` that closes a `{/* … */}` JSX comment, so a reason may run to two or
99
+ three lines and survives prettier reflowing a ternary. The regex requires text after the colon: a
100
+ bare `// theme-literal:` does not suppress anything (proven below). This is the shape the repo
101
+ already uses for `// no-trim:`, `// blank-ok:` and `// no-persist:` rather than a third one, and it
102
+ has one practical advantage over `// eslint-disable-next-line`: `eslint --fix` from the root deletes
103
+ a disable directive naming a rule that run does not know, and a plain comment survives that.
104
+ `// eslint-disable-next-line clicktap/no-literal-palette-color -- reason` still works.
105
+
106
+ There is **no** file-level or package-level suppression anywhere in this change. Every one of the 17
107
+ exemptions is a single line carrying its own reason.
108
+
109
+ ### The 17 deliberate literals
110
+
111
+ | Component | Class | Why it stays |
112
+ |---|---|---|
113
+ | `ModalOverlay` | `bg-black/30` | A scrim is black in every theme; its opacity is the lever. |
114
+ | `Checkbox` | `stroke-white`, `fill-white` | The tick and the indeterminate dash are drawn **on the filled control**, so they take their contrast from that fill, not from the page. |
115
+ | `ProductCardSwatches` | `ring-white`, `bg-white` ×2, `bg-slate-400` ×2, `text-white` | Every one is drawn on the swatch's **own** color, which comes from product data. `needsBorder` is the component's own light/dark test for that color. A re-themed `--color-fg-inverse` would make the check vanish on half a catalog. |
116
+ | `SwatchColor` | `ring-white`, `text-white` | The same marks, in the filter-sidebar rendering of the same swatch. |
117
+ | `GemIcon` | `text-purple-500` | Purple *is* the premium/upgrade signal the glyph carries. |
118
+ | `LockIcon` | `text-yellow-500` | Amber *is* the restricted/permission signal. |
119
+ | `StarIcon` | `text-amber-400` | A filled rating star is gold in every theme. (Its empty state already used `text-fg-subtle` and still does.) |
120
+ | `PermissionRequired` | `bg-yellow-100` | The chip tint pairs with `LockIcon`'s deliberately literal amber. |
121
+ | `UpgradePrompt` | `bg-purple-100` | The chip tint pairs with `GemIcon`'s deliberately literal purple. |
122
+ | `Radio` | `text-red-300` | A faded danger for the disabled-invalid state. |
123
+
124
+ The ticket named four that must survive — `ModalOverlay`, `Checkbox`, `ProductCardSwatches`, `Icon`.
125
+ Each was read in context and confirmed. `SwatchColor` is the same swatch mark as
126
+ `ProductCardSwatches` rendered in the filter sidebar and is annotated on the same grounds.
127
+
128
+ ### Deferred to CTAP-1834
129
+
130
+ Three of the annotations are annotations only because the token set has no role for them. They are
131
+ not judgments that a literal is right forever; no token is invented here:
132
+
133
+ - **A warning surface** and **a brand/premium surface** — `bg-yellow-100` in `PermissionRequired`,
134
+ `bg-purple-100` in `UpgradePrompt`. `--color-danger-surface` exists; there is no `warning`/`info`
135
+ equivalent, and nothing for a brand tint at all.
136
+ - **A muted danger** — `text-red-300` in `Radio`. `--color-danger-ring` is red-200 and is
137
+ documented as the invalid focus ring's value; using it as disabled text would be role abuse.
138
+ - **Shadows.** `shadow-[0_8px_24px_rgba(0,0,0,0.1)]` in `Dialog` and `shadow-[0_0_0_2px_#222]` in
139
+ the two swatch components are out of the rule's scope entirely, as the ticket directed.
140
+
141
+ ## What changed
142
+
143
+ Every swap and its role token. `theme.css` defines each token as the literal Tailwind value the
144
+ components used before the CTAP-1123 sweep, so "same value" below means byte-identical in the
145
+ default theme.
146
+
147
+ **Same light-theme value (76 occurrences) — nothing renders differently:**
148
+
149
+ | Literal | Token | n |
150
+ |---|---|---|
151
+ | `bg-white` | `bg-surface` | 25 |
152
+ | `text-white` | `text-fg-inverse` | 17 |
153
+ | `bg-slate-200` | `bg-border-subtle` | 9 |
154
+ | `bg-slate-300` | `bg-border` | 8 |
155
+ | `outline-slate-500` | `outline-fg-muted` | 5 |
156
+ | `bg-slate-500` | `bg-fg-muted` | 3 |
157
+ | `bg-slate-400` | `bg-border-emphasis` | 2 |
158
+ | `border-slate-500` | `border-fg-muted` | 2 |
159
+ | `outline-white` | `outline-fg-inverse` | 2 |
160
+ | `border-white` | `border-surface` | 1 |
161
+ | `fill-slate-300` | `fill-border` | 1 |
162
+ | `stroke-slate-300` | `stroke-border` | 1 |
163
+
164
+ **Different light-theme value (42 occurrences):**
165
+
166
+ | Literal | Token | Direction | n |
167
+ |---|---|---|---|
168
+ | `text-slate-600` | `text-fg-muted` (slate-500) | one step lighter | 7 |
169
+ | `text-slate-800` | `text-fg` (slate-900) | one step darker | 6 |
170
+ | `text-slate-700` | `text-fg` (slate-900) | two steps darker | 4 |
171
+ | `text-slate-700` | `text-fg-muted` (slate-500) | two steps lighter | 2 |
172
+ | `text-slate-300` | `text-fg-subtle` (slate-400) | one step darker | 4 |
173
+ | `bg-slate-600` | `bg-fg-muted` (slate-500) | one step lighter | 3 |
174
+ | `text-black` | `text-fg` (slate-900) | a hair lighter | 3 |
175
+ | `border-gray-200` | `border-border-subtle` (slate-200) | same lightness, faint hue shift | 2 |
176
+ | `bg-slate-50` / `fill-slate-50` / `bg-gray-50` | `*-surface-subtle` (slate-100) | one step darker | 3 |
177
+ | `border-slate-100` / `outline-slate-100` | `*-border-subtle` (slate-200) | one step darker | 2 |
178
+ | `border-gray-100` | `border-border-subtle` (slate-200) | one step darker | 1 |
179
+ | `text-slate-100` | `text-fg-inverse` (`#fff`) | a hair brighter | 1 |
180
+ | `text-gray-600` | `text-fg-muted` (slate-500) | one step lighter | 1 |
181
+ | `stroke-slate-800` / `fill-slate-800` | `stroke-fg` / `fill-fg` (slate-900) | one step darker | 2 |
182
+ | `text-green-700` | `text-success` (green-600) | one step lighter | 1 |
183
+
184
+ Where the same literal maps to two tokens, the role decided it. `text-slate-700` is the label on an
185
+ unselected swatch pill whose selected twin is `text-fg-inverse` on `bg-surface-inverse` — that is
186
+ the full-emphasis foreground, `text-fg`. The same class on `EmptyResults`' action links, whose hover
187
+ was already `text-fg`, is the muted end of a ladder, so `text-fg-muted`.
188
+
189
+ ### Rendering changes, component by component
190
+
191
+ A consumer who overrode one of these through `className` may have been working around the bug;
192
+ `cn` is tailwind-merge, so their override still wins and they lose nothing by dropping it.
193
+
194
+ | Component | What visibly changes in the default (light) theme |
195
+ |---|---|
196
+ | `Accordion` | Trigger focus ring slate-100 → slate-200. |
197
+ | `Badge` (`faded`) | Label slate-800 → slate-900. |
198
+ | `CollectionFilterChips` | Top rule gray-100 → slate-200 (a step darker). |
199
+ | `CollectionFilters` | Group heading slate-800 → slate-900; the two "clear" links' hover slate-700 → slate-900; the trigger and footer rules gray-200 → slate-200 (same lightness, a faintly cooler hue). |
200
+ | `CollectionToolbar` | Sort label gray-600 → slate-500 (lighter). |
201
+ | `EmptyResults` | Empty-state icon and the `\|` separator slate-300 → slate-400 (darker); heading slate-800 → slate-900; the two action links slate-700 → slate-500 (lighter at rest; their hover was already `text-fg`). |
202
+ | `FeatureGate` — `AccessDenied`, `PermissionRequired`, `UpgradePrompt` | Body copy slate-600 → slate-500 (lighter). |
203
+ | `Input` | Password-reveal toggle hover slate-600 → slate-500 (lighter). |
204
+ | `Link` | Hover slate-800 → slate-900; disabled and disabled-hover slate-300 → slate-400 (darker, so more legible). |
205
+ | `Menu`'s `ContextMenu` | Panel gray-50 → slate-100 and its arrow slate-50 → slate-100 (both a step darker). |
206
+ | `Pagination` | Control hover slate-50 → slate-100; page-item border slate-100 → slate-200; the `<nav>` text slate-600 → slate-500. |
207
+ | `PasswordCheck` | A met requirement's label green-700 → green-600 — which also makes it agree with its own check icon, already `text-success`; the strength segments past the current strength slate-600 → slate-500. |
208
+ | `PhoneInput` | Country-dropdown text and the dial-code-only input state slate-600 → slate-500. |
209
+ | `CircularProgressbar` | Progress arc and the centered value slate-800 → slate-900. |
210
+ | `ProductPrice` | Price and label `#000` → slate-900. |
211
+ | `ProductCardSwatches`, `SwatchText` | Unselected pill label slate-700 → slate-900. |
212
+ | `Tab` | Labels slate-800 → slate-900; the selected label on `solid`/`enclosed` slate-100 → `#fff`; the `solid` overlay slate-600 → slate-500 — under `mix-blend-color`, which takes only hue and chroma, and those two are within 0.003 of each other, so this one is not observable. |
213
+ | `UploadImage` | **The only new rendering.** The class was `hover:bg-slate-100s` — a typo, not a utility, so it never painted. It is now `hover:bg-surface-subtle`, so the control gains the hover background it was always meant to have. Its `hover:text-black` also becomes slate-900. |
214
+ | `Accordion` (`shadow` variant) | Nothing. `shadow-slate-200` set a shadow *color* with no `shadow` utility beside it, so it painted nothing; it is deleted. |
215
+
216
+ Everything not in that table renders exactly as before: `Button`, `Checkbox`, `ContextMenu`,
217
+ `CreditCardInput`, `CreditCardExpirationInput`, `DateInput`, `Dialog`, `Divider`, `DobInput`,
218
+ `Drawer`, `LinkAsButton`, `Loader`'s `Pulse`, `Meter`, `ModalOverlay`, `NumberInput`, `PinInput`,
219
+ `LinearProgressbar`, `Radio`, `Select` (all four files), `Skeleton`, `Slider`, `Switch`, `TabList`,
220
+ `TimeInput`, `ToggleButton`, `Tooltip`, and the three `Icon` glyphs.
221
+
222
+ ## On `Dialog.spec.tsx`
223
+
224
+ CTAP-1902 put a rendered-DOM assertion into `Dialog.spec.tsx` that fails on any literal palette
225
+ utility reaching the panel. That file arrives with PR #3030 and is not in `master`, so nothing here
226
+ touches it — and it should **stay**. The two checks do not overlap: the lint rule is static and sees
227
+ string literals in that one file, while the spec asserts on what actually reached the DOM, so it
228
+ still catches a literal arriving through a `tv()` variant assembled elsewhere, a HeroUI default, or
229
+ a consumer's `className`. The rule can also be switched off on a line by an annotation; the spec
230
+ cannot. Belt and braces, on purpose.
231
+
232
+ ## Consumer Impact
233
+
234
+ **No action needed to upgrade.** No prop, export or type changes. Class names on the rendered DOM do
235
+ change (`bg-white` becomes `bg-surface`), so a consumer asserting on a literal class name in a test —
236
+ or targeting one in a CSS selector — needs to update that assertion; nothing else does.
237
+
238
+ **You can now re-theme.** The point of the sweep: overriding `--color-surface`, `--color-fg` and the
239
+ rest under a class, an attribute or a media query now repaints these components, where before it
240
+ repainted only the ones CTAP-1123 had already converted.
241
+
242
+ ```css
243
+ :root[data-theme='dark'] {
244
+ --color-fg: oklch(98% 0 0);
245
+ --color-surface: oklch(20% 0.02 260);
246
+ --color-surface-subtle: oklch(25% 0.02 260);
247
+ }
248
+ ```
249
+
250
+ **Drop workaround overrides.** If you were passing `className="bg-slate-900 text-white"` to a
251
+ component purely because it hardcoded a light surface, remove it and set the token instead.
252
+
253
+ **Contributing to the package.** Write the token, not the literal. If a literal is genuinely right,
254
+ say why on the line above with `// theme-literal: <reason>` — the reason is required, and a bare
255
+ marker does not suppress anything.
256
+
257
+ ## Verification
258
+
259
+ ```
260
+ nx run frontend-module-ui:lint 0 errors (1 pre-existing react-hooks warning in ModalOverlay)
261
+ nx run frontend-module-ui:typecheck passed
262
+ nx run frontend-module-ui:test 24 suites, 136 tests, all passed
263
+ nx run frontend:lint 0 errors, 0 hits from this rule
264
+ nx run admin:lint 0 errors, 0 hits from this rule
265
+ ```
266
+
267
+ The rule was proven to fail before it was trusted to pass. Putting `bg-white` back into `Dialog`:
268
+
269
+ ```
270
+ libs/frontend-modules/ui/src/components/Dialog/Dialog.tsx
271
+ 64:7 error `bg-white` is a literal palette color: a re-themed app cannot repaint it. …
272
+ clicktap/no-literal-palette-color
273
+ ✖ 2 problems (1 error, 1 warning)
274
+ ```
275
+
276
+ and adding a reason-less `// theme-literal:` above it did **not** silence it:
277
+
278
+ ```
279
+ 65:7 error `bg-white` is a literal palette color: … clicktap/no-literal-palette-color
280
+ ✖ 2 problems (1 error, 1 warning)
281
+ ```
282
+
283
+ Both were reverted.
@@ -1,4 +1,44 @@
1
1
  [
2
+ {
3
+ "path": "2026/09/ui-2026-09-24-semantic-color-token-lint-rule.md",
4
+ "module": "libs/frontend-modules/ui",
5
+ "package": "@clicktap/ui",
6
+ "date": "2026-09-24",
7
+ "title": "IMPROVEMENT: literal palette colors are a lint error in `@clicktap/ui`, and the 136 existing ones are resolved",
8
+ "classification": "IMPROVEMENT",
9
+ "breaking": "No API change, and no prop, export or class-name contract moves. **Visual:** 76 of the 118 swaps resolve to the exact same light-theme value and render identically. The other 42 do not — they are listed component by component under \"Rendering changes\" and are all one step along a neutral ramp, except `UploadImage`, whose hover background **appears for the first time** because the class it had was a typo.",
10
+ "affects": "54 files under `src/components/**` — every component listed in \"What changed\" below — plus the new root-level `eslint.rules.no-literal-palette-color.mjs` and the two eslint configs that reference it."
11
+ },
12
+ {
13
+ "path": "2026/09/ui-2026-09-24-dialog-semantic-surface-tokens.md",
14
+ "module": "libs/frontend-modules/ui",
15
+ "package": "@clicktap/ui",
16
+ "date": "2026-09-24",
17
+ "title": "BUGFIX: `Dialog` paints from semantic tokens instead of literal `bg-white`",
18
+ "classification": "BUGFIX",
19
+ "breaking": "No. It is a change of default, and the default only moves where a consumer has re-themed the tokens: an app on the shipped light theme sees **no visual change at all** (`--color-surface` is `#fff`, the exact value `bg-white` produced, and `--color-fg` is the slate-900 such an app already inherited). A dark-themed app stops getting a white panel and gets its own surface. No API change, and the `className` escape hatch is untouched.",
20
+ "affects": "`src/components/Dialog/Dialog.tsx` — every `<Dialog>` that does not already pass its own background in `className`."
21
+ },
22
+ {
23
+ "path": "2026/09/ui-2026-09-23-interactive-elements-cursor-pointer.md",
24
+ "module": "libs/frontend-modules/ui",
25
+ "package": "@clicktap/ui",
26
+ "date": "2026-09-23",
27
+ "title": "BUGFIX: interactive elements show a pointer cursor under Tailwind v4",
28
+ "classification": "BUGFIX",
29
+ "breaking": "No API change. **Visual:** hovering an enabled button in any of the affected components now shows a pointer cursor instead of the default arrow. Disabled states are unchanged (still `not-allowed` where they had it, still no explicit cursor where they didn't).",
30
+ "affects": "`src/components/Button/Button.tsx`, `ResendCode/ResendCode.tsx`, `Select/ComboBoxSelect.tsx`, `Collection/EmptyResults/EmptyResults.tsx`, `Collection/CollectionFilters/CollectionFilters.tsx`."
31
+ },
32
+ {
33
+ "path": "2026/09/ui-2026-09-23-drawer-viewport-clamp.md",
34
+ "module": "libs/frontend-modules/ui",
35
+ "package": "@clicktap/ui",
36
+ "date": "2026-09-23",
37
+ "title": "BUGFIX: `Drawer` no longer silently becomes a full-screen takeover",
38
+ "classification": "BUGFIX",
39
+ "breaking": "No API change. **Visual:** a `left`/`right` drawer wider than 88% of the viewport, or a `top`/`bottom` drawer taller than 88% of it, now leaves a visible sliver of the page instead of covering it entirely. A drawer that already fit is unchanged.",
40
+ "affects": "`src/components/Drawer/Drawer.tsx`, `Drawer.types.ts` — every `<Drawer>` whose `size` (or default) exceeds the viewport along its opening axis."
41
+ },
2
42
  {
3
43
  "path": "2026/09/ui-2026-09-19-dialog-scrolls-within-viewport.md",
4
44
  "module": "libs/frontend-modules/ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clicktap/ui",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "private": false,
5
5
  "author": "Clicktap",
6
6
  "description": "A library of React UI components and low-level hooks.",