layered-ui-rails 0.18.0 → 0.18.2

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: 45a2361df4b5409267f1619b3ba0de294c2e90a56e610492e9587dc40c9a79be
4
- data.tar.gz: e812a5155ed72c2d0c9fe6668a74215fd4600decf3326d08190a76936b43de2f
3
+ metadata.gz: b140e481d1660db5080801447a42917679da886a673473d2b6b53446e50c8d24
4
+ data.tar.gz: d838daaf32f4c0cbadffc12b599c6e6bfbcde149ab57296f826ac2ef6cf6423f
5
5
  SHA512:
6
- metadata.gz: ef9f4e14b361fcc2a6d3d4db9e4629a97533d24d479f3fba123daa58278d0a53be18c1cd3526ed4a70cf4ef9fd7cf65a243a303249bb8eb81bfa2c7c55efbb1e
7
- data.tar.gz: d1f369d793b03173d8ba0c44616b4452841f07391c846744738b426f5c6f7af87ffd85250ffabf1a1248f59da74947a4df32288c5c63c63232ea97f8efb63885
6
+ metadata.gz: c0eb45b0d7a15313afeefc7b4ce39002c22fdcf8de41cb438865543c3d48926e9e307de14b86609ced6e642557745dcebaa4b60adb002c3fa154c57d8f0b6535
7
+ data.tar.gz: d475623cf9c06a78c0ec1486ace51231a914e7e6751c96aeeaeb653b893375171a23b0170af8258b2255f0d80897556399dfdee027211008a9f6931e32cfe0eb
@@ -132,6 +132,8 @@ Populate layout regions with `content_for` (always above the render call):
132
132
  Body class modifiers:
133
133
  - `l-ui-body--always-show-navigation` - pins navigation as a sidebar on desktop
134
134
  - `l-ui-body--hide-header` - hides the header and collapses its space
135
+ - `l-ui-body--glass-header` - glass header (translucent + blur); content scrolls under it
136
+ - `l-ui-body--flush-top` - zeroes the page's top gutter so a hero sits flush at the top, behind the header (pair with `--glass-header` or `--hide-header`)
135
137
 
136
138
  ### Controller instance variables
137
139
 
@@ -153,7 +155,7 @@ Quick reference:
153
155
 
154
156
  | Helper | Purpose |
155
157
  |---|---|
156
- | `l_ui_navigation_item(label, path, ...)` | Sidebar nav link (supports `icon:`, `match: :starts_with`, `expandable:`) |
158
+ | `l_ui_navigation_item(label, path, ...)` | Sidebar nav link (supports `icon:`, `match: :starts_with`, `expandable:`). For valid `icon:` names and the missing-asset gotcha, see the "Icons" section in `references/HELPERS.md` |
157
159
  | `l_ui_navigation_section(heading = nil, ...)` | Group nav items; supports `collapsible:`, `storage_key:`, `separated:` |
158
160
  | `l_ui_breadcrumbs(&block)` | Breadcrumb nav wrapper |
159
161
  | `l_ui_breadcrumb_item(label, path = nil)` | Individual breadcrumb |
@@ -178,7 +180,7 @@ Key components:
178
180
 
179
181
  | Component | Key classes |
180
182
  |---|---|
181
- | Page layout | `.l-ui-page`, `--with-navigation`, `__vertically-centered`, `__narrow` (narrow ~384px column, md:max-w-sm) |
183
+ | Page layout | `.l-ui-page`, `--with-navigation`, `__vertically-centered`, `__narrow` (narrow ~384px column, md:max-w-sm), `__contained` (wide column capped at `--l-ui-contained-width`) |
182
184
  | Buttons | `.l-ui-button`, `--primary`, `--outline`, `--outline-danger`, `--full`, `--icon` |
183
185
  | Surfaces | `.l-ui-surface`, `--highlighted`, `--sm`, `--collapsible`, `--collapsible-highlighted` |
184
186
  | Forms | `.l-ui-form`, `.l-ui-form__group`, `.l-ui-form__field`, `.l-ui-label`, `.l-ui-select` |
@@ -24,7 +24,9 @@ Applied to `<body>` via the `:l_ui_body_class` yield to toggle layout-level beha
24
24
  ```
25
25
  .l-ui-body--always-show-navigation Pin sidebar navigation open on desktop
26
26
  .l-ui-body--hide-header Hide the header and collapse its reserved space
27
- .l-ui-body--header-contained Constrain the header's inner row to max-w-7xl (landing pages)
27
+ .l-ui-body--header-contained Constrain the header's inner row to --l-ui-contained-width (landing pages)
28
+ .l-ui-body--glass-header Glass header (translucent + backdrop blur); content scrolls under it
29
+ .l-ui-body--flush-top Zero the page's top gutter so the first section sits flush at the top, behind the header
28
30
  ```
29
31
 
30
32
  ## Page layout
@@ -34,12 +36,13 @@ Applied to `<body>` via the `:l_ui_body_class` yield to toggle layout-level beha
34
36
  .l-ui-page--with-navigation Left margin for sidebar on desktop
35
37
  .l-ui-page__vertically-centered Centred layout element (e.g. login pages)
36
38
  .l-ui-page__narrow Narrow column (md:max-w-sm, ~384px) for any compact, centred content
39
+ .l-ui-page__contained Centred column capped at --l-ui-contained-width; aligns with the contained header
37
40
  .l-ui-bleed Breaks a child out to the page edge (full-bleed hero etc.)
38
41
  ```
39
42
 
40
- `.l-ui-page` (and `--with-navigation`) is applied by the engine layout around your view's `yield` - you do not add it yourself. Wrapping your view content in another `.l-ui-page` nests two containers. The `__vertically-centered` and `__narrow` elements are used *inside* views for centred, compact content (the Devise auth pages are one example, but they are general-purpose). `__narrow` caps width at `md:max-w-sm` (~384px). For a wider content area, add your own max-width wrapper in the host app - `.l-ui-page` already holds content to the available width.
43
+ `.l-ui-page` (and `--with-navigation`) is applied by the engine layout around your view's `yield` - you do not add it yourself. Wrapping your view content in another `.l-ui-page` nests two containers. The `__vertically-centered` and `__narrow` elements are used *inside* views for centred, compact content (the Devise auth pages are one example, but they are general-purpose). `__narrow` caps width at `md:max-w-sm` (~384px). For a wider content area, wrap your content in `.l-ui-page__contained`: it caps width at the `--l-ui-contained-width` token (default `80rem`) and centres with `mx-auto`. Because the contained header (`.l-ui-body--header-contained`) reads the same token, the two line up automatically - override `--l-ui-contained-width` once to move both. Left to itself, `.l-ui-page` holds content to the full available width.
41
44
 
42
- The page gutter (horizontal and bottom padding) is the `--l-ui-gutter` custom property (default `1rem`), shared by `.l-ui-page` and `.l-ui-header` so they always align. Override it on a container to change the gutter in one place rather than reverse-engineering padding values. To take a single child edge-to-edge (a full-bleed hero, a banner), add `.l-ui-bleed` to it - it cancels exactly the current gutter, so no negative-margin guesswork. Note `.l-ui-bleed` only handles the horizontal edges; content still sits below the page's top padding (header offset + gutter).
45
+ The page gutter (horizontal and bottom padding) is the `--l-ui-gutter` custom property (default `1rem`), shared by `.l-ui-page` and `.l-ui-header` so they always align. Override it on a container to change the gutter in one place rather than reverse-engineering padding values. To take a single child edge-to-edge (a full-bleed hero, a banner), add `.l-ui-bleed` to it - it cancels exactly the current gutter, so no negative-margin guesswork. Note `.l-ui-bleed` only handles the horizontal edges; content still sits below the page's top padding (header offset + gutter). To take the *top* edge too - a hero that starts at the very top of the viewport, behind the header - add `.l-ui-body--flush-top`, which zeroes the page's top padding. On its own over the opaque header that would hide content, so pair it with `.l-ui-body--glass-header` (the header turns translucent and content shows through the blur) or `.l-ui-body--hide-header`. Give the hero its own internal top padding (at least `--header-height`) so its content clears the floating header.
43
46
 
44
47
  `.l-ui-page` is a flex column (`flex flex-1 flex-col`) and uses `overflow-x-clip` to hold its content to the available width: intrinsically wide content (tables, code blocks, long unbroken strings) is clipped rather than expanding the page or adding a horizontal page scrollbar. So make such content scroll internally (e.g. wrap a table in an `overflow-x-auto` element) instead of expecting the page to grow.
45
48
 
@@ -60,7 +63,8 @@ Always combine the `l-ui-button` base class with a colour modifier (e.g. `l-ui-b
60
63
  Icon variants (combine with the `l-ui-button` base):
61
64
 
62
65
  ```
63
- .l-ui-button--icon Icon-only button (fixed size, no text)
66
+ .l-ui-button--icon Icon-only button (44px square, no text)
67
+ Add --small (l-ui-button--icon l-ui-button--small) for a 32px square icon button
64
68
  .l-ui-button--navigation-toggle Mobile navigation toggle
65
69
  ```
66
70
 
@@ -391,6 +395,8 @@ Tier 2 - Full palette (override individually as needed):
391
395
  --error-bg Error background
392
396
  --error-text Error text
393
397
  --header-height Header height (default 63px)
398
+ --l-ui-gutter Page/header horizontal + bottom padding (default 1rem)
399
+ --l-ui-contained-width Max width of contained content - header + .l-ui-page__contained (default 80rem)
394
400
  ```
395
401
 
396
402
  Override --button-primary-text when your accent color needs a different text/icon color on buttons (e.g. a pink accent with white button text in dark mode). Override --button-primary-icon instead when only the icon color should change.
@@ -14,13 +14,13 @@ l_ui_navigation_section(heading = nil, icon: nil, icon_path: nil, icon_html: nil
14
14
  - `path` (String) - URL
15
15
  - `active` (Boolean, optional) - force the active style; defaults to a match against the current request path. Does not affect `aria-current`, which is set only when `current_page?(path)` is true
16
16
  - `match` (Symbol) - `:exact` (default) uses `current_page?`; `:starts_with` activates when the request path begins with `path` (use for parents whose sub-routes should keep the parent highlighted)
17
- - `icon` (String, optional) - icon name; `"home"` ships with the gem. For others, supply the SVG in the host app at `app/assets/images/layered_ui/icon_NAME.svg`
17
+ - `icon` (String, optional) - icon name. Resolves to the asset `layered_ui/icon_NAME.svg`. See "Icons" below for the names that ship with the gem and how to add your own. A name with no matching asset raises `Propshaft::MissingAssetError` (a 500 in dev) when the page renders, so only pass a name you know resolves
18
18
  - `icon_path` (String, optional) - explicit asset path; takes precedence over `icon:`
19
19
  - `icon_html` (ActiveSupport::SafeBuffer, optional) - pre-rendered icon markup for icon-font libraries (e.g. `tag.i(class: "fa-solid fa-house")`); takes precedence over `icon:` and `icon_path:`. Must be already html-safe - plain strings will be escaped. Never pass user-controlled input
20
20
 
21
21
  `l_ui_navigation_section`:
22
22
  - `heading` (String, optional) - non-clickable section heading; omit for an unlabelled group. To expose the parent route of a section, add an "Overview" item inside the block
23
- - `icon` (String, optional) - host-app icon name next to the heading; resolves the same way as `l_ui_navigation_item`'s `icon:`
23
+ - `icon` (String, optional) - icon name next to the heading; resolves the same way as `l_ui_navigation_item`'s `icon:` (see "Icons" below)
24
24
  - `icon_path` (String, optional) - explicit asset path next to the heading; takes precedence over `icon:`
25
25
  - `icon_html` (String, optional) - pre-rendered icon markup; takes precedence over `icon:` and `icon_path:`
26
26
  - `collapsible` (Boolean) - when `true` and a heading is given, renders a toggle button with a chevron and wires `aria-controls` to the panel
@@ -43,6 +43,20 @@ l_ui_navigation_section(heading = nil, icon: nil, icon_path: nil, icon_html: nil
43
43
  <% end %>
44
44
  ```
45
45
 
46
+ ### Icons
47
+
48
+ The `icon:` argument (on `l_ui_navigation_item` and `l_ui_navigation_section`) resolves to the asset `layered_ui/icon_NAME.svg`. There is no fallback: a name with no matching asset raises `Propshaft::MissingAssetError` and renders a 500. Only pass a name you know resolves.
49
+
50
+ Names that ship with the gem (use the bare name, e.g. `icon: "globe"`):
51
+
52
+ - General-purpose (black `currentColor` sources, recoloured for dark mode by `dark:invert` - safe to use via `icon:`): `home`, `globe`, `mail`, `github`, `discord`, `linkedin`, `x`, `youtube`
53
+ - Internal UI (engine chrome - theme toggle, nav chevrons, close buttons): `close`, `chevron_down`, `chevron_right`, `hamburger`, `sun`, `moon`, `light`, `dark`, `panel_close`. Several are CSS mask shapes or carry baked theme colours (e.g. `chevron_down` is white, `dark` fills white), so they will **not** render correctly through `icon:`. Don't use these for nav icons
54
+
55
+ To add your own, drop an SVG in the host app at `app/assets/images/layered_ui/icon_NAME.svg` and pass `icon: "NAME"`. Alternatively:
56
+
57
+ - `icon_path:` - point at any asset path directly (e.g. `icon_path: "my_icons/star.svg"`), bypassing the `layered_ui/icon_` convention
58
+ - `icon_html:` - pass pre-rendered, html-safe markup for icon-font libraries (e.g. `tag.i(class: "fa-solid fa-house")`); never pass user-controlled input
59
+
46
60
  ## Breadcrumbs
47
61
 
48
62
  ```ruby
data/AGENTS.md CHANGED
@@ -27,6 +27,7 @@ Guidance for AI agents working in this repository.
27
27
  - Use l-ui classes only in engine views, with no additional Tailwind utilities, as Tailwind classes referenced only inside the engine will not be generated by the host app’s build
28
28
  - Dummy app documentation pages may use additional Tailwind utilities, but should favour l-ui classes where possible
29
29
  - Importmap for JS (no bundler)
30
+ - Put new changes on a branch with a meaningful name, but do not commit; the user will ask when ready to commit
30
31
  - We are currently in pre-release, so breaking changes may be introduced. Flag any expected breaking changes clearly before making them, but do not avoid necessary improvements solely to preserve backwards compatibility
31
32
  - CRITICAL: Retain WCAG 2.2 AA compliance. Do not be too pedantic though as this introduces audit loops which are undesirable.
32
33
  - A WCAG 2.2 AA table looks like this:
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
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.18.2] - 2026-06-08
6
+
7
+ ### Added
8
+
9
+ - `--l-ui-contained-width` token (default `80rem`) and `l-ui-page__contained`, a centred content column capped at that width. The contained header (`l-ui-body--header-contained`) now reads the same token, so header and page content align and can be moved together with a single override.
10
+ - `l-ui-body--glass-header`: glass header (translucent background plus backdrop blur, dropping the solid bottom border) so page content scrolls beneath it.
11
+ - `l-ui-body--flush-top`: zeroes the page's top padding so the first section sits flush at the top of the viewport, behind the header. Pair with `l-ui-body--glass-header` (content shows through the frosted header) or `l-ui-body--hide-header` for full-bleed heroes.
12
+
13
+ ## [0.18.1] - 2026-06-07
14
+
15
+ ### Added
16
+
17
+ - Small icon buttons: combining `l-ui-button--icon` with `l-ui-button--small` now renders a 32px square (still above the WCAG 2.2 AA 24px target-size minimum) instead of stretching to 44px wide, since `--small` previously only shrank the height. The panel close button now uses this variant.
18
+ - Documented the icons bundled with the gem and clarified `icon:` usage: the name resolves to `layered_ui/icon_NAME.svg`, and an unknown name raises `Propshaft::MissingAssetError`. Added an "Available icons" section to the dummy app showing the general-purpose set (internal-UI chrome icons are not usable via `icon:`).
19
+
20
+ ### Changed
21
+
22
+ - Navigation sub-sections now animate open by transitioning height from 0 to their natural height, replacing the per-item slide keyframe animation. Respects `prefers-reduced-motion`.
23
+
24
+ ### Fixed
25
+
26
+ - Navigation section chevron no longer animates when restoring its saved open/closed state on page load; it now renders in position.
27
+
5
28
  ## [0.18.0] - 2026-06-07
6
29
 
7
30
  ### Fixed
@@ -81,6 +81,7 @@
81
81
  --error-text: oklch(0.2248 0.0874 28.11);
82
82
  --header-height: 63px;
83
83
  --l-ui-gutter: 1rem;
84
+ --l-ui-contained-width: 80rem;
84
85
  }
85
86
 
86
87
  .dark {
@@ -466,6 +467,13 @@
466
467
  @apply md:ml-[256px];
467
468
  }
468
469
 
470
+ /* Zero the page's top gutter so the first section sits flush at the viewport top, behind the
471
+ header. Intended with .l-ui-body--glass-header (content shows through the glass header) or
472
+ .l-ui-body--hide-header; over an opaque header it would tuck content out of sight. */
473
+ .l-ui-body--flush-top .l-ui-page {
474
+ @apply pt-0;
475
+ }
476
+
469
477
  .l-ui-page__vertically-centered {
470
478
  @apply flex flex-1 flex-col items-center justify-center;
471
479
  }
@@ -477,6 +485,12 @@
477
485
  my-auto;
478
486
  }
479
487
 
488
+ /* Centred content column capped at --l-ui-contained-width; aligns with the contained header. */
489
+ .l-ui-page__contained {
490
+ @apply w-full max-w-[var(--l-ui-contained-width)]
491
+ mx-auto;
492
+ }
493
+
480
494
  /* Header */
481
495
 
482
496
  .l-ui-header-container {
@@ -490,6 +504,12 @@
490
504
  @apply hidden;
491
505
  }
492
506
 
507
+ .l-ui-body--glass-header .l-ui-header-container {
508
+ @apply bg-background/60
509
+ border-b-transparent
510
+ backdrop-blur-xl;
511
+ }
512
+
493
513
  .l-ui-header {
494
514
  @apply flex items-center justify-between
495
515
  h-[var(--header-height)]
@@ -497,7 +517,7 @@
497
517
  }
498
518
 
499
519
  .l-ui-body--header-contained .l-ui-header {
500
- @apply w-full max-w-7xl
520
+ @apply w-full max-w-[var(--l-ui-contained-width)]
501
521
  mx-auto;
502
522
  }
503
523
 
@@ -747,8 +767,16 @@
747
767
  mask-size: contain;
748
768
  }
749
769
 
770
+ /* transition-none is intentional: the after-change style determines the
771
+ transition, so this gives "animate open, snap shut" - opening matches the
772
+ base rule (animates), closing matches this rule (snaps instantly). */
750
773
  .l-ui-navigation__section-toggle[aria-expanded="false"] .l-ui-navigation__section-chevron {
751
- @apply -rotate-90;
774
+ @apply -rotate-90
775
+ transition-none;
776
+ }
777
+
778
+ .l-ui-navigation__section-toggle--no-transition .l-ui-navigation__section-chevron {
779
+ @apply transition-none;
752
780
  }
753
781
 
754
782
  .l-ui-navigation__section-items {
@@ -771,6 +799,19 @@
771
799
  gap-0.5 px-3 py-3;
772
800
  }
773
801
 
802
+ /* Height-expand slide applied while the section is opening; the controller
803
+ measures the natural height and transitions from 0 to reveal the items. */
804
+ .l-ui-navigation__section-items--opening {
805
+ @apply overflow-hidden
806
+ transition-[height] duration-200 ease-out;
807
+ }
808
+
809
+ @media (prefers-reduced-motion: reduce) {
810
+ .l-ui-navigation__section-items--opening {
811
+ @apply transition-none;
812
+ }
813
+ }
814
+
774
815
  .l-ui-navigation__user {
775
816
  @apply shrink-0
776
817
  p-4;
@@ -897,6 +938,11 @@
897
938
  text-xs;
898
939
  }
899
940
 
941
+ .l-ui-button--icon.l-ui-button--small {
942
+ @apply min-w-[32px]
943
+ p-1.5;
944
+ }
945
+
900
946
  .l-ui-button:disabled {
901
947
  @apply opacity-50
902
948
  cursor-not-allowed;
@@ -21,12 +21,21 @@ export default class extends Controller {
21
21
  if (stored === null) return
22
22
 
23
23
  const isOpen = stored === "true"
24
- this.toggleTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
24
+ // Apply the restored state without animating the chevron on load; it should
25
+ // simply render in the correct position.
26
+ const toggle = this.toggleTarget
27
+ toggle.classList.add("l-ui-navigation__section-toggle--no-transition")
28
+ toggle.setAttribute("aria-expanded", isOpen ? "true" : "false")
25
29
  if (isOpen) {
26
30
  this.panelTarget.removeAttribute("hidden")
27
31
  } else {
28
32
  this.panelTarget.setAttribute("hidden", "")
29
33
  }
34
+ requestAnimationFrame(() => {
35
+ requestAnimationFrame(() => {
36
+ toggle.classList.remove("l-ui-navigation__section-toggle--no-transition")
37
+ })
38
+ })
30
39
  }
31
40
 
32
41
  toggle() {
@@ -34,6 +43,7 @@ export default class extends Controller {
34
43
  this.toggleTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
35
44
  if (isOpen) {
36
45
  this.panelTarget.removeAttribute("hidden")
46
+ this.#animateOpen()
37
47
  } else {
38
48
  this.panelTarget.setAttribute("hidden", "")
39
49
  }
@@ -45,4 +55,25 @@ export default class extends Controller {
45
55
  // ignore
46
56
  }
47
57
  }
58
+
59
+ // Slide the panel open by transitioning its height from 0 to its natural
60
+ // height, only on user-initiated open, not on initial page load.
61
+ #animateOpen() {
62
+ const panel = this.panelTarget
63
+ const CLASS = "l-ui-navigation__section-items--opening"
64
+
65
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
66
+
67
+ const endHeight = panel.scrollHeight
68
+ panel.classList.add(CLASS)
69
+ panel.style.height = "0px"
70
+ panel.offsetHeight // Force a reflow so the starting height is applied.
71
+ panel.style.height = `${endHeight}px`
72
+
73
+ panel.addEventListener("transitionend", (event) => {
74
+ if (event.propertyName !== "height") return
75
+ panel.classList.remove(CLASS)
76
+ panel.style.height = ""
77
+ }, { once: true })
78
+ }
48
79
  }
@@ -35,7 +35,7 @@
35
35
 
36
36
  <div class="l-ui-panel__header-actions">
37
37
  <button type="button"
38
- class="l-ui-button l-ui-button--primary l-ui-button--icon"
38
+ class="l-ui-button l-ui-button--primary l-ui-button--icon l-ui-button--small"
39
39
  aria-label="Hide panel"
40
40
  aria-controls="panel"
41
41
  title="Toggle panel (Ctrl+i / ⌘i)"
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.18.0"
3
+ VERSION = "0.18.2"
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.18.0
4
+ version: 0.18.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai