nitro_kit 2.0.0.alpha.6 → 2.0.0.beta.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/README.md +16 -2
  4. data/STYLE_GUIDE.md +3 -1
  5. data/app/assets/stylesheets/nitro_kit.css +519 -35
  6. data/app/components/nitro_kit/app_shell.rb +41 -5
  7. data/app/components/nitro_kit/avatar.rb +21 -2
  8. data/app/components/nitro_kit/card.rb +28 -1
  9. data/app/javascript/controllers/nk/app_shell_controller.js +64 -2
  10. data/config/locales/en.yml +1 -0
  11. data/docs/agent_native_spec.md +5 -4
  12. data/docs/component_contracts.md +93 -5
  13. data/docs/customization.md +2 -2
  14. data/docs/eject.md +86 -0
  15. data/docs/migration_1_to_2.md +1 -1
  16. data/docs/patterns/application_foundation.md +22 -0
  17. data/docs/patterns/inset_workspace.md +9 -1
  18. data/docs/rails_integration.md +1 -1
  19. data/lib/generators/nitro_kit/eject_generator.rb +26 -0
  20. data/lib/nitro_kit/ejection.rb +136 -0
  21. data/lib/nitro_kit/version.rb +1 -1
  22. data/src/stylesheets/nitro_kit/components/app_navigation.css +4 -1
  23. data/src/stylesheets/nitro_kit/components/app_shell.css +209 -0
  24. data/src/stylesheets/nitro_kit/components/avatar.css +10 -0
  25. data/src/stylesheets/nitro_kit/components/avatar_stack.css +9 -5
  26. data/src/stylesheets/nitro_kit/components/card.css +206 -15
  27. data/src/stylesheets/nitro_kit/components/danger_zone.css +1 -1
  28. data/src/stylesheets/nitro_kit/components/dropzone.css +15 -0
  29. data/src/stylesheets/nitro_kit/components/empty_state.css +3 -3
  30. data/src/stylesheets/nitro_kit/components/field_group.css +6 -2
  31. data/src/stylesheets/nitro_kit/components/pagination_bar.css +3 -0
  32. data/src/stylesheets/nitro_kit/components/progressive_image.css +2 -0
  33. data/src/stylesheets/nitro_kit/components/settings_layout.css +2 -0
  34. data/src/stylesheets/nitro_kit/components/stat_grid.css +4 -4
  35. data/src/stylesheets/nitro_kit/components/toolbar.css +12 -0
  36. data/src/stylesheets/nitro_kit/components/tooltip.css +15 -1
  37. data/src/stylesheets/nitro_kit/components/typeset.css +4 -0
  38. data/src/stylesheets/nitro_kit/reset.css +11 -1
  39. data/src/stylesheets/nitro_kit/tokens.css +3 -2
  40. metadata +5 -2
@@ -5,6 +5,7 @@ module NitroKit
5
5
  alias_method :html_main, :main
6
6
 
7
7
  LAYOUTS = %i[sidebar topbar].freeze
8
+ SIDEBAR_STATES = %i[expanded collapsed].freeze
8
9
  REGIONS = %i[brand navigation topbar main].freeze
9
10
  REQUIRED_REGIONS = %i[navigation main].freeze
10
11
  private_constant :REQUIRED_REGIONS
@@ -12,6 +13,9 @@ module NitroKit
12
13
  def initialize(
13
14
  id:,
14
15
  layout: :sidebar,
16
+ collapsible: false,
17
+ sidebar: :expanded,
18
+ sidebar_toggle_label: I18n.t("nitro_kit.app_shell.pin_sidebar"),
15
19
  skip_link_label: I18n.t("nitro_kit.app_shell.skip_link"),
16
20
  open_navigation_label: I18n.t("nitro_kit.app_shell.open_navigation"),
17
21
  close_navigation_label: I18n.t("nitro_kit.app_shell.close_navigation"),
@@ -23,6 +27,12 @@ module NitroKit
23
27
  )
24
28
  @identifier = validate_id!("AppShell id", id)
25
29
  @layout = validate_choice!(:layout, layout, LAYOUTS)
30
+ @collapsible = validate_boolean!(:collapsible, collapsible)
31
+ @sidebar = validate_choice!(:sidebar, sidebar, SIDEBAR_STATES)
32
+ if @sidebar == :collapsed && !@collapsible
33
+ raise ArgumentError, "AppShell sidebar: :collapsed requires collapsible: true"
34
+ end
35
+ @sidebar_toggle_label = validate_label!(:sidebar_toggle_label, sidebar_toggle_label)
26
36
  @skip_link_label = validate_label!(:skip_link_label, skip_link_label)
27
37
  @open_navigation_label = validate_label!(:open_navigation_label, open_navigation_label)
28
38
  @close_navigation_label = validate_label!(:close_navigation_label, close_navigation_label)
@@ -39,9 +49,12 @@ module NitroKit
39
49
  layout: @layout,
40
50
  state: "closed",
41
51
  action: "turbo:before-visit@document->nk--app-shell#closeForNavigation " \
52
+ "turbo:before-morph-attribute->nk--app-shell#preserveSidebarState " \
42
53
  "turbo:morph@document->nk--app-shell#syncViewport",
43
54
  nk__app_shell_open_label_value: @open_navigation_label,
44
- nk__app_shell_close_label_value: @close_navigation_label
55
+ nk__app_shell_close_label_value: @close_navigation_label,
56
+ nk__app_shell_collapsible_value: @collapsible.to_s,
57
+ nk__app_shell_pinned_value: (@sidebar == :expanded).to_s
45
58
  }
46
59
  },
47
60
  html:,
@@ -65,8 +78,9 @@ module NitroKit
65
78
  end
66
79
  end
67
80
 
68
- def brand(&content)
81
+ def brand(icon: nil, &content)
69
82
  add_region(:brand, content:)
83
+ @brand_icon = Icon.new(icon, size: :sm) unless icon.nil?
70
84
  end
71
85
 
72
86
  def navigation(&content)
@@ -124,7 +138,12 @@ module NitroKit
124
138
 
125
139
  def render_header
126
140
  header(**slot_attributes(:header)) do
127
- div(**slot_attributes(:brand)) { render(region(:brand)) } if region(:brand)
141
+ if region(:brand)
142
+ div(**slot_attributes(:brand, attributes: { data: { action: @collapsible ? "pointerleave->nk--app-shell#resumeHover" : nil } })) do
143
+ render_in_slot(@brand_icon, :brand_icon) if @brand_icon
144
+ div(**slot_attributes(:brand_content)) { render(region(:brand)) }
145
+ end
146
+ end
128
147
  render_mobile_trigger
129
148
  div(**slot_attributes(:topbar)) { render(region(:topbar)) } if region(:topbar)
130
149
  end
@@ -155,19 +174,32 @@ module NitroKit
155
174
  **slot_attributes(
156
175
  :sidebar,
157
176
  attributes: {
158
- data: { nk__app_shell_target: "sidebar" }
177
+ data: {
178
+ nk__app_shell_target: "sidebar",
179
+ action: @collapsible ? "pointerleave->nk--app-shell#resumeHover" : nil
180
+ }
159
181
  }
160
182
  )
161
183
  ) do
162
184
  div(
163
185
  **slot_attributes(
164
186
  :navigation,
165
- attributes: { data: { nk__app_shell_target: "navigation" } }
187
+ attributes: { id: navigation_region_id, data: { nk__app_shell_target: "navigation" } }
166
188
  )
167
189
  ) { render(region(:navigation)) }
190
+ render_sidebar_toggle if layout == :sidebar && @collapsible
168
191
  end
169
192
  end
170
193
 
194
+ def render_sidebar_toggle
195
+ render_in_slot(Button.new(
196
+ icon: :panel_left,
197
+ label: @sidebar_toggle_label,
198
+ aria: { controls: navigation_region_id, pressed: @sidebar == :expanded },
199
+ data: { nk__app_shell_target: "pin", action: "click->nk--app-shell#togglePin" }
200
+ ), :sidebar_toggle)
201
+ end
202
+
171
203
  def render_dialog
172
204
  dialog(
173
205
  **slot_attributes(
@@ -204,6 +236,10 @@ module NitroKit
204
236
  html_main(**slot_attributes(:main, attributes: { id: main_id, tabindex: -1 })) { render(region(:main)) }
205
237
  end
206
238
 
239
+ def navigation_region_id
240
+ "#{identifier}-navigation-region"
241
+ end
242
+
207
243
  def drawer_id
208
244
  "#{identifier}-navigation-drawer"
209
245
  end
@@ -3,6 +3,9 @@
3
3
  module NitroKit
4
4
  class Avatar < Component
5
5
  SIZES = %i[xs sm md lg].freeze
6
+ # Derived initials never exceed two characters; explicit fallbacks may run
7
+ # to four, which the stylesheet steps down so they stay inside the circle.
8
+ MAX_FALLBACK_LENGTH = 4
6
9
 
7
10
  def initialize(
8
11
  src: nil,
@@ -30,6 +33,7 @@ module NitroKit
30
33
  raise ArgumentError, "Avatar images require alt: text unless decorative: true"
31
34
  end
32
35
  @fallback = fallback || initials_for(alt)
36
+ validate_fallback!(@fallback) unless fallback.nil?
33
37
  @size = validate_choice!(:size, size, SIZES)
34
38
  root_aria = fallback_aria(aria)
35
39
 
@@ -61,7 +65,7 @@ module NitroKit
61
65
  span(
62
66
  **slot_attributes(
63
67
  :fallback,
64
- attributes: src? ? { data: { nk__avatar_target: "fallback" } } : {},
68
+ attributes: { data: { nk__avatar_target: src? ? "fallback" : nil, length: fallback_length }.compact },
65
69
  aria: { hidden: (src? || !alt.empty?) ? true : nil }
66
70
  )
67
71
  ) { fallback }
@@ -97,7 +101,22 @@ module NitroKit
97
101
  label_key ? aria : { label: alt }.merge(aria)
98
102
  end
99
103
 
100
- def initials_for(name)
104
+ def validate_fallback!(value)
105
+ if value.strip.empty?
106
+ raise ArgumentError, "fallback must not be blank"
107
+ end
108
+ if value.grapheme_clusters.size > MAX_FALLBACK_LENGTH
109
+ raise ArgumentError, "fallback must be at most #{MAX_FALLBACK_LENGTH} characters; an avatar shows initials, not a word"
110
+ end
111
+ end
112
+
113
+ # Only fallbacks longer than derived initials need the stylesheet to react.
114
+ def fallback_length
115
+ length = fallback.grapheme_clusters.size
116
+ length if length > 2
117
+ end
118
+
119
+ def initials_for(name)
101
120
  words = name.strip.split
102
121
  return "?" if words.empty?
103
122
 
@@ -3,14 +3,21 @@
3
3
  module NitroKit
4
4
  class Card < Component
5
5
  TITLE_LEVELS = (1..6).freeze
6
+ SIZES = %i[sm md lg].freeze
7
+ VARIANTS = %i[default outline muted].freeze
8
+
9
+ def initialize(size: :md, variant: :default, id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
10
+ size = validate_choice!(:size, size, SIZES)
11
+ variant = validate_choice!(:variant, variant, VARIANTS)
6
12
 
7
- def initialize(id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
8
13
  super(
9
14
  component: :card,
10
15
  attributes: { id: }.compact,
11
16
  html:,
12
17
  aria:,
13
18
  data:,
19
+ size:,
20
+ variant:,
14
21
  desperately_need_a_class:
15
22
  )
16
23
  end
@@ -23,8 +30,15 @@ module NitroKit
23
30
 
24
31
  alias :html_title :title
25
32
  alias :html_body :body
33
+ alias :html_header :header
26
34
  alias :html_footer :footer
27
35
 
36
+ def header(html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
37
+ raise ArgumentError, "Card header requires a block" unless block_given?
38
+
39
+ html_header(**slot_attributes(:header, html:, aria:, data:, desperately_need_a_class:)) { yield }
40
+ end
41
+
28
42
  def title(text = nil, level: 2, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
29
43
  validate_choice!(:level, level, TITLE_LEVELS)
30
44
  require_region!(:title, text, block)
@@ -34,6 +48,19 @@ module NitroKit
34
48
  ) { text_or_block(text, &block) }
35
49
  end
36
50
 
51
+ def description(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
52
+ require_region!(:description, text, block)
53
+ p(**slot_attributes(:description, html:, aria:, data:, desperately_need_a_class:)) do
54
+ text_or_block(text, &block)
55
+ end
56
+ end
57
+
58
+ def actions(html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
59
+ raise ArgumentError, "Card actions requires a block" unless block_given?
60
+
61
+ div(**slot_attributes(:actions, html:, aria:, data:, desperately_need_a_class:)) { yield }
62
+ end
63
+
37
64
  def body(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
38
65
  require_region!(:body, text, block)
39
66
  div(**slot_attributes(:body, html:, aria:, data:, desperately_need_a_class:)) do
@@ -3,8 +3,14 @@ import { Controller } from "@hotwired/stimulus";
3
3
  const narrowViewport = "(width < 48rem)";
4
4
 
5
5
  export default class extends Controller {
6
- static targets = ["dialog", "navigation", "sidebar", "trigger"];
7
- static values = { openLabel: String, closeLabel: String };
6
+ static targets = ["dialog", "navigation", "sidebar", "trigger", "pin"];
7
+ static values = {
8
+ openLabel: String,
9
+ closeLabel: String,
10
+ collapsible: { type: Boolean, default: false },
11
+ pinned: { type: Boolean, default: true },
12
+ hoverSuppressed: { type: Boolean, default: false },
13
+ };
8
14
 
9
15
  connect() {
10
16
  this.onViewportChange = this.syncViewport.bind(this);
@@ -15,6 +21,7 @@ export default class extends Controller {
15
21
 
16
22
  disconnect() {
17
23
  this.viewport?.removeEventListener("change", this.onViewportChange);
24
+ this.hoverSuppressedValue = false;
18
25
  this.restoreFocusAfterClose = false;
19
26
 
20
27
  const dialog = this.element.querySelector(
@@ -49,6 +56,40 @@ export default class extends Controller {
49
56
  this.dialogTarget.open ? this.closeDialog() : this.open();
50
57
  }
51
58
 
59
+ togglePin(event) {
60
+ if (
61
+ !this.collapsibleValue ||
62
+ this.isNarrow ||
63
+ this.element.dataset.layout !== "sidebar"
64
+ )
65
+ return;
66
+
67
+ this.hoverSuppressedValue = this.pinnedValue && event.detail > 0;
68
+ this.pinnedValue = !this.pinnedValue;
69
+ }
70
+
71
+ resumeHover(event) {
72
+ const brand = this.element.querySelector(
73
+ ':scope > [data-slot="app-shell-header"] > [data-slot="app-shell-brand"]',
74
+ );
75
+ if (
76
+ this.sidebarTarget.contains(event.relatedTarget) ||
77
+ brand?.contains(event.relatedTarget)
78
+ )
79
+ return;
80
+
81
+ this.hoverSuppressedValue = false;
82
+ }
83
+
84
+ pinnedValueChanged() {
85
+ if (this.hasPinTarget)
86
+ this.pinTarget.setAttribute("aria-pressed", String(this.pinnedValue));
87
+ }
88
+
89
+ pinTargetConnected(pin) {
90
+ pin.setAttribute("aria-pressed", String(this.pinnedValue));
91
+ }
92
+
52
93
  open() {
53
94
  if (!this.isNarrow || this.dialogTarget.open) return;
54
95
 
@@ -78,9 +119,29 @@ export default class extends Controller {
78
119
  }
79
120
 
80
121
  closeForNavigation() {
122
+ this.hoverSuppressedValue = false;
81
123
  this.closeDialog({ restoreFocus: false });
82
124
  }
83
125
 
126
+ preserveSidebarState(event) {
127
+ if (!this.collapsibleValue || this.element.dataset.layout !== "sidebar")
128
+ return;
129
+
130
+ const attribute = event.detail.attributeName;
131
+ const sidebarState =
132
+ event.target === this.element &&
133
+ [
134
+ "data-nk--app-shell-pinned-value",
135
+ "data-nk--app-shell-hover-suppressed-value",
136
+ ].includes(attribute);
137
+ const pinState =
138
+ this.hasPinTarget &&
139
+ event.target === this.pinTarget &&
140
+ attribute === "aria-pressed";
141
+
142
+ if (sidebarState || pinState) event.preventDefault();
143
+ }
144
+
84
145
  dialogClosed() {
85
146
  this.finishClosing();
86
147
  }
@@ -101,6 +162,7 @@ export default class extends Controller {
101
162
 
102
163
  const enteringNarrow = this.wasNarrow === false && this.isNarrow;
103
164
  this.wasNarrow = this.isNarrow;
165
+ if (this.isNarrow) this.hoverSuppressedValue = false;
104
166
 
105
167
  if (!this.isNarrow) {
106
168
  this.closeDialog({ restoreFocus: false });
@@ -5,6 +5,7 @@ en:
5
5
  open_navigation: "Open navigation"
6
6
  close_navigation: "Close navigation"
7
7
  navigation_dialog: "Application navigation"
8
+ pin_sidebar: "Pin sidebar"
8
9
  appearance_picker:
9
10
  label: "Appearance"
10
11
  preferences:
@@ -25,7 +25,8 @@ Applications own:
25
25
  - application CSS and documented token overrides.
26
26
 
27
27
  Components load from the gem. Generators may install application guidance and
28
- integration files, but never copies of component source.
28
+ integration files. The explicit, user-requested [eject generator](eject.md) is
29
+ the sole opt-out from gem ownership, one component at a time, not copy-install.
29
30
 
30
31
  ## Public API principles
31
32
 
@@ -52,8 +53,8 @@ end
52
53
  component contracts define the current set.
53
54
  - Components reject `class:` and `style:`. The audited
54
55
  `desperately_need_a_class:` escape exists only for external integrations.
55
- - There are no `nk_*` helpers, generated variant helpers, copied components,
56
- or general ERB bridge.
56
+ - There are no `nk_*` helpers, generated variant helpers, copy-installed
57
+ components, or general ERB bridge.
57
58
 
58
59
  Rails helpers remain first-class for forms, routes, DOM IDs, translations,
59
60
  assets, Active Storage, Turbo Frames, and Turbo Streams.
@@ -105,7 +106,7 @@ hold delivery history; this document records only durable architecture.
105
106
 
106
107
  ## Outside the current architecture
107
108
 
108
- Nitro Kit does not provide generated component copies, a generic utility DSL,
109
+ Nitro Kit does not provide default copy-install, a generic utility DSL,
109
110
  arbitrary breakpoints, a public component registry, an MCP server, or a
110
111
  JavaScript custom-element runtime. New abstractions require demonstrated reuse
111
112
  and an explicit public contract.
@@ -4,7 +4,7 @@
4
4
  API. This table is also parsed by the gallery; keep every row to four cells and
5
5
  escape literal pipe characters.
6
6
 
7
- This is the shipped public catalog for `2.0.0.alpha.6`. It records current Ruby
7
+ This is the shipped public catalog for `2.0.0.beta.2`. It records current Ruby
8
8
  construction, rendered roots, closed vocabularies, compound cardinalities, and
9
9
  integration boundaries. For task guidance, start with the
10
10
  [agent guide](agent_guide.md) or [Rails integration](rails_integration.md).
@@ -37,7 +37,7 @@ Every default string a person can read or hear comes from the engine-loaded `nit
37
37
  | `xs sm md lg` | `Avatar`, `AvatarStack` | Identity reads from a list row up to a profile header; nothing needs a poster-sized avatar. |
38
38
  | `xs sm md` | `Badge` | A badge is compact by definition; a large badge would be a Button or an Alert. |
39
39
  | `sm md lg xl` | `Container` | Content measures, resolved from the `--nk-content-*` tokens. |
40
- | `sm md lg` | `Sheet`, `ProgressiveImage` | Panel and media footprints. |
40
+ | `sm md lg` | `Card`, `Sheet`, `ProgressiveImage` | Panel and media footprints. |
41
41
  | `md lg` | `Checkbox`, `RadioButton`, `Switch` | Selection controls have one comfortable size and one emphasized size, resolved from the choice and control ramps. |
42
42
 
43
43
  ## Atoms and components
@@ -48,7 +48,7 @@ Every default string a person can read or hear comes from the engine-loaded `nit
48
48
  | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
49
49
  | `Alert` | `variant: :default`, `title: nil`, `description: nil`, `live: :off`, `id: nil` | `div[data-nk=alert]`; variants `default info success warning destructive`, the same families as `Toast::Item`, which spells its failure variant `error`; live modes `off polite assertive` | One semantic axis: `variant:` emits `data-variant` and drives the tint through the shared semantic palette, which resolves every family to the public `--nk-palette-*` tokens. An Alert and a `Toast::Item` of the same family render identically; Alert's `destructive` and Toast's `error` resolve to the same tint family. There is no `color:` option. Accepts at most one `NitroKit::Icon` through `icon`, plus `title` and `description` through constructor text or the matching compound method, never both. Nitro renders icon, title, then description regardless of declaration order; declaring outside the render block raises. `live: :polite` adds `role=status`; `live: :assertive` adds `role=alert`; static alerts have no live role by default. |
50
50
  | `AppNavigation` | required `label:`; `id: nil` | native `nav[data-nk=app-navigation]` | Requires one body and at least one item. The body is a `ul`; every entry is an `li`. Optional unique `header` and `footer` surround ordered `section(label: nil, collapsible: false, expanded: true)`, `divider`, `item(text, href:, icon: nil, icon_end: nil, badge: nil, badge_color: :neutral, current: false, html:, aria:, data:, desperately_need_a_class:)`, and at most one `spacer`. A section renders a `span` label plus a nested `ul` named through `aria-label`; it emits no heading. A collapsible section requires a label and renders native `details` and `summary` with an owned chevron, so open state lives on the `open` attribute without JavaScript; `expanded:` sets the initial state. Items render `li > a[data-slot=app-navigation-item-link]` and carry the item attribute bags. Icon and badge vocabularies are validated as the item is declared. At most one item is current. |
51
- | `Avatar` | `src: nil`, `alt: ""`, `fallback: nil`, `decorative: false`, `size: :md`, `loading: "lazy"`, `decoding: "async"`, `id: nil` | `span[data-nk=avatar]`; sizes `xs sm md lg`; state `error` | Renders fallback and optional image slots. The source is a keyword; there is no positional form. An image with empty `alt:` raises unless `decorative: true`. When a source is present the root carries `nk--avatar`, which sets `data-state="error"` if the image fails to load so the initials fallback shows through instead of a broken-image glyph, and unhides that fallback for assistive technology. |
51
+ | `Avatar` | `src: nil`, `alt: ""`, `fallback: nil`, `decorative: false`, `size: :md`, `loading: "lazy"`, `decoding: "async"`, `id: nil` | `span[data-nk=avatar]`; sizes `xs sm md lg`; state `error` | Renders fallback and optional image slots. The source is a keyword; there is no positional form. `fallback:` is a non-blank String of at most four characters; derived initials never exceed two, and three- or four-character fallbacks carry `data-length` so the stylesheet steps the type down to stay inside the circle. An image with empty `alt:` raises unless `decorative: true`. When a source is present the root carries `nk--avatar`, which sets `data-state="error"` if the image fails to load so the initials fallback shows through instead of a broken-image glyph, and unhides that fallback for assistive technology. |
52
52
  | `AvatarStack` | required `label:`; `size: :md`, `max: nil`, `id: nil` | `span[data-nk=avatar-stack][role=group]`; sizes `xs sm md lg` | `label:` names the group; `aria: { label: }` raises. `avatar(src:, alt:, fallback:, decorative:, loading:, decoding:, id:, html:, aria:, data:, desperately_need_a_class:)` takes explicit keywords and inherits the stack size. Declarations are collected, so Nitro renders the avatars then the single overflow regardless of declaration order. `max:` bounds the visible avatars and derives a `+N` indicator from the remainder; it cannot be combined with an explicit `overflow(count, label:)`, whose count must be positive and whose accessible label is owned by `label:`. The indicator carries `role="img"`; a derived one is named from `nitro_kit.avatar_stack.overflow` with the remaining count. |
53
53
  | `Badge` | optional text or a content block; `variant: :default`, `size: :md`, `color: :neutral`, `id: nil` | `span[data-nk=badge]`; variants `default outline`; sizes `xs sm md`; semantic colors plus the decorative palette | Content has exactly one path: label text or a content block, never both. Blank text raises at construction, the earliest point it is knowable; missing content raises at render. The color axis carries two vocabularies with different jobs. The semantic families `neutral info success warning destructive` resolve to the `--nk-palette-{family}` tint roles and move with an application's theme; the seventeen decorative hues resolve to the `--nk-palette-{hue}` roles and stay the color they name. `red` and `destructive` are therefore independently themeable rather than two spellings of one value. `href:` and `dismissible:` are deliberately out of scope; wrap the Badge in a link or pair it with a Button instead. |
54
54
  | `Button` | optional text or block; `href: nil`, `variant: :default`, `size: :md`, `icon: nil`, `icon_end: nil`, `label: nil`, `id: nil`, `type: :button`, `name: nil`, `value: nil`, `form: nil`, `target: nil`, `rel: nil`, `download: nil`, `disabled: false`, `loading: false`, `submission_indicator: nil` | native `button` or `a[data-nk=button]`; variants `default primary destructive ghost`; sizes `xs sm md lg xl`; types `button submit reset` | Requires text, a block, or an icon. `default` is the ordinary action treatment; `ghost` is reserved for deliberately low-emphasis interface chrome, not routine secondary actions. Icon-only buttons require `label:`, `aria: { label: }`, or `aria: { labelledby: }`; `label:` and `aria: { label: }` are the same attribute and collide. `icon_end:` matches the `button-icon-end` slot. Blank text, treatment vocabularies, and link/button option mixing raise on construction; text-plus-block and the icon-only accessible name raise at render because only render time knows whether a block supplies the label. `type:` applies to native buttons only and raises when combined with `href:`. `loading: true` disables the control, sets `aria-busy="true"`, and replaces the leading icon with the `button-spinner` slot. Disabled links lose `href`, receive `aria-disabled`, and leave the tab order. `submission_indicator: :spinner` applies only to native submit Buttons, cannot combine with `loading:`, and renders the `button-submission-spinner` slot that `nk--button` reveals during a slow Turbo submission. |
@@ -137,7 +137,7 @@ Field types are `button color date datetime datetime_local email file hidden mon
137
137
  | Component | Constructor-specific options | Root and closed vocabulary | Compound contract |
138
138
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
139
139
  | `Accordion` | required `id:`; `mode: :multiple` | `div[data-nk=accordion]`; modes `multiple single` | Requires one or more uniquely keyed `item(key, title:, expanded: false)` declarations. Each item renders native `details` and `summary`, so find-in-page and fragment navigation reveal matching closed content without a controller. Single mode gives every disclosure the same `name` and accepts at most one initially expanded item; multiple mode omits `name`. There is no disabled item or controller. |
140
- | `Card` | `id: nil` | `article[data-nk=card]`; title levels `1..6` | Requires a content block. `title`, `body`, `footer`, `divider`, and `full` render immediately; their count and order are not constrained. `title`, `body`, and `footer` require non-blank text or a block, and `full` requires a block. The shadowed Phlex elements remain available as `html_title`, `html_body`, and `html_footer`. |
140
+ | `Card` | `size: :md`, `variant: :default`, `id: nil` | `article[data-nk=card][data-size][data-variant]`; sizes `sm md lg`; variants `default muted outline`; title levels `1..6` | Requires a content block. `header`, `title`, `description`, `actions`, `body`, `footer`, `divider`, and `full` render immediately; their count and order are not constrained. `title`, `description`, `body`, and `footer` require non-blank text or a block; `header`, `actions`, and `full` require a block. `header` lays out a title and description beside trailing `actions`; `actions` inside a footer push to its end. The shadowed Phlex elements remain available as `html_title`, `html_body`, `html_header`, and `html_footer`. Sizes coordinate padding, gaps, corners, and title size. See Card sizing and surfaces below. |
141
141
  | `Combobox` | required `id:`, `name:`, `label:`, `options:`; `value: nil`, `placeholder: nil`, `include_blank: true`, `placement: :bottom_start`, `required: false`, `disabled: false`, `autocomplete: "off"`, `control_aria: {}` | `div[data-nk=combobox]`; placements `bottom_start bottom_end top_start top_end` | Options are a non-empty set of unique typed choices; `Choice#description` renders as a described secondary line in the listbox. A non-nil value must match a declared option. `label:` renders a real `Label` bound to `#{id}-input`; `label: false` requires `control_aria: { label: }` or `{ labelledby: }` and names the input, listbox, and native select from it. `placeholder:` is the input hint and `include_blank:` is the native blank option. The named native Select is the truthful no-JavaScript control and submission source; Stimulus reveals and synchronizes the searchable combobox enhancement. |
142
142
  | `Dialog` | required `id:`; `dismissible: true` | `div[data-nk=dialog]` owning exactly one native dialog | Requires a declaration block with exactly one `panel(title:, description: nil, nonmodal: false)` and at most one `trigger`, which forwards Button treatment options including `icon:`, `icon_end:`, and `label:`, matching Sheet and Dropdown. Nitro renders trigger then panel, and inside the panel it owns close button, title, description, then the captured application content, so a sticky close control survives long scrolling content. At most one `close_button(label:)` may be declared inside the panel block, whose label defaults to `I18n.t("nitro_kit.dialog.close")`; `dismissible: false` renders none and declaring one raises. `nonmodal: true` is the explicit server-open state and cannot be combined with a trigger, whose `command="show-modal"` would open the same panel modally. Trigger and close controls use native `command`/`commandfor`. Turbo confirmations are deliberately not a Dialog runtime: applications own inline confirmation dialogs, see [destructive action](patterns/destructive_action.md). |
143
143
  | `DetailsTable` | `record`, `caption: nil`, `label: nil`, `empty_text: nil`, `boolean_labels: nil`, `route_base: nil`, `id: nil` | `div[data-nk=details-table]` containing a slotted `Table` | Requires one or more fields with unique keys, declared inside the render block. `field(attribute, label: nil, value:)` distinguishes omitted and explicit nil values; a block receives the resolved value. `fields(*attributes)` adds ordinary resolved fields. `caption:` names the table visibly and `label:` names it through `aria-label`; empty and boolean copy remain caller-owned keywords; when omitted they come from `nitro_kit.details_table.empty`, `.boolean_true`, and `.boolean_false`. |
@@ -150,6 +150,94 @@ Field types are `button color date datetime datetime_local email file hidden mon
150
150
  | `Tooltip` | required `id:`, `content:`; `placement: :top` | `span[data-nk=tooltip]`; placements `top right bottom left` | Requires exactly one trigger. The default `as: NitroKit::Button` path supports native buttons and links; Button options on any other trigger raise. `as: :div` and `:span` create explicit focusable HTML descriptions. `as: :custom` yields `TriggerAttributes(html:, aria:, data:)`; applications must forward all three boundaries to the actual focusable control, including ButtonTo's nested Button or a Dialog trigger. Nitro appends the tooltip ID to existing `aria-describedby`. CSS owns hover/focus visibility; the controller's document-level Escape listener dismisses only while the tooltip is shown, so a wrapping dialog's cancel is not swallowed. |
151
151
  | `Typeset` | `id: nil` | `div[data-nk=typeset]` | Requires direct content. Styles semantic rich content without constraining its width. Nested Nitro component roots and `data-typeset="off"` regions establish styling boundaries. The shipped stylesheet retains the `@scope` path and includes a low-specificity fallback for engines that do not parse `@scope`, including Firefox through 145. That fallback covers root typography plus explicitly anchored direct-child headings, flow elements, lists, code/pre, tables, and links within those supported elements; it is a documented semantic subset rather than full descendant parity. |
152
152
 
153
+ ### Card sizing and surfaces
154
+
155
+ `Card.new(size: :md, variant: :default, id: nil)` also accepts the shared
156
+ attribute boundary. Sizes are `sm md lg`; variants are `default muted outline`.
157
+ Both closed vocabularies require symbols and emit owned `data-size` and
158
+ `data-variant` attributes. Unsupported values raise at construction.
159
+
160
+ Padding and inter-part gaps scale with `--nk-space`; corners use the radius
161
+ tokens. The card title is sized by the card, not the shared surface title
162
+ role, so a small card's heading stays proportionate. Description, body, and
163
+ footer text stay `--nk-text-sm`, and action gaps do not scale. The card's
164
+ `full` regions and dividers follow its padding, and bleeding corners follow
165
+ its radius.
166
+
167
+ | Size | Padding | Part gap | Radius | Title |
168
+ | ---- | ------- | -------- | ------ | ----- |
169
+ | `sm` | 4 space steps | 3 space steps | `--nk-radius-lg` | `--nk-text-sm` |
170
+ | `md` | 6 space steps | 4 space steps | `--nk-radius-xl` | `--nk-text-base` |
171
+ | `lg` | 8 space steps | 6 space steps | `--nk-radius-xl` + 1 space step | `--nk-text-lg` |
172
+
173
+ `default` is `--nk-color-surface` with the shared border and `--nk-shadow-xs`.
174
+ `muted` tints the parent's surface with 4% foreground and drops the shadow;
175
+ `outline` keeps only the border. Neither adds hover or link behavior.
176
+
177
+ A section set apart by a divider at the card's top or bottom edge is a band:
178
+ its distance to the card edge equals the part gap, so a divided header or
179
+ footer sits evenly between edge and divider. `full` regions keep the full
180
+ padding because they bleed through it. Tables inside `full` align their first
181
+ and last columns and their caption with the card's padding. When a parent such as
182
+ a Grid row stretches the card, a trailing footer and its divider stay on the
183
+ bottom edge so actions line up across the row.
184
+
185
+ ```ruby
186
+ render NitroKit::Card.new do |card|
187
+ card.header do
188
+ card.title("Profile", level: 3)
189
+ card.description("This is how others see you.")
190
+ card.actions { render NitroKit::Button.new("Edit", size: :sm) }
191
+ end
192
+ card.divider
193
+ card.body { "…" }
194
+ card.divider
195
+ card.footer do
196
+ plain "Last saved 2 minutes ago"
197
+ card.actions { render NitroKit::Button.new("Save", variant: :primary, size: :sm) }
198
+ end
199
+ end
200
+ ```
201
+
202
+ #### Card configuration recipes
203
+
204
+ Choose density for the content's job, surface for its emphasis, and a parent
205
+ layout for its width. Neither card axis changes heading level, button state,
206
+ form semantics, or table scrolling.
207
+
208
+ | Configuration | Use |
209
+ | ------------- | --- |
210
+ | `size: :sm` | Dense widgets, link grids, and compact summaries |
211
+ | `size: :md` | Ordinary records; the default |
212
+ | `size: :lg` | Roomy forms and settings |
213
+ | `variant: :muted` | Secondary panels beside a primary card |
214
+ | `variant: :outline` | A quiet edge over the parent's surface |
215
+
216
+ The [card gallery](https://gallery.nitrokit.dev/gallery/components/card) covers
217
+ a simple confirmation, a header/body/footer profile form, every size and
218
+ surface, a divided settings list, media and table bleed, a responsive link
219
+ grid, footer Button states, partial structures, semantic title levels, and
220
+ hostile content.
221
+
222
+ Associate footer actions with the body's native form through `form:`. Use
223
+ `full` for media at either edge. An only-child `full` touches all four
224
+ corners; a leading or trailing region touches only that edge's corners. Do not
225
+ clip the root, since menus and tooltips may need to escape it.
226
+
227
+ ```ruby
228
+ render NitroKit::Card.new(size: :md) do |card|
229
+ card.full { img(src: "/images/hills.jpg", alt: "Green hills beneath a warm sun") }
230
+ card.header do
231
+ card.title("Morning hike", level: 3)
232
+ card.description("September 12 · 8.4 km")
233
+ end
234
+ end
235
+ ```
236
+
237
+ Compose collections with `Grid.new(cols: "1 sm:2 lg:3", gap: 4)`, not card width
238
+ options. Tables placed inside `full` retain their own horizontal scroll region.
239
+ Loading and disabled states belong to Buttons or application content, not Card.
240
+
153
241
  ## Layout primitives
154
242
 
155
243
  Only closed, component-specific layout values are public. `VStack` and `HStack` have been removed; choose an explicit `Flex` direction instead.
@@ -210,7 +298,7 @@ Unknown values or prefixes, duplicate base/prefix tokens, missing base values, b
210
298
  | Component | Constructor | Root | Compound contract |
211
299
  | ----------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
212
300
  | `AuthShell` | `id: nil` | `main[data-nk=auth-shell]` | Requires direct content. Owns `main` → medium Container → `Flex(dir: :col, gap: 6, align: :stretch)`; branding, Cards, and Turbo boundaries remain caller-owned. |
213
- | `AppShell` | required `id:`; `layout: :sidebar`; configurable skip/open/close/dialog labels defaulting to `nitro_kit.app_shell.*` | `div[data-nk=app-shell][data-layout]`; layouts `sidebar topbar`; states `open closed`; `data-enhanced` is written by the Stimulus controller and reserved | Requires exactly one `navigation` and one `main`; optional `brand` and `topbar` are unique. Regions are declared inside the render block only. One navigation tree moves between its neutral desktop wrapper and a native modal dialog at narrow widths. IDs are fragment-safe; product policy remains caller-owned. |
301
+ | `AppShell` | required `id:`; `layout: :sidebar`; `collapsible: false`; `sidebar: :expanded` (`expanded collapsed`); configurable skip/open/close/dialog/sidebar-toggle labels defaulting to `nitro_kit.app_shell.*` | `div[data-nk=app-shell][data-layout]`; layouts `sidebar topbar`; drawer states `open closed`; `data-enhanced` is written by the Stimulus controller and reserved | Requires exactly one `navigation` and one `main`; optional `brand(icon: nil)` and `topbar` are unique. Regions are declared inside the render block only. The default sidebar stays expanded with no toggle or peek. `collapsible: true` opts into a Button-backed pin toggle with `aria-pressed` and fine-pointer hover/focus overlay peek without changing the rail's layout width. Brand's optional validated Lucide icon stays visible on the rail; full brand content peeks instead of being cropped. Labels retain accessible names and vertical positions; supply item icons for the rail. `sidebar:` sets initial pin state; `sidebar: :collapsed` requires `collapsible: true`. These options do not affect topbar or the narrow native drawer. Pin state is not persisted across page loads. One navigation tree moves between its neutral desktop wrapper and a native modal dialog at narrow widths. IDs are fragment-safe; product policy remains caller-owned. |
214
302
  | `SettingsLayout` | `id: nil` | `div[data-nk=settings-layout]` | Exactly one `navigation(label:)` and one `content` region, both block-only and declared inside the render block. The navigation requires at least one `item(text, href:, icon: nil, current: false, html:, aria:, data:, desperately_need_a_class:)` and renders `nav > ul > li > a`, where `icon:` names a Lucide icon and the item attribute bags land on the link; a current item uses `aria-current="page"` and at most one item is current. Routes remain caller-owned. |
215
303
  | `Toolbar` | `id: nil` | `div[data-nk=toolbar]` | At most one `leading` and one `trailing`; at least one region total. Each region requires a content block. It deliberately has no toolbar role, sticky mode, or action registry. |
216
304
  | `PaginationBar` | `id: nil` | `div[data-nk=pagination-bar]` | Exactly one typed `pagination(NitroKit::Pagination)` and at most one non-blank `summary`. The summary announces politely unless the caller supplies its own `aria-live`; the shadowed Phlex element remains available as `html_summary`. Counts, routes, and page math remain caller-owned. |
@@ -4,7 +4,7 @@
4
4
  themes or composing application-owned UI. The first sections are task guidance;
5
5
  the final token tables are exhaustive reference.
6
6
 
7
- Nitro Kit owns component Ruby, markup, behavior, and default CSS. Applications customize the system by overriding the public `--nk-*` custom properties, composing components into application UI, and occasionally creating a narrow subclass. Applications do not copy or edit Nitro components.
7
+ Nitro Kit owns component Ruby, markup, behavior, and default CSS. Applications normally customize public `--nk-*` properties and compose application UI. When source-level changes are necessary, explicitly [eject one component](eject.md); that isolated snapshot becomes application-owned and stops receiving component updates.
8
8
 
9
9
  ## Stylesheet order
10
10
 
@@ -517,7 +517,7 @@ The following variables are the complete public token set. Theme-independent tok
517
517
  | `--nk-title-page-weight` | Page title weight. |
518
518
  | `--nk-title-section-size` | Section title size: data, settings, and danger sections. |
519
519
  | `--nk-title-section-weight` | Section title weight. |
520
- | `--nk-title-surface-size` | Surface title size: cards, dialogs, sheets, empty states, fieldsets. |
520
+ | `--nk-title-surface-size` | Surface title size: dialogs, sheets, empty states, fieldsets. |
521
521
  | `--nk-title-surface-weight` | Surface title weight. |
522
522
  | `--nk-title-compact-size` | Compact title size: legends, alert and toast titles. |
523
523
  | `--nk-title-compact-weight` | Compact title weight. |