nitro_kit 2.0.0.beta.1 → 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 +55 -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
data/docs/eject.md ADDED
@@ -0,0 +1,86 @@
1
+ # Eject one component
2
+
3
+ Gem-owned components, token overrides, and composition remain the default.
4
+ Eject is an explicit source-level customization opt-out, never an installation
5
+ step. Free eject turns a gem component into your code; Pro exemplars are already
6
+ your code when retrieved.
7
+
8
+ ```sh
9
+ bin/rails generate nitro_kit:eject Button
10
+ ```
11
+
12
+ Use the ordinary Phlex constructor:
13
+
14
+ ```ruby
15
+ render Ui::EjectedButton::Button.new("Save", icon: :check)
16
+ ```
17
+
18
+ ## Files and integration
19
+
20
+ - `app/components/ui/ejected_button/`: Button, nested slot classes, transitive
21
+ Ruby component/support dependencies (such as Icon), and an application-owned
22
+ Component adapter with a snapshot of base component-level helpers.
23
+ - `app/assets/stylesheets/ejected_button.css`: those components' CSS plus shared
24
+ palette/layout rules where needed, with explicit cascade-layer order.
25
+ - `app/javascript/controllers/ui/ejected_button/`: referenced Stimulus
26
+ controllers and their local JavaScript imports.
27
+ - `config/nitro_kit/ejected/button.json`: source version, component, namespace,
28
+ and generated file inventory. Every source file also records the version.
29
+
30
+ Keep Nitro Kit installed. The attribute/render kernel (`NitroKit::Component`),
31
+ public `--nk-*` tokens, global reset, translations, appearance document runtime,
32
+ and third-party integrations (Phlex, Lucide, Turbo, Active Storage) stay shared.
33
+ This is not a standalone replacement for the gem. Pin the gem and test kernel
34
+ upgrades even after ejecting.
35
+
36
+ Load the generated CSS after Nitro Kit and before application overrides:
37
+
38
+ ```erb
39
+ <%= stylesheet_link_tag "nitro_kit", "ejected_button", "application",
40
+ "data-turbo-track": "reload" %>
41
+ ```
42
+
43
+ In Phlex, use the Rails `stylesheet_link_tag` adapter with the same asset names;
44
+ CSS bundlers may import the generated file instead. The generator reports this
45
+ manual step rather than guessing which of your layouts or CSS entrypoints owns
46
+ the page. Keep the app's normal Stimulus loader enabled. Standard Rails importmap
47
+ apps already pin `app/javascript/controllers` recursively; bundler apps must
48
+ include the generated controllers in their controller loader/build as usual.
49
+
50
+ Dropzone's copied controller imports Active Storage's JavaScript even when
51
+ `direct_upload: false`. If the app omits Active Storage, its JavaScript asset
52
+ must still be available; eject does not install third-party integrations.
53
+
54
+ ## Isolation and composition
55
+
56
+ Each ejection has its own dependency snapshot. Ruby uses `Ui::EjectedButton`,
57
+ not `NitroKit` or an existing `Ui::Button`. Root/slot identities use
58
+ `ui-ejected-button-*`; controller identifiers follow Rails filename conventions
59
+ (`ui--ejected-button--button`); CSS layers, private variables, and keyframes
60
+ are renamed. Public tokens and the global reset deliberately remain shared.
61
+ Gem Buttons and ejected Buttons can render on the same page without sharing
62
+ component CSS or controller identifiers.
63
+
64
+ Namespace spelling follows the host application's inflections (for example,
65
+ an app with the `UI` acronym gets `UI::EjectedButton`). Use the constructor
66
+ printed by the generator.
67
+
68
+ For typed slots, construct children from the same snapshot, for example
69
+ `Ui::EjectedEmptyState::Button`. Arbitrary content blocks may still compose gem
70
+ components, but snapshot-specific contextual styling targets snapshot children.
71
+ Separate ejections do not silently share mutable dependencies.
72
+
73
+ ## Existing files and updates
74
+
75
+ Unknown names fail with a list of supported names before writing. CamelCase,
76
+ snake_case, and `NitroKit::Button` names are accepted. If any snapshot file
77
+ already exists, the entire operation skips without changing anything (including
78
+ with `--skip`). `--force` explicitly replaces the entire generated snapshot;
79
+ commit your modifications first. Stale files from older dependency graphs are
80
+ not deleted automatically.
81
+
82
+ Bundler updates do not update ejected component implementations, styles, or
83
+ controllers. Compare the recorded version to the changelog and port fixes
84
+ yourself, or generate a new snapshot in a disposable application and diff it.
85
+ The manifest enables future update notices; no updater or notice UI ships yet.
86
+ Commit all generated files and the stylesheet integration together.
@@ -12,7 +12,7 @@ Treat a 1.x migration as a product-flow review, not a helper rename. Nitro Kit
12
12
  Add the 2.0 prerelease to the application's Gemfile:
13
13
 
14
14
  ```ruby
15
- gem "nitro_kit", "2.0.0.beta.1"
15
+ gem "nitro_kit", "2.0.0.beta.2"
16
16
  ```
17
17
 
18
18
  Bundler records the exact released version in `Gemfile.lock`; commit `Gemfile`
@@ -45,6 +45,28 @@ Nitro owns responsive disclosure and focus behavior. Put infrequent account
45
45
  destinations after `navigation.spacer`. Add one `CommandPalette` only when the
46
46
  destination count warrants search, and render only authorized routes.
47
47
 
48
+ The sidebar stays expanded by default, without a toggle or hover peek. Opt in
49
+ with `AppShell(id: "workspace", collapsible: true)` to include the bottom
50
+ **Pin sidebar** toggle that switches between the full sidebar and an icon rail.
51
+ Hovering with a fine pointer or focusing within the navigation or brand
52
+ reveals the full navigation as an overlay without moving page content.
53
+ The pin control does not trigger peek on hover or focus, so directly pinning
54
+ the rail starts the sidebar and content resize together.
55
+ Clicking the toggle pins the sidebar and reserves its full layout width.
56
+ Pointer-clicking to unpin collapses it immediately and suppresses hover peek
57
+ until the pointer leaves the sidebar once; keyboard focus still peeks. Use
58
+ `AppShell(id: "workspace", collapsible: true, sidebar: :collapsed)` to start with the rail, and
59
+ give navigation items icons so they remain recognizable. Labels keep their
60
+ accessible names and vertical positions. Touch does not hover-peek; narrow
61
+ screens still use the modal drawer. Pin state survives Turbo morph refreshes
62
+ of the same shell, but is not persisted across page loads.
63
+ `sidebar_toggle_label:` overrides its name.
64
+
65
+ For a brand mark that stays visible on the rail, declare
66
+ `shell.brand(icon: :zap) { ... }`. The icon stays aligned with navigation;
67
+ the full brand content appears on hover/focus or when pinned instead of being
68
+ cropped to fit the rail.
69
+
48
70
  Use `AuthShell` with Rails `form_with` and `NitroKit::FormBuilder` for
49
71
  authentication. Put visible fields, submit, and recovery link in one
50
72
  `form.group`.
@@ -67,6 +67,13 @@ For `topbar`, put that Toolbar first inside `workspace-content` and omit
67
67
  `shell.topbar`. Keep one route title and one set of actions. The header and
68
68
  body in `sidebar` form one continuous canvas, not two stacked cards.
69
69
 
70
+ The inset composition also works with `collapsible: true`. Add
71
+ `sidebar: :expanded` to start pinned open, or `sidebar: :collapsed` to start
72
+ as an icon rail, and give `shell.brand` an `icon:` for its compact mark.
73
+ Pinning reserves navigation space; hover or keyboard-focus peeking overlays
74
+ the inset canvas without moving its toolbar or content. The App shell and
75
+ Sidebar operations galleries show static and collapsible inset examples.
76
+
70
77
  Load this stylesheet after Nitro Kit:
71
78
 
72
79
  ```css
@@ -165,7 +172,8 @@ Load this stylesheet after Nitro Kit:
165
172
 
166
173
  Navigation, mobile disclosure, and focus restoration remain Nitro-owned.
167
174
  Application code owns the destinations and the composition. The public
168
- Product resource gallery runs the sidebar example with this stylesheet at
175
+ App shell, Sidebar operations application, and Product resource galleries
176
+ run inset sidebar examples with this stylesheet at
169
177
  `test/dummy/app/assets/stylesheets/inset_workspace.css`.
170
178
 
171
179
  ## Verify the result
@@ -12,7 +12,7 @@ There are no `nk_form_with` helpers or general ERB component bridge.
12
12
  Pin the current prerelease:
13
13
 
14
14
  ```ruby
15
- gem "nitro_kit", "2.0.0.beta.1"
15
+ gem "nitro_kit", "2.0.0.beta.2"
16
16
  ```
17
17
 
18
18
  Use the released gem and commit `Gemfile` with `Gemfile.lock`. Before upgrading,
@@ -0,0 +1,26 @@
1
+ require "rails/generators"
2
+ require "nitro_kit/ejection"
3
+
4
+ module NitroKit
5
+ class EjectGenerator < Rails::Generators::Base
6
+ argument :component, type: :string, desc: "Component to eject (for example Button)"
7
+ desc "Copy one component and its dependencies into isolated application-owned code."
8
+
9
+ def eject_component
10
+ ejection = Ejection.new(component)
11
+ files = ejection.files
12
+ existing = files.keys.select { |path| File.exist?(File.join(destination_root, path)) }
13
+ if existing.any? && !options[:force]
14
+ say_status :skip, "Already ejected or conflicting files: #{existing.join(', ')}. No files changed; use --force to replace the entire snapshot.", :yellow
15
+ return
16
+ end
17
+
18
+ files.each { |path, content| create_file(path, content) }
19
+ say "Render #{ejection.namespace}::#{ejection.component_name}.new(...)"
20
+ say "Load #{ejection.stylesheet_name}.css after nitro_kit.css (stylesheet_link_tag, or your CSS entrypoint)."
21
+ say "Keep Nitro Kit installed and keep the application's Stimulus controller loader enabled. See docs/eject.md."
22
+ rescue ArgumentError => error
23
+ raise Rails::Generators::Error, error.message
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,136 @@
1
+ require "ripper"
2
+ require "json"
3
+ require "nitro_kit/version"
4
+
5
+ module NitroKit
6
+ class Ejection
7
+ ROOT = File.expand_path("../..", __dir__)
8
+ SUPPORT_FILES = %w[component layout_options responsive_value].freeze
9
+
10
+ def initialize(name)
11
+ @name = name.delete_prefix("NitroKit::").underscore
12
+ @sources = Dir["#{ROOT}/app/components/nitro_kit/*.rb"].to_h do |path|
13
+ [ File.basename(path, ".rb").camelize, File.read(path) ]
14
+ end
15
+ unless @sources.key?(component_name) && !SUPPORT_FILES.include?(@name)
16
+ raise ArgumentError, "Unknown component #{name.inspect}. Choose one of: #{(@sources.keys - SUPPORT_FILES.map(&:camelize)).sort.join(', ')}"
17
+ end
18
+ end
19
+
20
+ def component_name = @name.camelize
21
+ def stylesheet_name = "ejected_#{@name}"
22
+ def namespace = directory.camelize
23
+ def prefix = "ui-ejected-#{@name.dasherize}"
24
+ def directory = "ui/ejected_#{@name}"
25
+
26
+ def files
27
+ result = {}
28
+ ruby_names.each do |name|
29
+ result["app/components/#{directory}/#{name.underscore}.rb"] = header("#") + rewrite_ruby(@sources.fetch(name))
30
+ end
31
+ result["app/components/#{directory}/component.rb"] = header("#") + component_adapter
32
+ result["app/assets/stylesheets/#{stylesheet_name}.css"] = header("/*", " */") + stylesheet
33
+ javascript_paths.each do |path|
34
+ relative = path.delete_prefix("#{ROOT}/app/javascript/controllers/nk/")
35
+ source = rewrite_contracts(File.read(path))
36
+ .gsub("controllers/nk/", "controllers/#{directory}/")
37
+ result["app/javascript/controllers/#{directory}/#{relative}"] = header("//") + source
38
+ end
39
+ result["config/nitro_kit/ejected/#{@name}.json"] = JSON.pretty_generate(
40
+ component: component_name, version: VERSION, namespace:, files: result.keys
41
+ ) + "\n"
42
+ result
43
+ end
44
+
45
+ private
46
+ def header(open, close = "")
47
+ "#{open} Ejected from Nitro Kit #{VERSION}: #{component_name}. Application-owned; not automatically upgraded.#{close}\n"
48
+ end
49
+
50
+ def ruby_names
51
+ @ruby_names ||= begin
52
+ names = [ component_name ]
53
+ names.each do |name|
54
+ Ripper.lex(@sources.fetch(name)).each do |_, type, token, _|
55
+ names << token if type == :on_const && @sources.key?(token) && token != "Component" && !names.include?(token)
56
+ end
57
+ end
58
+ names
59
+ end
60
+ end
61
+
62
+ def stylesheet
63
+ names = ruby_names.map(&:underscore)
64
+ names << "palette" if (ruby_names & %w[Badge Alert Toast]).any?
65
+ names << "layout" if (ruby_names & %w[Flex Grid]).any?
66
+ rules = names.filter_map do |name|
67
+ path = "#{ROOT}/src/stylesheets/nitro_kit/components/#{name}.css"
68
+ rewrite_contracts(File.read(path)) if File.file?(path)
69
+ end
70
+ layers = %w[base variant size state compound].map { |layer| "#{prefix}.#{layer}" }.join(", ")
71
+ "@layer #{layers};\n" + rules.join("\n")
72
+ end
73
+
74
+ def javascript_paths
75
+ ruby_source = ruby_names.map { |name| @sources.fetch(name) }.join("\n")
76
+ paths = Dir["#{ROOT}/app/javascript/controllers/nk/*_controller.js"].select do |path|
77
+ name = File.basename(path, "_controller.js")
78
+ ruby_source.include?("nk--#{name.dasherize}") || ruby_source.include?("nk__#{name}")
79
+ end
80
+ # Shared overlay command plumbing lives in the component adapter.
81
+ paths |= [ "#{ROOT}/app/javascript/controllers/nk/dialog_controller.js" ] if ruby_source.include?("command_data(")
82
+ paths.each do |path|
83
+ File.read(path).scan(/(?:from\s*|import\s*)["']([^"']+)["']/).flatten.each do |import|
84
+ dependency = if import.start_with?("controllers/nk/")
85
+ "#{ROOT}/app/javascript/#{import}.js"
86
+ elsif import.start_with?(".")
87
+ File.expand_path(import.end_with?(".js") ? import : "#{import}.js", File.dirname(path))
88
+ end
89
+ paths << dependency if dependency && File.file?(dependency) && !paths.include?(dependency)
90
+ end
91
+ end
92
+ paths
93
+ end
94
+
95
+ def rewrite_ruby(source)
96
+ rewrite_contracts(source.gsub("module NitroKit", "module #{namespace}").gsub("NitroKit::", "#{namespace}::"))
97
+ end
98
+
99
+ def rewrite_contracts(source)
100
+ source.gsub("nk--", "ui--ejected-#{@name.dasherize}--")
101
+ .gsub("nk__", "ui__ejected_#{@name}__")
102
+ .gsub(/(data-(?:nk|slot)(?:[~|^$*]?=)["'])([^"']+)/) { "#{$1}#{prefix}-#{$2}" }
103
+ .gsub(/(dataset\.slot\s*=\s*["'])([^"']+)/) { "#{$1}#{prefix}-#{$2}" }
104
+ .gsub("--_nk-", "--_#{prefix}-")
105
+ .gsub(/(?<![-\w])nk-(?!-)[a-z][a-z0-9-]*/) { |name| "#{prefix}-#{name.delete_prefix('nk-')}" }
106
+ .gsub("nitro-kit.", "#{prefix}.")
107
+ end
108
+
109
+ def component_adapter
110
+ # Copy component helpers, not the attribute kernel or the enclosing class.
111
+ helpers = @sources.fetch("Component").lines
112
+ .drop_while { |line| !line.start_with?(" def description_id") }[...-2].join
113
+ <<~RUBY
114
+ module #{namespace}
115
+ class Component < NitroKit::Component
116
+ def initialize(component:, **attributes)
117
+ super(component: "#{prefix}-\#{component.to_s.tr('_', '-')}", **attributes)
118
+ end
119
+
120
+ private
121
+
122
+ def qualified_slot(slot)
123
+ name = slot.to_s.tr("_", "-")
124
+ original = @component_name.delete_prefix("#{prefix}-")
125
+ name = name.delete_prefix("\#{original}-")
126
+ "\#{@component_name}-\#{name}"
127
+ end
128
+
129
+ # Snapshot of component-level helpers; attribute/render kernel stays gem-owned.
130
+ #{rewrite_contracts(helpers)}
131
+ end
132
+ end
133
+ RUBY
134
+ end
135
+ end
136
+ end
@@ -1,3 +1,3 @@
1
1
  module NitroKit
2
- VERSION = "2.0.0.beta.1"
2
+ VERSION = "2.0.0.beta.2"
3
3
  end
@@ -78,6 +78,8 @@
78
78
  min-block-size: var(--nk-control-height-md);
79
79
  margin: var(--nk-space) 0;
80
80
  padding: calc(var(--nk-space) * 2) calc(var(--nk-space) * 3);
81
+ /* The label text is an anonymous flex item with a min-content floor. */
82
+ overflow-wrap: anywhere;
81
83
  list-style: none;
82
84
  user-select: none;
83
85
  cursor: pointer;
@@ -135,7 +137,8 @@
135
137
  border-radius: var(--nk-radius-lg);
136
138
  transition:
137
139
  color var(--nk-duration-normal) var(--nk-ease),
138
- background-color var(--nk-duration-normal) var(--nk-ease);
140
+ background-color var(--nk-duration-normal) var(--nk-ease),
141
+ inline-size var(--nk-duration-fast) var(--nk-ease);
139
142
  }
140
143
 
141
144
  :where([data-nk="app-navigation"] [data-slot="app-navigation-item-label"]) {
@@ -357,6 +357,209 @@
357
357
  }
358
358
 
359
359
  @layer nitro-kit.compound {
360
+ :where(
361
+ [data-nk="app-shell"]
362
+ > [data-slot="app-shell-sidebar"]
363
+ > [data-slot="app-shell-sidebar-toggle"]
364
+ ) {
365
+ display: none;
366
+ }
367
+
368
+ @media (width >= 48rem) {
369
+ :where(
370
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
371
+ ) {
372
+ --_nk-shell-rail-width: calc(var(--nk-space) * 16);
373
+ --_nk-shell-panel-width: var(--nk-app-shell-sidebar-width);
374
+ --_nk-shell-label-clip: none;
375
+ transition: grid-template-columns var(--nk-duration-fast) var(--nk-ease);
376
+ }
377
+
378
+ :where(
379
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
380
+ > [data-slot="app-shell-sidebar"]
381
+ ) {
382
+ z-index: 30;
383
+ inline-size: var(--_nk-shell-panel-width);
384
+ background-color: var(--nk-app-shell-background);
385
+ background-image: linear-gradient(
386
+ var(--nk-app-shell-sidebar-background),
387
+ var(--nk-app-shell-sidebar-background)
388
+ );
389
+ transition: inline-size var(--nk-duration-fast) var(--nk-ease);
390
+ }
391
+
392
+ :where(
393
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
394
+ > [data-slot="app-shell-header"]
395
+ > [data-slot="app-shell-brand"]:has(
396
+ > [data-slot="app-shell-brand-icon"]
397
+ )
398
+ ) {
399
+ gap: calc(var(--nk-space) * 2);
400
+ padding-inline: calc(var(--nk-space) * 6);
401
+ }
402
+
403
+ :where(
404
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
405
+ > [data-slot="app-shell-header"]
406
+ > [data-slot="app-shell-brand"]
407
+ > [data-slot="app-shell-brand-icon"]
408
+ ) {
409
+ flex: none;
410
+ }
411
+
412
+ :where(
413
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
414
+ > [data-slot="app-shell-header"]
415
+ > [data-slot="app-shell-brand"]
416
+ > [data-slot="app-shell-brand-content"]
417
+ ) {
418
+ min-inline-size: 0;
419
+ overflow: hidden;
420
+ clip-path: var(--_nk-shell-label-clip, none);
421
+ }
422
+
423
+ :where(
424
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
425
+ > [data-slot="app-shell-header"]
426
+ > [data-slot="app-shell-brand"]
427
+ ) {
428
+ z-index: 30;
429
+ inline-size: var(--_nk-shell-panel-width);
430
+ overflow: hidden;
431
+ white-space: nowrap;
432
+ background-color: var(--nk-app-shell-background);
433
+ background-image: linear-gradient(
434
+ var(--nk-app-shell-sidebar-background),
435
+ var(--nk-app-shell-sidebar-background)
436
+ );
437
+ transition: inline-size var(--nk-duration-fast) var(--nk-ease);
438
+ }
439
+
440
+ :where(
441
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
442
+ > [data-slot="app-shell-sidebar"]
443
+ > [data-slot="app-shell-navigation"]
444
+ ) {
445
+ flex: 1 1 0;
446
+ inline-size: calc(
447
+ var(--nk-app-shell-sidebar-width) - var(--nk-border-width)
448
+ );
449
+ min-inline-size: calc(
450
+ var(--nk-app-shell-sidebar-width) - var(--nk-border-width)
451
+ );
452
+ }
453
+
454
+ :where(
455
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
456
+ > [data-slot="app-shell-sidebar"]
457
+ > [data-slot="app-shell-sidebar-toggle"]
458
+ ) {
459
+ display: inline-flex;
460
+ flex: none;
461
+ align-self: flex-start;
462
+ margin: calc(var(--nk-space) * 3);
463
+ margin-inline-start: calc(
464
+ (var(--_nk-shell-rail-width) - var(--nk-control-height-md)) / 2
465
+ );
466
+ }
467
+
468
+ :where(
469
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
470
+ > [data-slot="app-shell-sidebar"]
471
+ [data-slot="app-navigation-item-link"]
472
+ ) {
473
+ inline-size: min(
474
+ 100%,
475
+ calc(
476
+ var(--_nk-shell-panel-width) - (var(--_nk-app-navigation-inset) * 2)
477
+ )
478
+ );
479
+ overflow: hidden;
480
+ }
481
+
482
+ :where(
483
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"][data-nk--app-shell-pinned-value="false"]
484
+ ) {
485
+ --_nk-shell-panel-width: var(--_nk-shell-rail-width);
486
+ grid-template-columns: var(--_nk-shell-rail-width) minmax(0, 1fr);
487
+ }
488
+
489
+ /* Keep the full navigation geometry: only its labels disappear. Focus
490
+ reveals arbitrary header/footer content before it can be activated. */
491
+ :where(
492
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"][data-nk--app-shell-pinned-value="false"]:not(
493
+ :has(
494
+ > [data-slot="app-shell-sidebar"]
495
+ > [data-slot="app-shell-navigation"]:focus-within,
496
+ > [data-slot="app-shell-header"]
497
+ > [data-slot="app-shell-brand"]:focus-within
498
+ )
499
+ )
500
+ ) {
501
+ --_nk-shell-label-clip: inset(50%);
502
+ }
503
+
504
+ :where(
505
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
506
+ > [data-slot="app-shell-sidebar"]
507
+ [data-slot="app-navigation-item-label"],
508
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
509
+ > [data-slot="app-shell-sidebar"]
510
+ [data-slot="app-navigation-section-label"],
511
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
512
+ > [data-slot="app-shell-sidebar"]
513
+ [data-slot="app-navigation-item-badge"],
514
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
515
+ > [data-slot="app-shell-sidebar"]
516
+ [data-slot="app-navigation-item-icon-end"],
517
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
518
+ > [data-slot="app-shell-sidebar"]
519
+ [data-slot="app-navigation-header"],
520
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]
521
+ > [data-slot="app-shell-sidebar"]
522
+ [data-slot="app-navigation-footer"]
523
+ ) {
524
+ clip-path: var(--_nk-shell-label-clip, none);
525
+ }
526
+
527
+ :where(
528
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]:has(
529
+ > [data-slot="app-shell-sidebar"]
530
+ > [data-slot="app-shell-navigation"]:focus-within,
531
+ > [data-slot="app-shell-header"]
532
+ > [data-slot="app-shell-brand"]:focus-within
533
+ )
534
+ ) {
535
+ --_nk-shell-panel-width: var(--nk-app-shell-sidebar-width);
536
+ --_nk-shell-label-clip: none;
537
+ }
538
+
539
+ @media (hover: hover) and (pointer: fine) {
540
+ :where(
541
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"]:not(
542
+ [data-nk--app-shell-hover-suppressed-value="true"]
543
+ ):has(
544
+ > [data-slot="app-shell-sidebar"]
545
+ > [data-slot="app-shell-navigation"]:hover,
546
+ > [data-slot="app-shell-header"]
547
+ > [data-slot="app-shell-brand"]:hover
548
+ )
549
+ ) {
550
+ --_nk-shell-panel-width: var(--nk-app-shell-sidebar-width);
551
+ --_nk-shell-label-clip: none;
552
+ }
553
+ }
554
+
555
+ :where(
556
+ [data-nk="app-shell"][data-layout="sidebar"][data-enhanced][data-nk--app-shell-collapsible-value="true"][data-nk--app-shell-pinned-value="false"]
557
+ > [data-slot="app-shell-sidebar"]
558
+ ) {
559
+ box-shadow: var(--nk-shadow-md);
560
+ }
561
+ }
562
+
360
563
  @media (width < 48rem) {
361
564
  :where([data-nk="app-shell"]) {
362
565
  grid-template-rows: auto auto minmax(0, 1fr);
@@ -574,6 +777,12 @@
574
777
 
575
778
  @media (prefers-reduced-motion: reduce) {
576
779
  :where(
780
+ [data-nk="app-shell"][data-nk--app-shell-collapsible-value="true"],
781
+ [data-nk="app-shell"][data-nk--app-shell-collapsible-value="true"]
782
+ > [data-slot="app-shell-sidebar"],
783
+ [data-nk="app-shell"][data-nk--app-shell-collapsible-value="true"]
784
+ > [data-slot="app-shell-header"]
785
+ > [data-slot="app-shell-brand"],
577
786
  [data-nk="app-shell"] > [data-slot="app-shell-skip-link"],
578
787
  [data-nk="app-shell"] > [data-slot="app-shell-dialog"],
579
788
  [data-nk="app-shell"]
@@ -52,6 +52,16 @@
52
52
  z-index: 1;
53
53
  object-fit: cover;
54
54
  }
55
+
56
+ /* Derived initials are two characters. Explicit fallbacks may run to four,
57
+ so longer ones step the type down instead of clipping mid-letter. */
58
+ :where([data-nk="avatar"] > [data-slot="avatar-fallback"][data-length="3"]) {
59
+ font-size: 80%;
60
+ }
61
+
62
+ :where([data-nk="avatar"] > [data-slot="avatar-fallback"][data-length="4"]) {
63
+ font-size: 62.5%;
64
+ }
55
65
  }
56
66
 
57
67
  @layer nitro-kit.state {
@@ -46,7 +46,9 @@
46
46
  }
47
47
 
48
48
  /* The chip is one of the heads: same fill as the avatar fallbacks and the
49
- same hairline ring, drawn as a real border since nothing overlaps it. */
49
+ same hairline ring, drawn as a real border since nothing overlaps it.
50
+ Its size is a minimum, so a wide count grows it into a pill instead of
51
+ spilling past the circle. */
50
52
  :where([data-nk="avatar-stack"] > [data-slot="avatar-stack-overflow"]) {
51
53
  /* Preceding avatars are positioned, so the overflow chip must be too or
52
54
  their paint order covers its leading "+". */
@@ -56,16 +58,18 @@
56
58
  font-weight: var(--nk-font-weight-semibold);
57
59
  line-height: 1;
58
60
  color: var(--nk-color-muted-foreground);
61
+ white-space: nowrap;
59
62
  border: var(--nk-border-width) solid
60
63
  color-mix(in oklab, var(--nk-color-foreground) 10%, transparent);
61
64
  border-radius: var(--nk-radius-full);
65
+ padding-inline: var(--nk-space);
62
66
  }
63
67
 
64
68
  :where(
65
69
  [data-nk="avatar-stack"][data-size="xs"]
66
70
  > [data-slot="avatar-stack-overflow"]
67
71
  ) {
68
- inline-size: var(--nk-avatar-size-xs);
72
+ min-inline-size: var(--nk-avatar-size-xs);
69
73
  block-size: var(--nk-avatar-size-xs);
70
74
  font-size: calc(var(--nk-text-xs) * 0.875);
71
75
  }
@@ -74,7 +78,7 @@
74
78
  [data-nk="avatar-stack"][data-size="sm"]
75
79
  > [data-slot="avatar-stack-overflow"]
76
80
  ) {
77
- inline-size: var(--nk-avatar-size-sm);
81
+ min-inline-size: var(--nk-avatar-size-sm);
78
82
  block-size: var(--nk-avatar-size-sm);
79
83
  font-size: var(--nk-text-xs);
80
84
  }
@@ -83,7 +87,7 @@
83
87
  [data-nk="avatar-stack"][data-size="md"]
84
88
  > [data-slot="avatar-stack-overflow"]
85
89
  ) {
86
- inline-size: var(--nk-avatar-size-md);
90
+ min-inline-size: var(--nk-avatar-size-md);
87
91
  block-size: var(--nk-avatar-size-md);
88
92
  font-size: var(--nk-text-sm);
89
93
  }
@@ -92,7 +96,7 @@
92
96
  [data-nk="avatar-stack"][data-size="lg"]
93
97
  > [data-slot="avatar-stack-overflow"]
94
98
  ) {
95
- inline-size: var(--nk-avatar-size-lg);
99
+ min-inline-size: var(--nk-avatar-size-lg);
96
100
  block-size: var(--nk-avatar-size-lg);
97
101
  font-size: var(--nk-text-base);
98
102
  }