Skip to content

Commit e296ce8

Browse files
Migrate theme tokens from HSL channels to oklch()
1 parent 4375871 commit e296ce8

10 files changed

Lines changed: 146 additions & 139 deletions

File tree

.claude/skills/layered-ui-rails/SKILL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Populate layout regions with `content_for` (always above the render call):
6161
<%# Inject into <head> (e.g. per-tenant theming) %>
6262
<% content_for :l_ui_head do %>
6363
<style nonce="<%= content_security_policy_nonce %>">
64-
:root { --accent: 220 80% 55%; }
64+
:root { --accent: oklch(0.58 0.19 255); }
6565
</style>
6666
<% end %>
6767
@@ -150,17 +150,17 @@ All controllers use the `l-ui--` namespace and are auto-registered via importmap
150150

151151
## Theming
152152

153-
Override CSS custom properties after the engine import. Values are space-separated HSL channels (e.g. `220 80% 55%`, or `0 0% 100%` for white). Keywords and hex/rgb won't work - tokens are wrapped in `hsl()` when consumed. A converter such as https://colorpicker.dev/ can help translate hex/rgb values.
153+
Override CSS custom properties after the engine import. Values are full CSS colors - `oklch()` is recommended for perceptually uniform mixing and consistent contrast, but `#hex`, `rgb()`, and keywords also work. A converter such as https://oklch.com/ can help translate from hex/rgb.
154154

155155
```css
156156
@import "./layered_ui";
157157

158158
:root {
159-
--accent: 220 80% 55%;
160-
--accent-foreground: 0 0% 100%;
159+
--accent: oklch(0.58 0.19 255);
160+
--accent-foreground: oklch(1 0 0);
161161
}
162162
.dark {
163-
--accent: 220 80% 65%;
163+
--accent: oklch(0.72 0.14 255);
164164
}
165165
```
166166

.claude/skills/layered-ui-rails/references/CSS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ WCAG 2.2 AA table pattern:
283283

284284
## Theming tokens
285285

286-
All color values are space-separated HSL channels (e.g. `220 80% 55%`, or `0 0% 100%` for white). Override after importing the engine CSS. Keywords and hex/rgb won't work - tokens are wrapped in `hsl()` when consumed. A converter such as https://colorpicker.dev/ can help translate hex/rgb values.
286+
All color values are full CSS colors - `oklch()` is recommended for perceptually uniform mixing and consistent contrast (e.g. `oklch(0.58 0.19 255)`), but `#hex`, `rgb()`, and keywords also work. Override after importing the engine CSS. A converter such as https://oklch.com/ can help translate from hex/rgb.
287287

288288
Tier 1 - Accent (quick branding):
289289
```

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Guidance for AI agents working in this repository.
66

77
- **Entry:** `require "layered-ui-rails"``lib/layered/ui.rb``lib/layered/ui/engine.rb`
88
- **Engine:** importmap, assets, Pagy helpers when present; helpers: `AuthenticationHelper`, `NavigationHelper`, `PagyHelper`
9-
- **CSS** `app/assets/tailwind/layered/ui/styles.css`: HSL tokens, `.dark` on `<html>`, `@theme` utilities (`bg-background`, etc.), BEM components (`.l-ui-button--primary`, etc.). Layout: 63px header, 240px sidebar, 320px panel. WCAG 2.2 AA.
9+
- **CSS** `app/assets/tailwind/layered/ui/styles.css`: OKLCH tokens, `.dark` on `<html>`, `@theme` utilities (`bg-background`, etc.), BEM components (`.l-ui-button--primary`, etc.). Layout: 63px header, 240px sidebar, 320px panel. WCAG 2.2 AA.
1010
- **CSS `@apply`:** Multi-line with grouping (layout → spacing → typography → colors → effects). Single utilities may stay on one line.
1111
- **Generators:** `bin/rails generate layered:ui:install` (copy CSS, import CSS, import JS)
1212
- **JS** `app/javascript/layered_ui/`: Stimulus controllers registered as `l-ui--theme`, `l-ui--navigation`, `l-ui--panel`, `l-ui--modal`, `l-ui--tabs`

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
44

5+
## [Unreleased]
6+
7+
### Changed
8+
9+
- **Breaking:** Theme tokens migrated from HSL channels (`220 80% 55%`) to full `oklch()` values for perceptually uniform mixing. `@theme` now references the vars directly instead of wrapping them in `hsl()`. Existing overrides files need to be updated: replace `hue sat% lightness%` with the equivalent `oklch(L C H)` value (or any valid CSS color - `#hex`, `rgb()`, and keywords all work now).
10+
511
## [0.7.0] - 2026-04-23
612

713
### Added

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,14 @@ All colors are CSS custom properties on `:root` using a two-tier system:
104104
@import "./layered_ui";
105105

106106
:root {
107-
--accent: 220 80% 55%;
108-
--accent-foreground: 0 0% 100%;
107+
--accent: oklch(0.58 0.19 255);
108+
--accent-foreground: oklch(1 0 0);
109109
}
110110

111111
.dark {
112-
--accent: 220 80% 65%;
113-
--accent-foreground: 0 0% 9%;
114-
--button-primary-text: 0 0% 100%; /* white icons/text on colored buttons */
112+
--accent: oklch(0.72 0.14 255);
113+
--accent-foreground: oklch(0.2044 0 0);
114+
--button-primary-text: oklch(1 0 0); /* white icons/text on colored buttons */
115115
}
116116
```
117117

@@ -120,7 +120,7 @@ For dynamic theming (e.g. per-tenant branding), use `content_for :l_ui_head` to
120120
```erb
121121
<% content_for :l_ui_head do %>
122122
<style>
123-
:root { --accent: <%= @tenant.accent_hsl %>; --accent-foreground: 0 0% 100%; }
123+
:root { --accent: <%= @tenant.accent_color %>; --accent-foreground: oklch(1 0 0); }
124124
</style>
125125
<% end %>
126126
```
@@ -132,7 +132,7 @@ For dynamic theming (e.g. per-tenant branding), use `content_for :l_ui_head` to
132132
> ```erb
133133
> <% content_for :l_ui_head do %>
134134
> <style nonce="<%= content_security_policy_nonce %>">
135-
> :root { --accent: <%= @tenant.accent_hsl %>; --accent-foreground: 0 0% 100%; }
135+
> :root { --accent: <%= @tenant.accent_color %>; --accent-foreground: oklch(1 0 0); }
136136
> </style>
137137
> <% end %>
138138
> ```

app/assets/tailwind/layered/ui/styles.css

Lines changed: 66 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
@variant dark (&:where(.dark, .dark *));
3434

3535
/*
36-
* Color tokens (HSL channels, e.g. "220 80% 55%")
36+
* Color tokens (full oklch() values, e.g. "oklch(0.7 0.15 240)")
3737
*
3838
* Tier 1 - Accent: --accent, --accent-foreground
3939
* Tier 2 - Full palette: background, foreground, border, surface, etc.
@@ -48,59 +48,59 @@
4848
@layer base {
4949
:root {
5050
/* Tier 1 - Accent */
51-
--accent: 0 0% 9%;
52-
--accent-foreground: 0 0% 100%;
51+
--accent: oklch(0.2044 0 0);
52+
--accent-foreground: oklch(1 0 0);
5353
/* Tier 2 - Full palette */
54-
--background: 0 0% 100%;
55-
--foreground: 0 0% 13%;
56-
--foreground-muted: 0 0% 29%;
57-
--border: 0 0% 91%;
58-
--border-control: 0 0% 55%;
59-
--ring: 0 0% 13%;
60-
--surface: 0 0% 96%;
61-
--surface-highlighted: 0 0% 91%;
54+
--background: oklch(1 0 0);
55+
--foreground: oklch(0.2484 0 0);
56+
--foreground-muted: oklch(0.4089 0 0);
57+
--border: oklch(0.9312 0 0);
58+
--border-control: oklch(0.6409 0 0);
59+
--ring: oklch(0.2484 0 0);
60+
--surface: oklch(0.9696 0 0);
61+
--surface-highlighted: oklch(0.9312 0 0);
6262
--button-primary-bg: var(--accent);
6363
--button-primary-text: var(--accent-foreground);
6464
--button-primary-icon: var(--button-primary-text);
65-
--danger: 0 72% 38%;
66-
--danger-light: 0 100% 97%;
67-
--danger-text: 0 72% 35%;
68-
--success-bg: 142 76% 65%;
69-
--success-text: 142 76% 13%;
70-
--switch-track-checked: 142 70% 38%;
71-
--warning-bg: 48 96% 65%;
72-
--warning-text: 48 96% 15%;
73-
--error-bg: 0 84% 75%;
74-
--error-text: 0 93% 12%;
65+
--danger: oklch(0.47 0.1742 27.23);
66+
--danger-light: oklch(0.9663 0.0166 17.44);
67+
--danger-text: oklch(0.443 0.1634 27.14);
68+
--success-bg: oklch(0.8395 0.1698 152.91);
69+
--success-text: oklch(0.3088 0.0763 150.76);
70+
--switch-track-checked: oklch(0.6334 0.1664 149.75);
71+
--warning-bg: oklch(0.8908 0.1551 94.86);
72+
--warning-text: oklch(0.3625 0.0732 93.12);
73+
--error-bg: oklch(0.748 0.1306 20.64);
74+
--error-text: oklch(0.2248 0.0874 28.11);
7575
--header-height: 63px;
7676
}
7777

7878
.dark {
7979
/* Tier 1 - Accent */
80-
--accent: 0 0% 100%;
81-
--accent-foreground: 0 0% 9%;
80+
--accent: oklch(1 0 0);
81+
--accent-foreground: oklch(0.2044 0 0);
8282
/* Tier 2 - Full palette */
83-
--background: 0 0% 0%;
84-
--foreground: 0 0% 89%;
85-
--foreground-muted: 0 0% 71%;
86-
--border: 0 0% 16%;
87-
--border-control: 0 0% 40%;
88-
--ring: 0 0% 89%;
89-
--surface: 0 0% 8%;
90-
--surface-highlighted: 0 0% 16%;
83+
--background: oklch(0 0 0);
84+
--foreground: oklch(0.9157 0 0);
85+
--foreground-muted: oklch(0.7733 0 0);
86+
--border: oklch(0.2801 0 0);
87+
--border-control: oklch(0.5103 0 0);
88+
--ring: oklch(0.9157 0 0);
89+
--surface: oklch(0.193 0 0);
90+
--surface-highlighted: oklch(0.2801 0 0);
9191
--button-primary-bg: var(--accent);
9292
--button-primary-text: var(--accent-foreground);
9393
--button-primary-icon: var(--button-primary-text);
94-
--danger: 0 85% 60%;
95-
--danger-light: 0 93% 15%;
96-
--danger-text: 0 85% 64%;
97-
--success-bg: 142 76% 15%;
98-
--success-text: 142 76% 80%;
99-
--switch-track-checked: 142 70% 45%;
100-
--warning-bg: 48 96% 15%;
101-
--warning-text: 48 96% 80%;
102-
--error-bg: 0 93% 12%;
103-
--error-text: 0 84% 75%;
94+
--danger: oklch(0.6362 0.2102 25.49);
95+
--danger-light: oklch(0.2596 0.1021 28.32);
96+
--danger-text: oklch(0.6607 0.1921 24.02);
97+
--success-bg: oklch(0.3385 0.0852 150.45);
98+
--success-text: oklch(0.8999 0.1022 155.94);
99+
--switch-track-checked: oklch(0.7176 0.1902 149.6);
100+
--warning-bg: oklch(0.3625 0.0732 93.12);
101+
--warning-text: oklch(0.9336 0.1 95.79);
102+
--error-bg: oklch(0.2248 0.0874 28.11);
103+
--error-text: oklch(0.748 0.1306 20.64);
104104
}
105105

106106
/* Typography */
@@ -148,7 +148,7 @@
148148
.l-ui-body,
149149
.l-ui-body * {
150150
scrollbar-width: thin;
151-
scrollbar-color: hsl(var(--foreground-muted) / 0.3) transparent;
151+
scrollbar-color: color-mix(in oklch, var(--foreground-muted) 30%, transparent) transparent;
152152
}
153153
}
154154

@@ -157,29 +157,29 @@
157157
@theme {
158158
--font-manrope: 'Manrope', ui-sans-serif, system-ui, sans-serif;
159159
--font-inter: 'Inter', ui-sans-serif, system-ui, sans-serif;
160-
--color-accent: hsl(var(--accent));
161-
--color-accent-foreground: hsl(var(--accent-foreground));
162-
--color-background: hsl(var(--background));
163-
--color-foreground: hsl(var(--foreground));
164-
--color-foreground-muted: hsl(var(--foreground-muted));
165-
--color-border: hsl(var(--border));
166-
--color-border-control: hsl(var(--border-control));
167-
--color-surface: hsl(var(--surface));
168-
--color-surface-highlighted: hsl(var(--surface-highlighted));
169-
--color-button-primary-bg: hsl(var(--button-primary-bg));
170-
--color-button-primary-text: hsl(var(--button-primary-text));
171-
--color-button-primary-icon: hsl(var(--button-primary-icon));
172-
--color-danger: hsl(var(--danger));
173-
--color-danger-light: hsl(var(--danger-light));
174-
--color-danger-text: hsl(var(--danger-text));
175-
--color-success-bg: hsl(var(--success-bg));
176-
--color-success-text: hsl(var(--success-text));
177-
--color-switch-track-checked: hsl(var(--switch-track-checked));
178-
--color-warning-bg: hsl(var(--warning-bg));
179-
--color-warning-text: hsl(var(--warning-text));
180-
--color-error-bg: hsl(var(--error-bg));
181-
--color-error-text: hsl(var(--error-text));
182-
--color-ring: hsl(var(--ring));
160+
--color-accent: var(--accent);
161+
--color-accent-foreground: var(--accent-foreground);
162+
--color-background: var(--background);
163+
--color-foreground: var(--foreground);
164+
--color-foreground-muted: var(--foreground-muted);
165+
--color-border: var(--border);
166+
--color-border-control: var(--border-control);
167+
--color-surface: var(--surface);
168+
--color-surface-highlighted: var(--surface-highlighted);
169+
--color-button-primary-bg: var(--button-primary-bg);
170+
--color-button-primary-text: var(--button-primary-text);
171+
--color-button-primary-icon: var(--button-primary-icon);
172+
--color-danger: var(--danger);
173+
--color-danger-light: var(--danger-light);
174+
--color-danger-text: var(--danger-text);
175+
--color-success-bg: var(--success-bg);
176+
--color-success-text: var(--success-text);
177+
--color-switch-track-checked: var(--switch-track-checked);
178+
--color-warning-bg: var(--warning-bg);
179+
--color-warning-text: var(--warning-text);
180+
--color-error-bg: var(--error-bg);
181+
--color-error-text: var(--error-text);
182+
--color-ring: var(--ring);
183183
}
184184

185185
/* Accessibility */
@@ -1092,7 +1092,7 @@ pre.l-ui-surface {
10921092
w-4 h-4
10931093
pointer-events-none;
10941094
content: '';
1095-
background-color: hsl(var(--foreground));
1095+
background-color: var(--foreground);
10961096
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E");
10971097
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E");
10981098
-webkit-mask-size: contain;

lib/generators/layered/ui/create_overrides_generator.rb

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ def overrides_content
2626
* This file is NOT overwritten by the install generator, so your
2727
* changes are preserved when you upgrade layered-ui-rails.
2828
*
29-
* Values are space-separated HSL channels (e.g. `220 80% 55%`,
30-
* or `0 0% 100%` for white). Keywords and hex/rgb won't work -
31-
* tokens are wrapped in `hsl()` when consumed. A converter such
32-
* as https://colorpicker.dev/ can help translate hex/rgb values.
29+
* Values are full `oklch()` colors (e.g. `oklch(0.7 0.15 240)`).
30+
* Any valid CSS color works too - `#hex`, `rgb(...)`, keywords -
31+
* but `oklch()` is recommended for perceptually uniform mixing
32+
* and consistent contrast. A converter such as
33+
* https://oklch.com/ can help translate from hex/rgb.
3334
*/
3435
3536
/* ----------------------------------------------------------------
@@ -46,13 +47,13 @@ def overrides_content
4647
* ---------------------------------------------------------------- */
4748
4849
:root {
49-
/* --accent: 0 0% 9%; */
50-
/* --accent-foreground: 0 0% 100%; */
50+
/* --accent: oklch(0.2044 0 0); */
51+
/* --accent-foreground: oklch(1 0 0); */
5152
}
5253
5354
.dark {
54-
/* --accent: 0 0% 100%; */
55-
/* --accent-foreground: 0 0% 9%; */
55+
/* --accent: oklch(1 0 0); */
56+
/* --accent-foreground: oklch(0.2044 0 0); */
5657
}
5758
5859
/* ----------------------------------------------------------------
@@ -63,49 +64,49 @@ def overrides_content
6364
6465
/*
6566
:root {
66-
--background: 0 0% 100%;
67-
--foreground: 0 0% 13%;
68-
--foreground-muted: 0 0% 29%;
69-
--border: 0 0% 91%;
70-
--border-control: 0 0% 55%;
71-
--ring: 0 0% 13%;
72-
--surface: 0 0% 96%;
73-
--surface-highlighted: 0 0% 91%;
67+
--background: oklch(1 0 0);
68+
--foreground: oklch(0.2484 0 0);
69+
--foreground-muted: oklch(0.4089 0 0);
70+
--border: oklch(0.9312 0 0);
71+
--border-control: oklch(0.6409 0 0);
72+
--ring: oklch(0.2484 0 0);
73+
--surface: oklch(0.9696 0 0);
74+
--surface-highlighted: oklch(0.9312 0 0);
7475
--button-primary-bg: var(--accent);
7576
--button-primary-text: var(--accent-foreground);
7677
--button-primary-icon: var(--button-primary-text);
77-
--danger: 0 72% 38%;
78-
--danger-light: 0 100% 97%;
79-
--danger-text: 0 72% 35%;
80-
--success-bg: 142 76% 65%;
81-
--success-text: 142 76% 13%;
82-
--warning-bg: 48 96% 65%;
83-
--warning-text: 48 96% 15%;
84-
--error-bg: 0 84% 75%;
85-
--error-text: 0 93% 12%;
78+
--danger: oklch(0.47 0.1742 27.23);
79+
--danger-light: oklch(0.9663 0.0166 17.44);
80+
--danger-text: oklch(0.443 0.1634 27.14);
81+
--success-bg: oklch(0.8395 0.1698 152.91);
82+
--success-text: oklch(0.3088 0.0763 150.76);
83+
--warning-bg: oklch(0.8908 0.1551 94.86);
84+
--warning-text: oklch(0.3625 0.0732 93.12);
85+
--error-bg: oklch(0.748 0.1306 20.64);
86+
--error-text: oklch(0.2248 0.0874 28.11);
8687
}
8788
8889
.dark {
89-
--background: 0 0% 0%;
90-
--foreground: 0 0% 89%;
91-
--foreground-muted: 0 0% 71%;
92-
--border: 0 0% 16%;
93-
--border-control: 0 0% 40%;
94-
--ring: 0 0% 89%;
95-
--surface: 0 0% 8%;
96-
--surface-highlighted: 0 0% 16%;
90+
--background: oklch(0 0 0);
91+
--foreground: oklch(0.9157 0 0);
92+
--foreground-muted: oklch(0.7733 0 0);
93+
--border: oklch(0.2801 0 0);
94+
--border-control: oklch(0.5103 0 0);
95+
--ring: oklch(0.9157 0 0);
96+
--surface: oklch(0.193 0 0);
97+
--surface-highlighted: oklch(0.2801 0 0);
9798
--button-primary-bg: var(--accent);
9899
--button-primary-text: var(--accent-foreground);
99100
--button-primary-icon: var(--button-primary-text);
100-
--danger: 0 85% 60%;
101-
--danger-light: 0 93% 15%;
102-
--danger-text: 0 85% 64%;
103-
--success-bg: 142 76% 15%;
104-
--success-text: 142 76% 80%;
105-
--warning-bg: 48 96% 15%;
106-
--warning-text: 48 96% 80%;
107-
--error-bg: 0 93% 12%;
108-
--error-text: 0 84% 75%;
101+
--danger: oklch(0.6362 0.2102 25.49);
102+
--danger-light: oklch(0.2596 0.1021 28.32);
103+
--danger-text: oklch(0.6607 0.1921 24.02);
104+
--success-bg: oklch(0.3385 0.0852 150.45);
105+
--success-text: oklch(0.8999 0.1022 155.94);
106+
--warning-bg: oklch(0.3625 0.0732 93.12);
107+
--warning-text: oklch(0.9336 0.1 95.79);
108+
--error-bg: oklch(0.2248 0.0874 28.11);
109+
--error-text: oklch(0.748 0.1306 20.64);
109110
}
110111
*/
111112

0 commit comments

Comments
 (0)