layered-ui-rails 0.7.0 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b538d1b28b8718c6bcbaa3e9e21b8294b2e0556ca869433245d1e587ac7679a9
4
- data.tar.gz: 775ec4083eb6e729eee0aa2c83c3ed5001fe258dd79c78ed476e103910797dda
3
+ metadata.gz: ff66e126597c535133ec15b8fa13fd0efeecfe319b9a60d383b051ce111234e1
4
+ data.tar.gz: 25872e652c050f87c8752a43b8f9184ee3f98f888a7b30086efa4531c329ed1a
5
5
  SHA512:
6
- metadata.gz: bfd719cd045e9ceb2a065c8ab8de722f6bc9187e541580dd39e7f11bd3672aae14628b04252fd0dc0de44dcc0656cb61df01954c210227a611a6bf4c5d187e1e
7
- data.tar.gz: 4ac05f4a41d0cb312d67f0337136f77a3eecfb944089ce65be0c78fe15fe7a326edd7f06fa790c0b5cec1cb57cb2275c99d14d39015be3288ef35da7dffd706e
6
+ metadata.gz: 22e3a324fb57843766fc6b243071b305508d88dc8ca00dd428cce23eebb7f965cab8be974fcaeef365e74ba142bff59d28f2b8b9680ff50ab3853c94d1806079
7
+ data.tar.gz: d0e60d24e524aad6516e2e070db67e31d7eb049d63334f36d99463d2cd2cb392ce8e863070f1952d5387096f5bc5ec06c172346ba57c3fe2b4a78ceeb6cbe4fc
@@ -61,7 +61,7 @@ Populate layout regions with `content_for` (always above the render call):
61
61
  <%# Inject into <head> (e.g. per-tenant theming) %>
62
62
  <% content_for :l_ui_head do %>
63
63
  <style nonce="<%= content_security_policy_nonce %>">
64
- :root { --accent: 220 80% 55%; }
64
+ :root { --accent: oklch(0.58 0.19 255); }
65
65
  </style>
66
66
  <% end %>
67
67
 
@@ -106,6 +106,7 @@ Quick reference:
106
106
  | `l_ui_navigation_item(label, path, active: nil, &block)` | Sidebar nav link with optional nesting |
107
107
  | `l_ui_breadcrumbs(&block)` | Breadcrumb nav wrapper |
108
108
  | `l_ui_breadcrumb_item(label, path = nil)` | Individual breadcrumb |
109
+ | `l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)` | Responsive page title bar with breadcrumbs and actions |
109
110
  | `l_ui_pagy(pagy)` | Styled pagination (requires pagy gem) |
110
111
  | `l_ui_search_form(query, url:, fields:, ...)` | Search form (requires ransack gem) |
111
112
  | `l_ui_sort_link(query, attribute, label = nil, ...)` | Sortable table header (requires ransack gem) |
@@ -150,17 +151,17 @@ All controllers use the `l-ui--` namespace and are auto-registered via importmap
150
151
 
151
152
  ## Theming
152
153
 
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.
154
+ 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.
154
155
 
155
156
  ```css
156
157
  @import "./layered_ui";
157
158
 
158
159
  :root {
159
- --accent: 220 80% 55%;
160
- --accent-foreground: 0 0% 100%;
160
+ --accent: oklch(0.58 0.19 255);
161
+ --accent-foreground: oklch(1 0 0);
161
162
  }
162
163
  .dark {
163
- --accent: 220 80% 65%;
164
+ --accent: oklch(0.72 0.14 255);
164
165
  }
165
166
  ```
166
167
 
@@ -28,6 +28,7 @@ Responsive sidebar navigation with backdrop overlay on mobile.
28
28
  **Targets:** `navigation`, `backdrop`, `toggleButton`, `openIcon`, `closeIcon`
29
29
  **Actions:** `toggle`, `close`
30
30
  **Keyboard:** Escape to close
31
+ **Behaviour:** Locks body scroll while the mobile overlay is open
31
32
 
32
33
  The layout wires this up automatically. Navigation items are populated via `content_for :l_ui_navigation_items`.
33
34
 
@@ -170,6 +170,15 @@ WCAG 2.2 AA table pattern:
170
170
  .l-ui-breadcrumbs__link Breadcrumb link
171
171
  ```
172
172
 
173
+ ## Title bar
174
+
175
+ ```
176
+ .l-ui-title-bar Title bar wrapper used with .l-ui-container--spread
177
+ .l-ui-title-bar__content Breadcrumbs and title column
178
+ .l-ui-title-bar__title Page title
179
+ .l-ui-title-bar__actions Action area
180
+ ```
181
+
173
182
  ## Pagination
174
183
 
175
184
  ```
@@ -275,6 +284,7 @@ WCAG 2.2 AA table pattern:
275
284
  .l-ui-sr-only Visually hidden, screen reader only
276
285
  .l-ui-skip-link Accessibility skip link
277
286
  .l-ui-list Styled list
287
+ .l-ui-hr Horizontal rule with theme border and vertical spacing
278
288
  .l-ui-container--grid 1-col mobile, 2-col desktop grid
279
289
  .l-ui-container--spread Flex row with space-between
280
290
  .l-ui-container--pagy Pagination wrapper
@@ -283,7 +293,7 @@ WCAG 2.2 AA table pattern:
283
293
 
284
294
  ## Theming tokens
285
295
 
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.
296
+ 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.
287
297
 
288
298
  Tier 1 - Accent (quick branding):
289
299
  ```
@@ -41,6 +41,29 @@ l_ui_breadcrumb_item(label, path = nil)
41
41
  <% end %>
42
42
  ```
43
43
 
44
+ ## Title bar
45
+
46
+ ```ruby
47
+ l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)
48
+ ```
49
+
50
+ - `title` (String) - page title rendered as the `<h1>`
51
+ - `breadcrumbs` (Array, optional) - breadcrumb items as `[label, path]` arrays or `{ label:, path: }` hashes
52
+ - `actions` (String|Array, optional) - HTML-safe action content; omit when using a block
53
+ - `&block` - optional action markup, usually buttons or links
54
+
55
+ ```erb
56
+ <%= l_ui_title_bar(
57
+ title: "Users",
58
+ breadcrumbs: [
59
+ ["Home", root_path],
60
+ ["Admin", admin_path]
61
+ ]
62
+ ) do %>
63
+ <%= link_to "New user", new_user_path, class: "l-ui-button--primary" %>
64
+ <% end %>
65
+ ```
66
+
44
67
  ## Pagination (requires pagy gem)
45
68
 
46
69
  ```ruby
data/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ Guidance for AI agents working in this repository.
6
6
 
7
7
  - **Entry:** `require "layered-ui-rails"` → `lib/layered/ui.rb` → `lib/layered/ui/engine.rb`
8
8
  - **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.
10
10
  - **CSS `@apply`:** Multi-line with grouping (layout → spacing → typography → colors → effects). Single utilities may stay on one line.
11
11
  - **Generators:** `bin/rails generate layered:ui:install` (copy CSS, import CSS, import JS)
12
12
  - **JS** `app/javascript/layered_ui/`: Stimulus controllers registered as `l-ui--theme`, `l-ui--navigation`, `l-ui--panel`, `l-ui--modal`, `l-ui--tabs`
data/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.9.0] - 2026-04-26
6
+
7
+ ### Added
8
+
9
+ - `l_ui_title_bar` helper for page title bars with optional actions
10
+ - `l-ui-hr` class for styled horizontal rules
11
+ - `color-scheme` CSS property set to match the active theme so native form controls (date pickers, scrollbars, etc.) render with matching light/dark styling
12
+
13
+ ### Changed
14
+
15
+ - Navigation and panel controllers share an extracted `scroll_lock` utility module
16
+ - Checkbox and radio inputs use a pointer cursor
17
+ - Scroll-to-bottom button is centred and has a faint shadow
18
+ - Form submit label shortened from "Save changes" to "Save"
19
+
20
+ ## [0.8.0] - 2026-04-24
21
+
22
+ ### Changed
23
+
24
+ - **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).
25
+ - `layered:ui:create_overrides` generator and token documentation (`SKILL.md`, `references/CSS.md`, `README.md`, dummy app colour/head pages) updated to reflect the new `oklch()` format.
26
+
5
27
  ## [0.7.0] - 2026-04-23
6
28
 
7
29
  ### Added
data/README.md CHANGED
@@ -104,14 +104,14 @@ All colors are CSS custom properties on `:root` using a two-tier system:
104
104
  @import "./layered_ui";
105
105
 
106
106
  :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);
109
109
  }
110
110
 
111
111
  .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 */
115
115
  }
116
116
  ```
117
117
 
@@ -120,7 +120,7 @@ For dynamic theming (e.g. per-tenant branding), use `content_for :l_ui_head` to
120
120
  ```erb
121
121
  <% content_for :l_ui_head do %>
122
122
  <style>
123
- :root { --accent: <%= @tenant.accent_hsl %>; --accent-foreground: 0 0% 100%; }
123
+ :root { --accent: <%= @tenant.accent_color %>; --accent-foreground: oklch(1 0 0); }
124
124
  </style>
125
125
  <% end %>
126
126
  ```
@@ -132,7 +132,7 @@ For dynamic theming (e.g. per-tenant branding), use `content_for :l_ui_head` to
132
132
  > ```erb
133
133
  > <% content_for :l_ui_head do %>
134
134
  > <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); }
136
136
  > </style>
137
137
  > <% end %>
138
138
  > ```
@@ -213,9 +213,7 @@ kamal deploy
213
213
 
214
214
  ## Contributing
215
215
 
216
- This project is still in its early days. We welcome issues, feedback, and ideas - they genuinely help shape the direction of the project. That said, we're holding off on accepting pull requests until after the 1.0 release so we can stay focused on getting the core foundations right. Once we're there, we'd love to open things up to broader contributions. Thanks for your patience and interest!
217
-
218
- - [CLA.md](CLA.md) - contributor license agreement
216
+ This project is still in its early days. We welcome issues, feedback, and ideas - they genuinely help shape the direction of the project. That said, we're holding off on accepting pull requests for now to stay focused on getting the foundations right. Thank you for your patience and interest. See [CLA.md](CLA.md) for the full policy.
219
217
 
220
218
  ## License
221
219
 
@@ -225,4 +223,4 @@ Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830). See [NOTICE](NO
225
223
 
226
224
  ## Trademarks
227
225
 
228
- The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See [TRADEMARK.md](TRADEMARK.md) for the full policy.
226
+ The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See [TRADEMARK.md](TRADEMARK.md) for the full policy.
@@ -33,7 +33,7 @@
33
33
  @variant dark (&:where(.dark, .dark *));
34
34
 
35
35
  /*
36
- * Color tokens (HSL channels, e.g. "220 80% 55%")
36
+ * Color tokens (full oklch() values, e.g. "oklch(0.7 0.15 240)")
37
37
  *
38
38
  * Tier 1 - Accent: --accent, --accent-foreground
39
39
  * Tier 2 - Full palette: background, foreground, border, surface, etc.
@@ -47,60 +47,62 @@
47
47
 
48
48
  @layer base {
49
49
  :root {
50
+ color-scheme: light;
50
51
  /* Tier 1 - Accent */
51
- --accent: 0 0% 9%;
52
- --accent-foreground: 0 0% 100%;
52
+ --accent: oklch(0.2044 0 0);
53
+ --accent-foreground: oklch(1 0 0);
53
54
  /* 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%;
55
+ --background: oklch(1 0 0);
56
+ --foreground: oklch(0.2484 0 0);
57
+ --foreground-muted: oklch(0.4089 0 0);
58
+ --border: oklch(0.9312 0 0);
59
+ --border-control: oklch(0.6409 0 0);
60
+ --ring: oklch(0.2484 0 0);
61
+ --surface: oklch(0.9696 0 0);
62
+ --surface-highlighted: oklch(0.9312 0 0);
62
63
  --button-primary-bg: var(--accent);
63
64
  --button-primary-text: var(--accent-foreground);
64
65
  --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%;
66
+ --danger: oklch(0.47 0.1742 27.23);
67
+ --danger-light: oklch(0.9663 0.0166 17.44);
68
+ --danger-text: oklch(0.443 0.1634 27.14);
69
+ --success-bg: oklch(0.8395 0.1698 152.91);
70
+ --success-text: oklch(0.3088 0.0763 150.76);
71
+ --switch-track-checked: oklch(0.6334 0.1664 149.75);
72
+ --warning-bg: oklch(0.8908 0.1551 94.86);
73
+ --warning-text: oklch(0.3625 0.0732 93.12);
74
+ --error-bg: oklch(0.748 0.1306 20.64);
75
+ --error-text: oklch(0.2248 0.0874 28.11);
75
76
  --header-height: 63px;
76
77
  }
77
78
 
78
79
  .dark {
80
+ color-scheme: dark;
79
81
  /* Tier 1 - Accent */
80
- --accent: 0 0% 100%;
81
- --accent-foreground: 0 0% 9%;
82
+ --accent: oklch(1 0 0);
83
+ --accent-foreground: oklch(0.2044 0 0);
82
84
  /* 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%;
85
+ --background: oklch(0 0 0);
86
+ --foreground: oklch(0.9157 0 0);
87
+ --foreground-muted: oklch(0.7733 0 0);
88
+ --border: oklch(0.2801 0 0);
89
+ --border-control: oklch(0.5103 0 0);
90
+ --ring: oklch(0.9157 0 0);
91
+ --surface: oklch(0.193 0 0);
92
+ --surface-highlighted: oklch(0.2801 0 0);
91
93
  --button-primary-bg: var(--accent);
92
94
  --button-primary-text: var(--accent-foreground);
93
95
  --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%;
96
+ --danger: oklch(0.6362 0.2102 25.49);
97
+ --danger-light: oklch(0.2596 0.1021 28.32);
98
+ --danger-text: oklch(0.6607 0.1921 24.02);
99
+ --success-bg: oklch(0.3385 0.0852 150.45);
100
+ --success-text: oklch(0.8999 0.1022 155.94);
101
+ --switch-track-checked: oklch(0.7176 0.1902 149.6);
102
+ --warning-bg: oklch(0.3625 0.0732 93.12);
103
+ --warning-text: oklch(0.9336 0.1 95.79);
104
+ --error-bg: oklch(0.2248 0.0874 28.11);
105
+ --error-text: oklch(0.748 0.1306 20.64);
104
106
  }
105
107
 
106
108
  /* Typography */
@@ -148,7 +150,7 @@
148
150
  .l-ui-body,
149
151
  .l-ui-body * {
150
152
  scrollbar-width: thin;
151
- scrollbar-color: hsl(var(--foreground-muted) / 0.3) transparent;
153
+ scrollbar-color: color-mix(in oklch, var(--foreground-muted) 30%, transparent) transparent;
152
154
  }
153
155
  }
154
156
 
@@ -157,29 +159,29 @@
157
159
  @theme {
158
160
  --font-manrope: 'Manrope', ui-sans-serif, system-ui, sans-serif;
159
161
  --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));
162
+ --color-accent: var(--accent);
163
+ --color-accent-foreground: var(--accent-foreground);
164
+ --color-background: var(--background);
165
+ --color-foreground: var(--foreground);
166
+ --color-foreground-muted: var(--foreground-muted);
167
+ --color-border: var(--border);
168
+ --color-border-control: var(--border-control);
169
+ --color-surface: var(--surface);
170
+ --color-surface-highlighted: var(--surface-highlighted);
171
+ --color-button-primary-bg: var(--button-primary-bg);
172
+ --color-button-primary-text: var(--button-primary-text);
173
+ --color-button-primary-icon: var(--button-primary-icon);
174
+ --color-danger: var(--danger);
175
+ --color-danger-light: var(--danger-light);
176
+ --color-danger-text: var(--danger-text);
177
+ --color-success-bg: var(--success-bg);
178
+ --color-success-text: var(--success-text);
179
+ --color-switch-track-checked: var(--switch-track-checked);
180
+ --color-warning-bg: var(--warning-bg);
181
+ --color-warning-text: var(--warning-text);
182
+ --color-error-bg: var(--error-bg);
183
+ --color-error-text: var(--error-text);
184
+ --color-ring: var(--ring);
183
185
  }
184
186
 
185
187
  /* Accessibility */
@@ -294,6 +296,13 @@
294
296
  space-y-1;
295
297
  }
296
298
 
299
+ /* Horizontal rule */
300
+
301
+ .l-ui-hr {
302
+ @apply my-4
303
+ border-0 border-t border-border;
304
+ }
305
+
297
306
  /* Markdown */
298
307
 
299
308
  .l-ui-markdown > *:first-child {
@@ -740,6 +749,26 @@
740
749
  focus-ring rounded-sm;
741
750
  }
742
751
 
752
+ /* Title bar */
753
+
754
+ .l-ui-title-bar {
755
+ @apply w-full;
756
+ }
757
+
758
+ .l-ui-title-bar__content {
759
+ @apply min-w-0;
760
+ }
761
+
762
+ .l-ui-title-bar__title {
763
+ @apply mt-0;
764
+ }
765
+
766
+ .l-ui-title-bar__actions {
767
+ @apply flex flex-wrap items-center justify-end
768
+ gap-2
769
+ shrink-0;
770
+ }
771
+
743
772
  /* Buttons */
744
773
 
745
774
  @utility button {
@@ -1017,7 +1046,8 @@ pre.l-ui-surface {
1017
1046
  @apply w-6 h-6
1018
1047
  accent-foreground
1019
1048
  focus-ring
1020
- rounded-sm;
1049
+ rounded-sm
1050
+ cursor-pointer;
1021
1051
  }
1022
1052
 
1023
1053
  /* Search */
@@ -1092,7 +1122,7 @@ pre.l-ui-surface {
1092
1122
  w-4 h-4
1093
1123
  pointer-events-none;
1094
1124
  content: '';
1095
- background-color: hsl(var(--foreground));
1125
+ background-color: var(--foreground);
1096
1126
  -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");
1097
1127
  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");
1098
1128
  -webkit-mask-size: contain;
@@ -1124,11 +1154,12 @@ pre.l-ui-surface {
1124
1154
  @apply w-6 h-6
1125
1155
  mr-2
1126
1156
  accent-foreground
1127
- focus-ring;
1157
+ focus-ring
1158
+ cursor-pointer;
1128
1159
  }
1129
1160
 
1130
1161
  .l-ui-radio__label {
1131
- @apply text-sm;
1162
+ @apply text-sm cursor-pointer;
1132
1163
  }
1133
1164
 
1134
1165
  /* Tabs */
@@ -1659,10 +1690,11 @@ pre.l-ui-surface {
1659
1690
  .l-ui-scroll-to-bottom {
1660
1691
  @apply
1661
1692
  sticky bottom-2 flex items-center justify-center
1662
- ml-auto mr-0 -mt-9 h-9 w-9
1693
+ mx-auto -mt-9 h-9 w-9
1663
1694
  rounded-full
1664
1695
  cursor-pointer
1665
1696
  bg-button-primary-bg text-button-primary-icon
1697
+ shadow-sm
1666
1698
  focus-ring
1667
1699
  opacity-0 pointer-events-none
1668
1700
  transition-opacity duration-200;
@@ -0,0 +1,52 @@
1
+ module Layered
2
+ module Ui
3
+ module TitleBarHelper
4
+ include Layered::Ui::BreadcrumbsHelper
5
+
6
+ def l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)
7
+ action_content = block_given? ? capture(&block) : actions
8
+
9
+ content_tag(:header, class: "l-ui-title-bar l-ui-container--spread") do
10
+ safe_join([
11
+ content_tag(:div, class: "l-ui-title-bar__content") do
12
+ safe_join([
13
+ l_ui_title_bar_breadcrumbs(breadcrumbs),
14
+ content_tag(:h1, title, class: "l-ui-title-bar__title")
15
+ ].compact)
16
+ end,
17
+ l_ui_title_bar_actions(action_content)
18
+ ].compact)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def l_ui_title_bar_breadcrumbs(breadcrumbs)
25
+ return if breadcrumbs.blank?
26
+
27
+ l_ui_breadcrumbs do
28
+ safe_join(breadcrumbs.map { |breadcrumb| l_ui_title_bar_breadcrumb_item(breadcrumb) })
29
+ end
30
+ end
31
+
32
+ def l_ui_title_bar_breadcrumb_item(breadcrumb)
33
+ case breadcrumb
34
+ when Hash
35
+ l_ui_breadcrumb_item(breadcrumb.fetch(:label), breadcrumb[:path])
36
+ when Array
37
+ l_ui_breadcrumb_item(breadcrumb[0], breadcrumb[1])
38
+ else
39
+ l_ui_breadcrumb_item(breadcrumb)
40
+ end
41
+ end
42
+
43
+ def l_ui_title_bar_actions(action_content)
44
+ return if action_content.blank?
45
+
46
+ content = action_content.is_a?(Array) ? safe_join(action_content) : action_content
47
+
48
+ content_tag(:div, content, class: "l-ui-title-bar__actions")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,6 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
3
3
  import { isMobile } from "layered_ui/utilities/layout"
4
+ import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"
4
5
 
5
6
  export default class extends Controller {
6
7
  static targets = ["navigation", "backdrop", "toggleButton", "openIcon", "closeIcon"]
@@ -8,6 +9,7 @@ export default class extends Controller {
8
9
  connect() {
9
10
  this.previousActiveElement = null
10
11
  this.isOpen = false
12
+ this.isScrollLocked = false
11
13
  this._resizeFrame = null
12
14
  this.boundHandleResize = () => {
13
15
  if (this._resizeFrame) return
@@ -54,6 +56,7 @@ export default class extends Controller {
54
56
  this.navigationTarget.classList.add("open")
55
57
  this.backdropTarget.classList.add("open")
56
58
  this.setNavigationInteractivity(true)
59
+ this.updateScrollLock()
57
60
 
58
61
  // Update ARIA attributes and swap icons
59
62
  if (this.hasToggleButtonTarget) {
@@ -89,6 +92,7 @@ export default class extends Controller {
89
92
  this.navigationTarget.classList.remove("open")
90
93
  this.backdropTarget.classList.remove("open")
91
94
  this.setNavigationInteractivity(false)
95
+ this.unlockScroll()
92
96
 
93
97
  // Update ARIA attributes and swap icons
94
98
  if (this.hasToggleButtonTarget) {
@@ -116,6 +120,7 @@ export default class extends Controller {
116
120
  clearAnnounceTimeout(this)
117
121
  cancelAnimationFrame(this._resizeFrame)
118
122
  window.removeEventListener("resize", this.boundHandleResize)
123
+ this.unlockScroll()
119
124
  this.previousActiveElement = null
120
125
  }
121
126
 
@@ -125,6 +130,7 @@ export default class extends Controller {
125
130
  // In overlay mode (default), always respect isOpen state regardless of viewport
126
131
  if (isMobile() || !this.alwaysShow) {
127
132
  this.setNavigationInteractivity(this.isOpen)
133
+ this.updateScrollLock()
128
134
  return
129
135
  }
130
136
 
@@ -133,6 +139,7 @@ export default class extends Controller {
133
139
  this.navigationTarget.classList.remove("open")
134
140
  this.backdropTarget.classList.remove("open")
135
141
  this.setNavigationInteractivity(true)
142
+ this.unlockScroll()
136
143
 
137
144
  if (this.hasToggleButtonTarget) {
138
145
  this.toggleButtonTarget.setAttribute("aria-expanded", "false")
@@ -157,6 +164,28 @@ export default class extends Controller {
157
164
  this.navigationTarget.removeAttribute("aria-hidden")
158
165
  }
159
166
 
167
+ updateScrollLock() {
168
+ if (this.isOpen && isMobile()) {
169
+ this.lockScroll()
170
+ } else {
171
+ this.unlockScroll()
172
+ }
173
+ }
174
+
175
+ lockScroll() {
176
+ if (this.isScrollLocked) return
177
+
178
+ lockBodyScroll()
179
+ this.isScrollLocked = true
180
+ }
181
+
182
+ unlockScroll() {
183
+ if (!this.isScrollLocked) return
184
+
185
+ unlockBodyScroll()
186
+ this.isScrollLocked = false
187
+ }
188
+
160
189
  get alwaysShow() {
161
190
  return this.element.classList.contains("l-ui-body--always-show-navigation")
162
191
  }
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
2
2
  import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
3
3
  import { storageGet, storageSet } from "layered_ui/utilities/storage"
4
4
  import { isMobile } from "layered_ui/utilities/layout"
5
+ import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"
5
6
 
6
7
  export default class extends Controller {
7
8
  static targets = ["container", "hideButton", "actionButton"]
@@ -9,6 +10,7 @@ export default class extends Controller {
9
10
  connect() {
10
11
  this.previousActiveElement = null
11
12
  this.isOpen = false
13
+ this.isScrollLocked = false
12
14
  this.boundKeyboardShortcut = this.handleKeyboardShortcut.bind(this)
13
15
  this.boundCloseOnNavigate = this.closeOnMobileNavigate.bind(this)
14
16
  const page = document.querySelector(".l-ui-page")
@@ -34,6 +36,7 @@ export default class extends Controller {
34
36
  clearAnnounceTimeout(this)
35
37
  document.removeEventListener('keydown', this.boundKeyboardShortcut)
36
38
  document.removeEventListener('turbo:visit', this.boundCloseOnNavigate)
39
+ this.unlockScroll()
37
40
  this.previousActiveElement = null
38
41
  }
39
42
 
@@ -90,9 +93,7 @@ export default class extends Controller {
90
93
  if (isMobile()) {
91
94
  const main = document.querySelector("main")
92
95
  if (main) main.setAttribute("inert", "")
93
- this.savedScrollY = window.scrollY
94
- document.body.style.top = `-${this.savedScrollY}px`
95
- document.body.classList.add("l-ui-scroll-lock")
96
+ this.lockScroll()
96
97
  }
97
98
 
98
99
  storageSet("panelOpen", "true")
@@ -124,11 +125,7 @@ export default class extends Controller {
124
125
 
125
126
  const main = document.querySelector("main")
126
127
  if (main) main.removeAttribute("inert")
127
- document.body.classList.remove("l-ui-scroll-lock")
128
- document.body.style.top = ""
129
- if (this.savedScrollY !== undefined) {
130
- window.scrollTo(0, this.savedScrollY)
131
- }
128
+ this.unlockScroll()
132
129
 
133
130
  storageSet("panelOpen", "false")
134
131
  this.updatePageMargin()
@@ -161,4 +158,18 @@ export default class extends Controller {
161
158
  page.style.marginRight = ""
162
159
  }
163
160
  }
161
+
162
+ lockScroll() {
163
+ if (this.isScrollLocked) return
164
+
165
+ lockBodyScroll()
166
+ this.isScrollLocked = true
167
+ }
168
+
169
+ unlockScroll() {
170
+ if (!this.isScrollLocked) return
171
+
172
+ unlockBodyScroll()
173
+ this.isScrollLocked = false
174
+ }
164
175
  }
@@ -0,0 +1,29 @@
1
+ let lockCount = 0
2
+ let savedScrollY = null
3
+
4
+ export function lockBodyScroll() {
5
+ if (lockCount === 0) {
6
+ savedScrollY = window.scrollY
7
+ document.body.style.top = `-${savedScrollY}px`
8
+ document.body.classList.add("l-ui-scroll-lock")
9
+ }
10
+
11
+ lockCount++
12
+ }
13
+
14
+ export function unlockBodyScroll() {
15
+ if (lockCount === 0) return
16
+
17
+ lockCount--
18
+
19
+ if (lockCount === 0) {
20
+ document.body.classList.remove("l-ui-scroll-lock")
21
+ document.body.style.top = ""
22
+
23
+ if (savedScrollY !== null) {
24
+ window.scrollTo(0, savedScrollY)
25
+ }
26
+
27
+ savedScrollY = null
28
+ }
29
+ }
@@ -10,7 +10,7 @@
10
10
  <% end %>
11
11
 
12
12
  <div class="l-ui-form__actions">
13
- <%= f.submit(record.new_record? ? "Create" : "Save changes",
13
+ <%= f.submit(record.new_record? ? "Create" : "Save",
14
14
  class: "l-ui-button l-ui-button--primary") %>
15
15
  </div>
16
16
  <% end %>
data/config/importmap.rb CHANGED
@@ -3,6 +3,7 @@ pin "layered_ui", to: "layered_ui/index.js"
3
3
  # Utilities
4
4
  pin "layered_ui/utilities/announce", to: "layered_ui/utilities/announce.js"
5
5
  pin "layered_ui/utilities/layout", to: "layered_ui/utilities/layout.js"
6
+ pin "layered_ui/utilities/scroll_lock", to: "layered_ui/utilities/scroll_lock.js"
6
7
  pin "layered_ui/utilities/storage", to: "layered_ui/utilities/storage.js"
7
8
 
8
9
  # Controllers
@@ -26,10 +26,11 @@ module Layered
26
26
  * This file is NOT overwritten by the install generator, so your
27
27
  * changes are preserved when you upgrade layered-ui-rails.
28
28
  *
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.
33
34
  */
34
35
 
35
36
  /* ----------------------------------------------------------------
@@ -46,13 +47,13 @@ module Layered
46
47
  * ---------------------------------------------------------------- */
47
48
 
48
49
  :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); */
51
52
  }
52
53
 
53
54
  .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); */
56
57
  }
57
58
 
58
59
  /* ----------------------------------------------------------------
@@ -63,49 +64,49 @@ module Layered
63
64
 
64
65
  /*
65
66
  :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);
74
75
  --button-primary-bg: var(--accent);
75
76
  --button-primary-text: var(--accent-foreground);
76
77
  --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);
86
87
  }
87
88
 
88
89
  .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);
97
98
  --button-primary-bg: var(--accent);
98
99
  --button-primary-text: var(--accent-foreground);
99
100
  --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);
109
110
  }
110
111
  */
111
112
 
@@ -31,6 +31,7 @@ module Layered
31
31
  helper Layered::Ui::NavigationHelper
32
32
  helper Layered::Ui::PagyHelper
33
33
  helper Layered::Ui::TableHelper
34
+ helper Layered::Ui::TitleBarHelper
34
35
  helper Layered::Ui::FormHelper
35
36
  helper Layered::Ui::RansackHelper
36
37
  end
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.7.0"
3
+ VERSION = "0.9.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-ui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -215,6 +215,7 @@ files:
215
215
  - app/helpers/layered/ui/pagy_helper.rb
216
216
  - app/helpers/layered/ui/ransack_helper.rb
217
217
  - app/helpers/layered/ui/table_helper.rb
218
+ - app/helpers/layered/ui/title_bar_helper.rb
218
219
  - app/javascript/layered_ui/controllers/l_ui/modal_controller.js
219
220
  - app/javascript/layered_ui/controllers/l_ui/navigation_controller.js
220
221
  - app/javascript/layered_ui/controllers/l_ui/panel_button_controller.js
@@ -226,6 +227,7 @@ files:
226
227
  - app/javascript/layered_ui/index.js
227
228
  - app/javascript/layered_ui/utilities/announce.js
228
229
  - app/javascript/layered_ui/utilities/layout.js
230
+ - app/javascript/layered_ui/utilities/scroll_lock.js
229
231
  - app/javascript/layered_ui/utilities/storage.js
230
232
  - app/views/devise/confirmations/new.html.erb
231
233
  - app/views/devise/mailer/confirmation_instructions.html.erb