@teamofsilicons/silicon-ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +4 -3
  2. package/check.mjs +14 -5
  3. package/components/activity-heatmap/activity-heatmap.module.css +4 -4
  4. package/components/alert/alert.module.css +1 -1
  5. package/components/animated-counter/animated-counter.js +1 -1
  6. package/components/announcement-bar/announcement-bar.module.css +6 -6
  7. package/components/avatar/avatar.d.ts +2 -2
  8. package/components/avatar/avatar.js +1 -1
  9. package/components/avatar/avatar.module.css +5 -2
  10. package/components/avatar-group/avatar-group.module.css +4 -4
  11. package/components/badge/badge.module.css +4 -4
  12. package/components/bar-chart/bar-chart.js +1 -1
  13. package/components/bar-chart/bar-chart.module.css +4 -4
  14. package/components/billing-toggle/billing-toggle.js +2 -2
  15. package/components/billing-toggle/billing-toggle.module.css +10 -10
  16. package/components/bottom-sheet/bottom-sheet.js +1 -1
  17. package/components/bottom-sheet/bottom-sheet.module.css +4 -5
  18. package/components/breadcrumb/breadcrumb.d.ts +8 -2
  19. package/components/breadcrumb/breadcrumb.js +21 -9
  20. package/components/breadcrumb/breadcrumb.module.css +7 -3
  21. package/components/brush-chart/brush-chart.module.css +9 -9
  22. package/components/button/button.d.ts +7 -2
  23. package/components/button/button.js +3 -1
  24. package/components/button/button.module.css +10 -1
  25. package/components/button-group/button-group.module.css +7 -7
  26. package/components/calendar/calendar.js +15 -5
  27. package/components/calendar/calendar.module.css +13 -10
  28. package/components/card/card.js +4 -4
  29. package/components/card/card.module.css +2 -2
  30. package/components/card-stack/card-stack.js +2 -2
  31. package/components/card-stack/card-stack.module.css +2 -3
  32. package/components/carousel/carousel.module.css +3 -3
  33. package/components/chat-thread/chat-thread.module.css +41 -41
  34. package/components/checkbox/checkbox.module.css +1 -1
  35. package/components/chip-group/chip-group.js +1 -1
  36. package/components/chip-group/chip-group.module.css +2 -2
  37. package/components/code-block/code-block.module.css +7 -14
  38. package/components/color-picker/color-picker.module.css +20 -21
  39. package/components/combobox/combobox.js +5 -1
  40. package/components/combobox/combobox.module.css +2 -2
  41. package/components/command-palette/command-palette.js +18 -11
  42. package/components/command-palette/command-palette.module.css +18 -18
  43. package/components/comment-thread/comment-thread.module.css +35 -35
  44. package/components/confirm-morph/confirm-morph.js +5 -5
  45. package/components/confirm-morph/confirm-morph.module.css +9 -9
  46. package/components/context-menu/context-menu.js +10 -28
  47. package/components/context-menu/context-menu.module.css +3 -3
  48. package/components/copy-button/copy-button.js +4 -4
  49. package/components/countdown/countdown.module.css +4 -4
  50. package/components/date-picker/date-picker.js +3 -3
  51. package/components/date-picker/date-picker.module.css +7 -7
  52. package/components/date-range-picker/date-range-picker.js +13 -10
  53. package/components/date-range-picker/date-range-picker.module.css +23 -22
  54. package/components/dialog/dialog.module.css +2 -2
  55. package/components/donut-chart/donut-chart.module.css +5 -5
  56. package/components/drawer/drawer.module.css +7 -7
  57. package/components/dropdown-menu/dropdown-menu.d.ts +71 -1
  58. package/components/dropdown-menu/dropdown-menu.js +100 -6
  59. package/components/dropdown-menu/dropdown-menu.module.css +15 -5
  60. package/components/elastic-slider/elastic-slider.js +2 -2
  61. package/components/elastic-slider/elastic-slider.module.css +4 -4
  62. package/components/expandable-card/expandable-card.js +1 -1
  63. package/components/expandable-card/expandable-card.module.css +1 -1
  64. package/components/expanding-button-group/expanding-button-group.module.css +3 -3
  65. package/components/expanding-search/expanding-search.js +3 -3
  66. package/components/expanding-search/expanding-search.module.css +9 -9
  67. package/components/file-dropzone/file-dropzone.js +3 -3
  68. package/components/file-dropzone/file-dropzone.module.css +21 -21
  69. package/components/file-upload/file-upload.module.css +7 -7
  70. package/components/filter-toolbar/filter-toolbar.js +7 -3
  71. package/components/filter-toolbar/filter-toolbar.module.css +9 -9
  72. package/components/floating-button-group/floating-button-group.module.css +7 -7
  73. package/components/gauge/gauge.js +1 -1
  74. package/components/gauge/gauge.module.css +2 -2
  75. package/components/hover-card/hover-card.js +2 -2
  76. package/components/hover-card/hover-card.module.css +2 -2
  77. package/components/image-compare/image-compare.module.css +4 -4
  78. package/components/inline-edit/inline-edit.module.css +12 -12
  79. package/components/json-viewer/json-viewer.module.css +12 -12
  80. package/components/line-chart/line-chart.js +1 -1
  81. package/components/line-chart/line-chart.module.css +8 -8
  82. package/components/mention-input/mention-input.js +4 -2
  83. package/components/mention-input/mention-input.module.css +8 -8
  84. package/components/metric-card/metric-card.module.css +1 -1
  85. package/components/money-input/money-input.js +6 -3
  86. package/components/money-input/money-input.module.css +10 -10
  87. package/components/morph-select/morph-select.js +7 -5
  88. package/components/morph-select/morph-select.module.css +13 -13
  89. package/components/multi-select/multi-select.js +4 -2
  90. package/components/multi-select/multi-select.module.css +6 -6
  91. package/components/nav-list/nav-list.d.ts +64 -0
  92. package/components/nav-list/nav-list.js +96 -0
  93. package/components/nav-list/nav-list.module.css +25 -0
  94. package/components/notification-center/notification-center.module.css +27 -27
  95. package/components/number-field/number-field.js +3 -3
  96. package/components/number-field/number-field.module.css +3 -3
  97. package/components/otp-input/otp-input.module.css +4 -4
  98. package/components/pagination/pagination.module.css +1 -1
  99. package/components/password-field/password-field.module.css +2 -2
  100. package/components/password-strength/password-strength.module.css +8 -8
  101. package/components/phone-input/phone-input.js +9 -7
  102. package/components/phone-input/phone-input.module.css +19 -19
  103. package/components/popover/popover.module.css +2 -2
  104. package/components/progress/progress.module.css +1 -1
  105. package/components/radio-cards/radio-cards.module.css +8 -8
  106. package/components/radio-group/radio-group.js +4 -1
  107. package/components/radio-group/radio-group.module.css +4 -3
  108. package/components/resizable-panels/resizable-panels.module.css +5 -5
  109. package/components/rich-text-editor/rich-text-editor.js +3 -3
  110. package/components/rich-text-editor/rich-text-editor.module.css +12 -12
  111. package/components/ridgeline/ridgeline.module.css +7 -7
  112. package/components/scroll-area/scroll-area.module.css +8 -8
  113. package/components/segmented-control/segmented-control.d.ts +3 -1
  114. package/components/segmented-control/segmented-control.js +5 -2
  115. package/components/segmented-control/segmented-control.module.css +9 -4
  116. package/components/select/select.module.css +5 -5
  117. package/components/shortcut-recorder/shortcut-recorder.js +5 -5
  118. package/components/shortcut-recorder/shortcut-recorder.module.css +16 -16
  119. package/components/signature-pad/signature-pad.module.css +11 -11
  120. package/components/slider/slider.module.css +4 -4
  121. package/components/slope-chart/slope-chart.module.css +7 -7
  122. package/components/slot-text/slot-text.js +2 -1
  123. package/components/sortable-data-table/sortable-data-table.module.css +4 -4
  124. package/components/sparkline/sparkline.js +1 -1
  125. package/components/split-button/split-button.module.css +6 -6
  126. package/components/stepper/stepper.module.css +3 -3
  127. package/components/streamgraph/streamgraph.module.css +7 -7
  128. package/components/swipe-actions/swipe-actions.js +2 -2
  129. package/components/swipe-actions/swipe-actions.module.css +5 -5
  130. package/components/switch/switch.js +2 -2
  131. package/components/switch/switch.module.css +1 -1
  132. package/components/tabs/tabs.d.ts +9 -2
  133. package/components/tabs/tabs.js +11 -5
  134. package/components/tabs/tabs.module.css +10 -3
  135. package/components/tag-input/tag-input.module.css +4 -4
  136. package/components/text-reveal/text-reveal.js +1 -1
  137. package/components/time-picker/time-picker.js +4 -2
  138. package/components/time-picker/time-picker.module.css +3 -3
  139. package/components/timeline/timeline.js +4 -4
  140. package/components/timeline/timeline.module.css +3 -3
  141. package/components/toast/toast.js +1 -1
  142. package/components/toast/toast.module.css +5 -5
  143. package/components/toast-stack/toast-stack.js +1 -1
  144. package/components/toast-stack/toast-stack.module.css +5 -5
  145. package/components/tooltip/tooltip.module.css +1 -1
  146. package/components/tree-view/tree-view.js +16 -12
  147. package/components/tree-view/tree-view.module.css +10 -8
  148. package/components/treemap/treemap.module.css +13 -13
  149. package/components/usage-meter/usage-meter.js +1 -1
  150. package/components/usage-meter/usage-meter.module.css +5 -5
  151. package/components/user-menu/user-menu.js +16 -14
  152. package/components/user-menu/user-menu.module.css +22 -22
  153. package/components/waffle-chart/waffle-chart.module.css +7 -7
  154. package/components.json +416 -1
  155. package/eslint.js +35 -3
  156. package/lib/fluid-hover.d.ts +83 -0
  157. package/lib/fluid-hover.js +184 -0
  158. package/lib/menu-highlight.d.ts +24 -11
  159. package/lib/menu-highlight.js +29 -28
  160. package/lib/motion-tokens.d.ts +76 -18
  161. package/lib/motion-tokens.js +45 -10
  162. package/package.json +1 -1
  163. package/styles.css +50 -7
@@ -14,32 +14,32 @@
14
14
  .scroller { min-height: 0; flex: 1; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-gutter: stable; }
15
15
  /* The content springs up from where it was when the thread grows; the clip keeps that travel out of the scroll range. */
16
16
  .track { overflow: clip; }
17
- .content { padding: 12px 16px 14px; will-change: transform; }
17
+ .content { padding: var(--space-group) var(--space-inset) var(--space-inset); will-change: transform; }
18
18
 
19
19
  .list { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; }
20
20
 
21
- .day { display: flex; align-items: center; gap: 12px; padding: 20px 4px 4px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; }
21
+ .day { display: flex; align-items: center; gap: var(--space-group); padding: var(--space-comfort) var(--space-tight) var(--space-tight); color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; }
22
22
  .day::before, .day::after { height: 1px; flex: 1; background: var(--border-subtle); content: ""; }
23
- .day:first-child { padding-top: 4px; }
23
+ .day:first-child { padding-top: var(--space-tight); }
24
24
  .day > span:empty::before { content: "\00a0"; }
25
25
 
26
- .row { position: relative; display: flex; align-items: flex-end; gap: 8px; margin-top: 2px; }
27
- .row[data-first] { margin-top: 14px; }
28
- .day + .row { margin-top: 10px; }
26
+ .row { position: relative; display: flex; align-items: flex-end; gap: var(--space-related); margin-top: var(--space-hair); }
27
+ .row[data-first] { margin-top: var(--space-inset); }
28
+ .day + .row { margin-top: var(--space-group); }
29
29
  .row[data-mine] { justify-content: flex-end; }
30
30
 
31
31
  .gutter { width: 28px; flex: none; align-self: flex-end; }
32
- .stack { display: flex; min-width: 0; max-width: min(78%, 34rem); flex-direction: column; align-items: flex-start; gap: 4px; }
32
+ .stack { display: flex; min-width: 0; max-width: min(78%, 34rem); flex-direction: column; align-items: flex-start; gap: var(--space-tight); }
33
33
  .row[data-mine] .stack { align-items: flex-end; }
34
34
 
35
- .meta { display: flex; align-items: baseline; gap: 6px; margin: 0 0 2px; padding: 0 12px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
35
+ .meta { display: flex; align-items: baseline; gap: var(--space-related); margin: 0 0 var(--space-hair); padding: 0 var(--space-group); color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
36
36
  .author { color: var(--text-secondary); font-weight: 500; }
37
37
 
38
- .line { position: relative; display: flex; max-width: 100%; align-items: center; gap: 6px; }
38
+ .line { position: relative; display: flex; max-width: 100%; align-items: center; gap: var(--space-related); }
39
39
  /* The author's face sits in the gutter, level with the bottom of their last bubble rather than the reactions under it. */
40
- .face { position: absolute; bottom: 0; left: -36px; display: flex; }
40
+ .face { position: absolute; bottom: 0; left: calc(var(--space-block) * -1); display: flex; }
41
41
  .row[data-mine] .line { flex-direction: row-reverse; }
42
- .parts { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: 4px; }
42
+ .parts { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: var(--space-tight); }
43
43
  .row[data-mine] .parts { align-items: flex-end; }
44
44
 
45
45
  /* Bubbles in a group share a soft edge on the author's side, so a run of messages reads as one voice. */
@@ -47,7 +47,7 @@
47
47
  --r: 18px;
48
48
  --tight: 6px;
49
49
  max-width: 100%;
50
- padding: 8px 13px 9px;
50
+ padding: var(--space-related) var(--space-group) var(--space-related);
51
51
  border-radius: var(--r);
52
52
  background: var(--surface-muted);
53
53
  color: var(--foreground);
@@ -63,7 +63,7 @@
63
63
  .row[data-mine]:not([data-last]) .bubble { border-bottom-right-radius: var(--tight); }
64
64
  .row[data-failed] .bubble { opacity: .6; }
65
65
 
66
- .gallery { display: grid; width: min(240px, 100%); gap: 3px; grid-template-columns: 1fr; }
66
+ .gallery { display: grid; width: min(240px, 100%); gap: var(--space-tight); grid-template-columns: 1fr; }
67
67
  .gallery[data-count="2"],
68
68
  .gallery[data-count="3"],
69
69
  .gallery[data-count="4"] { grid-template-columns: 1fr 1fr; }
@@ -73,10 +73,10 @@
73
73
  .gallery:not([data-count="1"]) .image { border-radius: var(--radius-detail); corner-shape: var(--corner-shape); }
74
74
  .gallery:not([data-count="1"]) { overflow: hidden; border-radius: var(--radius-control); corner-shape: var(--corner-shape); }
75
75
  .image img { display: block; width: 100%; height: 100%; object-fit: cover; }
76
- .more { position: absolute; inset: 0; display: grid; place-items: center; background: oklch(0% 0 0 / .45); color: #fff; font-size: var(--text-lg); font-weight: 500; }
76
+ .more { position: absolute; inset: 0; display: grid; place-items: center; background: var(--media-scrim); color: var(--on-media); font-size: var(--text-lg); font-weight: 500; }
77
77
 
78
78
  .file {
79
- display: flex; max-width: 100%; align-items: center; gap: 10px; padding: 9px 14px 9px 11px;
79
+ display: flex; max-width: 100%; align-items: center; gap: var(--space-group); padding: var(--space-related) var(--space-inset) var(--space-related) var(--space-group);
80
80
  border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface); color: var(--foreground); text-decoration: none;
81
81
  transition: background-color var(--duration-fast) var(--ease-standard);
82
82
  }
@@ -103,17 +103,17 @@
103
103
  @media (hover: none) { .reactButton { opacity: .7; } }
104
104
 
105
105
  .picker {
106
- position: absolute; z-index: 5; bottom: calc(100% + 6px); right: -4px;
107
- display: flex; gap: 2px; padding: 4px;
106
+ position: absolute; z-index: 5; bottom: calc(100% + var(--space-related)); right: calc(var(--space-tight) * -1);
107
+ display: flex; gap: var(--space-hair); padding: var(--space-tight);
108
108
  border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface-raised); box-shadow: var(--shadow-floating);
109
109
  transform-origin: calc(100% - 18px) 100%;
110
110
  }
111
- .picker[data-mine] { right: auto; left: -4px; transform-origin: 18px 100%; }
112
- .pickerWrap[data-below] .picker { top: calc(100% + 6px); bottom: auto; transform-origin: calc(100% - 18px) 0; }
111
+ .picker[data-mine] { right: auto; left: calc(var(--space-tight) * -1); transform-origin: 18px 100%; }
112
+ .pickerWrap[data-below] .picker { top: calc(100% + var(--space-related)); bottom: auto; transform-origin: calc(100% - 18px) 0; }
113
113
  .pickerWrap[data-below] .picker[data-mine] { transform-origin: 18px 0; }
114
114
  .emoji {
115
115
  display: grid; width: 34px; height: 34px; place-items: center; padding: 0;
116
- border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; cursor: pointer; font-size: 19px; line-height: 1;
116
+ border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; cursor: pointer; font-size: var(--text-lg); line-height: 1;
117
117
  -webkit-tap-highlight-color: transparent;
118
118
  transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
119
119
  }
@@ -121,31 +121,31 @@
121
121
  .emoji:active { transform: scale(.9); }
122
122
  @media (hover: hover) and (pointer: fine) { .emoji:hover { background: var(--surface-muted); transform: scale(1.12); } }
123
123
 
124
- .reactionClip { overflow: hidden; margin: -2px -4px; padding: 2px 4px 2px 8px; }
125
- .row[data-mine] .reactionClip { padding: 2px 8px 2px 4px; }
126
- .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; }
124
+ .reactionClip { overflow: hidden; margin: calc(var(--space-hair) * -1) calc(var(--space-tight) * -1); padding: var(--space-hair) var(--space-tight) var(--space-hair) var(--space-related); }
125
+ .row[data-mine] .reactionClip { padding: var(--space-hair) var(--space-related) var(--space-hair) var(--space-tight); }
126
+ .reactions { display: flex; flex-wrap: wrap; gap: var(--space-tight); margin: 0; padding: 0; list-style: none; }
127
127
  .row[data-mine] .reactions { justify-content: flex-end; }
128
128
  .reaction {
129
- display: inline-flex; height: 26px; align-items: center; gap: 5px; padding: 0 9px 0 7px;
129
+ display: inline-flex; height: 26px; align-items: center; gap: var(--space-tight); padding: 0 var(--space-related) 0 var(--space-related);
130
130
  border: 1px solid var(--border-subtle); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); color: var(--text-secondary); cursor: pointer;
131
131
  font: inherit; font-size: var(--text-xs); font-weight: 500; line-height: 1; font-variant-numeric: tabular-nums;
132
132
  -webkit-tap-highlight-color: transparent;
133
133
  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
134
134
  }
135
- .reaction > span:first-child { font-size: 14px; }
135
+ .reaction > span:first-child { font-size: var(--text-sm); }
136
136
  .reaction[aria-pressed="true"] { border-color: color-mix(in oklab, var(--accent) 28%, transparent); background: color-mix(in oklab, var(--accent) 8%, var(--surface)); color: var(--foreground); }
137
137
  @media (hover: hover) and (pointer: fine) { .reaction:hover { border-color: var(--border-strong); } }
138
138
  .reaction:active { transform: scale(.95); }
139
139
  .countRoll { display: inline-grid; overflow: hidden; font-variant-numeric: tabular-nums; }
140
140
  .countRoll > span { grid-area: 1 / 1; }
141
141
 
142
- .receipts { display: flex; min-height: 16px; align-items: center; gap: 6px; padding: 1px 6px 0; color: var(--text-muted); font-size: var(--text-xs); }
142
+ .receipts { display: flex; min-height: 16px; align-items: center; gap: var(--space-related); padding: 1px var(--space-related) 0; color: var(--text-muted); font-size: var(--text-xs); }
143
143
  .readers { display: inline-flex; }
144
144
  .reader { display: inline-flex; border-radius: 50%; corner-shape: round; box-shadow: 0 0 0 2px var(--surface); }
145
- .reader + .reader { margin-left: -4px; }
146
- .status[data-status="failed"] { display: inline-flex; align-items: center; gap: 8px; color: var(--danger); }
145
+ .reader + .reader { margin-left: calc(var(--space-tight) * -1); }
146
+ .status[data-status="failed"] { display: inline-flex; align-items: center; gap: var(--space-related); color: var(--danger); }
147
147
  .retry {
148
- display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px;
148
+ display: inline-flex; align-items: center; gap: var(--space-tight); padding: var(--space-hair) var(--space-related);
149
149
  border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); color: var(--foreground); cursor: pointer;
150
150
  font: inherit; font-weight: 500; line-height: 16px;
151
151
  -webkit-tap-highlight-color: transparent;
@@ -157,8 +157,8 @@
157
157
  .avatar { display: inline-grid; flex: none; overflow: hidden; place-items: center; border-radius: 50%; corner-shape: round; background: var(--surface-muted); color: var(--text-secondary); font-weight: 500; }
158
158
  .avatar img { width: 100%; height: 100%; object-fit: cover; }
159
159
 
160
- .typing { display: grid; height: 36px; box-sizing: border-box; place-items: center; padding: 0 14px; }
161
- .dots { display: inline-flex; gap: 4px; }
160
+ .typing { display: grid; height: 36px; box-sizing: border-box; place-items: center; padding: 0 var(--space-inset); }
161
+ .dots { display: inline-flex; gap: var(--space-tight); }
162
162
  .dots i { width: 6px; height: 6px; border-radius: 50%; corner-shape: round; background: var(--text-muted); animation: breathe 1.2s var(--ease-in-out) infinite; }
163
163
  .dots i:nth-child(2) { animation-delay: .16s; }
164
164
  .dots i:nth-child(3) { animation-delay: .32s; }
@@ -169,8 +169,8 @@
169
169
  @media (prefers-reduced-motion: reduce) { .dots i { animation: none; opacity: .6; } }
170
170
 
171
171
  .pill {
172
- position: absolute; z-index: 6; bottom: 12px; left: 50%; translate: -50% 0;
173
- display: inline-flex; height: 32px; align-items: center; gap: 6px; padding: 0 14px 0 11px;
172
+ position: absolute; z-index: 6; bottom: var(--space-group); left: 50%; translate: -50% 0;
173
+ display: inline-flex; height: 32px; align-items: center; gap: var(--space-related); padding: 0 var(--space-inset) 0 var(--space-group);
174
174
  border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface-raised); box-shadow: var(--shadow-floating); color: var(--foreground); cursor: pointer;
175
175
  font: inherit; font-size: var(--text-xs); font-weight: 500; white-space: nowrap;
176
176
  -webkit-tap-highlight-color: transparent;
@@ -180,22 +180,22 @@
180
180
  .pill:active { scale: .97; }
181
181
 
182
182
  /* Composer. */
183
- .composer { display: grid; padding: 10px 12px 12px; border-top: 1px solid var(--border-subtle); transition: background-color var(--duration-fast) var(--ease-standard); }
183
+ .composer { display: grid; padding: var(--space-group) var(--space-group) var(--space-group); border-top: 1px solid var(--border-subtle); transition: background-color var(--duration-fast) var(--ease-standard); }
184
184
  .composer[data-dragging] { background: var(--accent-subtle); }
185
185
  .trayClip { overflow: hidden; }
186
- .tray { display: flex; gap: 8px; margin: 0; padding: 8px 8px 10px 4px; overflow-x: auto; list-style: none; scrollbar-width: none; }
186
+ .tray { display: flex; gap: var(--space-related); margin: 0; padding: var(--space-related) var(--space-related) var(--space-group) var(--space-tight); overflow-x: auto; list-style: none; scrollbar-width: none; }
187
187
  .pending { position: relative; flex: none; }
188
188
  .pendingImage { display: block; width: 56px; height: 56px; border-radius: var(--radius-control); corner-shape: var(--corner-shape); object-fit: cover; }
189
- .pendingFile { display: grid; width: 168px; height: 56px; box-sizing: border-box; align-content: center; column-gap: 8px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface); grid-template-columns: auto minmax(0, 1fr); }
189
+ .pendingFile { display: grid; width: 168px; height: 56px; box-sizing: border-box; align-content: center; column-gap: var(--space-related); padding: 0 var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface); grid-template-columns: auto minmax(0, 1fr); }
190
190
  .pendingFile > svg { grid-row: span 2; align-self: center; color: var(--text-secondary); }
191
191
  .pendingName { overflow: hidden; font-size: var(--text-xs); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
192
192
  .pendingSize { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
193
193
  .remove {
194
- position: absolute; top: -6px; right: -6px; display: grid; width: 20px; height: 20px; place-items: center; padding: 0;
194
+ position: absolute; top: calc(var(--space-related) * -1); right: calc(var(--space-related) * -1); display: grid; width: 20px; height: 20px; place-items: center; padding: 0;
195
195
  border: 2px solid var(--surface); border-radius: 50%; corner-shape: round; background: var(--foreground); color: var(--background); cursor: pointer;
196
196
  }
197
197
 
198
- .inputRow { display: flex; align-items: flex-end; gap: 6px; }
198
+ .inputRow { display: flex; align-items: flex-end; gap: var(--space-related); }
199
199
  .attach {
200
200
  display: grid; width: 40px; height: 40px; flex: none; place-items: center; margin-bottom: 1px; padding: 0;
201
201
  border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--text-secondary); cursor: pointer;
@@ -216,7 +216,7 @@
216
216
  .field { position: relative; min-width: 0; flex: 1; overflow: hidden; border-radius: var(--radius-control); corner-shape: var(--corner-shape); }
217
217
  .mirror,
218
218
  .textarea {
219
- box-sizing: border-box; width: 100%; margin: 0; padding: 9px 6px 9px 16px; border: 0;
219
+ box-sizing: border-box; width: 100%; margin: 0; padding: var(--space-related) var(--space-related) var(--space-related) var(--space-inset); border: 0;
220
220
  font: inherit; font-size: var(--text-base); letter-spacing: var(--tracking-body); line-height: 22px;
221
221
  overflow-wrap: break-word; white-space: pre-wrap; word-break: break-word;
222
222
  }
@@ -225,7 +225,7 @@
225
225
  .textarea::placeholder { color: var(--text-muted); }
226
226
 
227
227
  .send {
228
- position: relative; display: grid; width: 32px; height: 32px; flex: none; place-items: center; margin: 0 4px 4px 0; overflow: hidden; padding: 0;
228
+ position: relative; display: grid; width: 32px; height: 32px; flex: none; place-items: center; margin: 0 var(--space-tight) var(--space-tight) 0; overflow: hidden; padding: 0;
229
229
  border: 0; border-radius: 50%; corner-shape: round; background: transparent; color: var(--text-muted); cursor: default;
230
230
  -webkit-tap-highlight-color: transparent;
231
231
  transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
@@ -237,6 +237,6 @@
237
237
  .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
238
238
 
239
239
  @media (max-width: 420px) {
240
- .content { padding-inline: 12px; }
240
+ .content { padding-inline: var(--space-group); }
241
241
  .stack { max-width: 84%; }
242
242
  }
@@ -9,7 +9,7 @@
9
9
  .box:active:not(:disabled) .visual { transform: scale(.95); transition: border-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
10
10
  .box:disabled { opacity: .5; cursor: not-allowed; }
11
11
  /* The label's first line centers on the square: (44px box - 19.6px line) / 2. */
12
- .copy { display: grid; gap: 2px; min-width: 0; margin-left: 1px; padding-top: calc((var(--control-height-md) - var(--text-sm) * 1.4) / 2); }
12
+ .copy { display: grid; gap: var(--space-hair); min-width: 0; margin-left: 1px; padding-top: calc((var(--control-height-md) - var(--text-sm) * 1.4) / 2); }
13
13
  .copy label { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; }
14
14
  .copy span { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
15
15
  @media (prefers-reduced-motion: reduce) { .visual, .box:active:not(:disabled) .visual { transition: none; transform: none; } }
@@ -9,7 +9,7 @@ const MORE = "\u0000more";
9
9
  const standard = [...motionTokens.ease.standard];
10
10
  const enterEase = [...motionTokens.ease.enter];
11
11
  const fade = { duration: motionTokens.duration.fast, ease: standard };
12
- const reducedFade = { duration: .15, ease: standard };
12
+ const reducedFade = { duration: motionTokens.duration.fast, ease: standard };
13
13
  const exitFast = { duration: motionTokens.duration.fast, ease: standard };
14
14
  const shown = { opacity: 1, y: "0em", scale: 1, filter: "blur(0px)" };
15
15
  const textIn = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
@@ -6,11 +6,11 @@
6
6
  /* The button is only the hit area and the layout box. What you see is the surface, which trails the box on a spring while the width morphs. */
7
7
  .chip { position: relative; display: inline-flex; max-width: 100%; padding: 0; border: 0; border-radius: var(--radius-full); corner-shape: round; background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; letter-spacing: var(--tracking-body); line-height: 20px; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
8
8
  /* Press answers on the body with the individual scale property, so it never fights the layout transform on the button. */
9
- .body { position: relative; isolation: isolate; display: inline-flex; min-width: 0; height: var(--control-height-sm); align-items: center; padding: 0 14px; transition: scale var(--duration-spring) var(--ease-spring); }
9
+ .body { position: relative; isolation: isolate; display: inline-flex; min-width: 0; height: var(--control-height-sm); align-items: center; padding: 0 var(--space-inset); transition: scale var(--duration-spring) var(--ease-spring); }
10
10
  .chip:active .body { scale: .97; transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
11
11
  .surface { position: absolute; z-index: -1; top: 0; bottom: 0; left: 0; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
12
12
  /* The check grows from its left edge where the label started; the slot hands its width to the label in one layout step, and the label's offset springs it over. */
13
- .check { position: absolute; top: calc(50% - 7px); left: 12px; display: grid; width: 14px; height: 14px; place-items: center; color: var(--accent-strong); transform-origin: 0 50%; }
13
+ .check { position: absolute; top: calc(50% - var(--space-related)); left: var(--space-group); display: grid; width: 14px; height: 14px; place-items: center; color: var(--accent-strong); transform-origin: 0 50%; }
14
14
  .check svg { display: block; overflow: visible; }
15
15
  .slot { width: 0; flex: none; }
16
16
  .body[data-selected="true"] .slot { width: 18px; }
@@ -23,21 +23,14 @@
23
23
  .chevron { display: grid; flex: none; place-items: center; color: var(--text-muted); }
24
24
  /* Each role has contrast in both themes; color supports, but never replaces, source text. */
25
25
  .comment { color: var(--text-muted); font-style: italic; }
26
- .string { color: oklch(46% .13 150); }
27
- .number { color: oklch(50% .14 55); }
28
- .keyword { color: oklch(48% .15 285); }
29
- .type { color: oklch(46% .13 230); }
30
- .function { color: oklch(43% .12 25); }
31
- .property { color: oklch(44% .12 85); }
32
- .tag { color: oklch(46% .13 230); }
26
+ .string { color: var(--syntax-string); }
27
+ .number { color: var(--syntax-number); }
28
+ .keyword { color: var(--syntax-keyword); }
29
+ .type { color: var(--syntax-type); }
30
+ .function { color: var(--syntax-function); }
31
+ .property { color: var(--syntax-property); }
32
+ .tag { color: var(--syntax-type); }
33
33
  .punctuation { color: var(--text-secondary); }
34
- :global(:root[data-theme="dark"]) .string { color: oklch(77% .15 150); }
35
- :global(:root[data-theme="dark"]) .number { color: oklch(80% .14 75); }
36
- :global(:root[data-theme="dark"]) .keyword { color: oklch(78% .16 285); }
37
- :global(:root[data-theme="dark"]) .type { color: oklch(79% .13 230); }
38
- :global(:root[data-theme="dark"]) .function { color: oklch(80% .13 25); }
39
- :global(:root[data-theme="dark"]) .property { color: oklch(79% .12 85); }
40
- :global(:root[data-theme="dark"]) .tag { color: oklch(79% .13 230); }
41
34
  @media (hover: hover) and (pointer: fine) { .expand:hover { background: var(--surface-muted); color: var(--foreground); } .expand:hover .chevron { color: var(--foreground); } }
42
35
  @media (max-width: 420px) { .header { padding-left: var(--space-group); } .header :global(button) { width: var(--control-height-sm); padding-inline: 0; } .header :global(button) > span:last-of-type { display: none; } .viewport { --code-collapsed: calc(var(--code-lines, 0) * var(--text-xs) * 1.7 + var(--space-inset) * 2); } .pre { padding: var(--space-inset); font-size: var(--text-xs); } }
43
36
  @media (prefers-reduced-motion: reduce) { .expand { transition: none; } }
@@ -11,7 +11,7 @@
11
11
  }
12
12
  :global(:root[data-theme="dark"]) .root { --picker-fill: var(--surface-raised); --checker: repeating-conic-gradient(oklch(42% 0 0) 0 25%, oklch(30% 0 0) 0 50%) 0 0 / 10px 10px; }
13
13
 
14
- .trigger, .head { display: flex; height: 44px; align-items: center; gap: 10px; padding: 6px 16px 6px 6px; }
14
+ .trigger, .head { display: flex; height: 44px; align-items: center; gap: var(--space-group); padding: var(--space-related) var(--space-inset) var(--space-related) var(--space-related); }
15
15
  .trigger {
16
16
  border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape); background: var(--picker-fill); box-shadow: var(--shadow-resting); color: inherit; font: inherit; cursor: pointer;
17
17
  -webkit-tap-highlight-color: transparent; transition: border-color var(--duration-fast) var(--ease-standard);
@@ -22,76 +22,75 @@
22
22
 
23
23
  /* A chip over a checkerboard, so opacity reads as opacity. */
24
24
  .chip { position: relative; display: block; width: 32px; height: 32px; flex: none; overflow: hidden; border-radius: 50%; corner-shape: round; background: var(--checker); }
25
- .chip > span { position: absolute; inset: 0; border-radius: inherit; corner-shape: inherit; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .1); }
25
+ .chip > span { position: absolute; inset: 0; border-radius: inherit; corner-shape: inherit; box-shadow: var(--edge-inset); }
26
26
 
27
27
  /* The floating layer. Its drop shadow follows the clipped shape, so the shadow grows with the panel. */
28
- .float { position: absolute; z-index: 20; top: -1px; left: -1px; filter: drop-shadow(0 18px 36px oklch(0% 0 0 / .12)) drop-shadow(0 2px 6px oklch(0% 0 0 / .06)); pointer-events: none; }
29
- :global(:root[data-theme="dark"]) .float { filter: drop-shadow(0 20px 44px oklch(0% 0 0 / .5)); }
28
+ .float { position: absolute; z-index: var(--layer-popover); top: -1px; left: -1px; filter: var(--shadow-drop); pointer-events: none; }
30
29
  .float[data-open] { pointer-events: auto; }
31
- .panel { width: var(--picker-width); padding-bottom: 14px; border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape); background: var(--picker-fill); will-change: clip-path; }
32
- .head { gap: 10px; padding-right: 6px; }
30
+ .panel { width: var(--picker-width); padding-bottom: var(--space-inset); border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape); background: var(--picker-fill); will-change: clip-path; }
31
+ .head { gap: var(--space-group); padding-right: var(--space-related); }
33
32
  .head .triggerText { flex: 1; }
34
- .headActions { display: flex; gap: 2px; }
33
+ .headActions { display: flex; gap: var(--space-hair); }
35
34
  .iconButton {
36
35
  display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: transparent; color: var(--text-secondary); cursor: pointer;
37
36
  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);
38
37
  }
39
38
  .iconButton:active { transform: scale(.94); }
40
39
  /* One column locked to the panel: a wider grade label ("AA large") must never widen the area and sliders mid drag. */
41
- .body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; padding: 8px 14px 0; }
40
+ .body { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-inset); padding: var(--space-related) var(--space-inset) 0; }
42
41
 
43
42
  /* Saturation runs left to right, brightness bottom to top, over the pure hue. */
44
43
  .area {
45
44
  position: relative; height: 164px; border-radius: var(--radius-control); corner-shape: var(--corner-shape); cursor: crosshair; touch-action: none;
46
- background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), var(--picker-hue);
47
- box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .08);
45
+ background: linear-gradient(to top, var(--color-black), transparent), linear-gradient(to right, var(--color-white), transparent), var(--picker-hue);
46
+ box-shadow: var(--edge-inset);
48
47
  }
49
48
  .areaThumb, .sliderThumb {
50
49
  position: absolute; width: 22px; height: 22px; border-radius: 50%; corner-shape: round; translate: -50% -50%; cursor: grab;
51
- box-shadow: 0 0 0 3px #fff, 0 2px 8px oklch(0% 0 0 / .3), inset 0 0 0 1px oklch(0% 0 0 / .12);
50
+ box-shadow: 0 0 0 3px var(--color-white), var(--shadow-knob), var(--edge-inset);
52
51
  transition: scale var(--duration-spring) var(--ease-spring);
53
52
  }
54
53
  .areaThumb { background: var(--picker-opaque); }
55
54
  .area[data-active] .areaThumb, .sliderThumb[data-active] { scale: 1.2; cursor: grabbing; }
56
55
 
57
- .slider { position: relative; height: 14px; margin: 0 11px; border-radius: var(--radius-full); corner-shape: round; cursor: pointer; touch-action: none; }
58
- .slider::before { content: ""; position: absolute; inset: 0 -11px; border-radius: inherit; corner-shape: inherit; background: var(--track); box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .08); }
56
+ .slider { position: relative; height: 14px; margin: 0 var(--space-group); border-radius: var(--radius-full); corner-shape: round; cursor: pointer; touch-action: none; }
57
+ .slider::before { content: ""; position: absolute; inset: 0 calc(var(--space-group) * -1); border-radius: inherit; corner-shape: inherit; background: var(--track); box-shadow: var(--edge-inset); }
59
58
  .sliderThumb { top: 50%; background: var(--checker); }
60
59
  .sliderThumb::after { content: ""; position: absolute; inset: 0; border-radius: inherit; corner-shape: inherit; background: var(--thumb-fill); }
61
60
  /* The gradient stops sit at the thumb's travel ends, so the color under the thumb is the value it reports. */
62
61
  .hue { --track: linear-gradient(to right, #f00 11px, #ff0 calc(11px + (100% - 22px) * .1666), #0f0 calc(11px + (100% - 22px) * .3333), #0ff 50%, #00f calc(11px + (100% - 22px) * .6666), #f0f calc(11px + (100% - 22px) * .8333), #f00 calc(100% - 11px)); }
63
62
  .alpha { --track: linear-gradient(to right, transparent 11px, var(--picker-opaque) calc(100% - 11px)), var(--checker); }
64
63
 
65
- .field { display: flex; height: 40px; align-items: stretch; overflow: hidden; margin-top: 2px; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface-muted); transition: border-color var(--duration-fast) var(--ease-standard); }
64
+ .field { display: flex; height: 40px; align-items: stretch; overflow: hidden; margin-top: var(--space-hair); border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface-muted); transition: border-color var(--duration-fast) var(--ease-standard); }
66
65
  .field[data-invalid] { border-color: var(--danger); }
67
66
  .format {
68
- display: flex; min-width: 56px; align-items: center; justify-content: center; padding: 0 10px; border: 0; border-right: 1px solid var(--border); background: transparent;
67
+ display: flex; min-width: 56px; align-items: center; justify-content: center; padding: 0 var(--space-group); border: 0; border-right: 1px solid var(--border); background: transparent;
69
68
  color: var(--foreground); font: inherit; font-size: var(--text-xs); font-weight: 500; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard);
70
69
  }
71
70
  .inputWrap { position: relative; display: flex; min-width: 0; flex: 1; }
72
- .input, .morph { padding: 0 10px; font: inherit; font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
71
+ .input, .morph { padding: 0 var(--space-group); font: inherit; font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
73
72
  .input { width: 100%; min-width: 0; border: 0; background: transparent; color: var(--foreground); caret-color: var(--foreground); }
74
73
  .input[data-morphing] { color: transparent; }
75
74
  .morph { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: var(--foreground); pointer-events: none; white-space: nowrap; }
76
- .error { min-height: 0; margin: -10px 2px -4px; color: var(--danger); font-size: var(--text-xs); }
75
+ .error { min-height: 0; margin: calc(var(--space-group) * -1) var(--space-hair) calc(var(--space-tight) * -1); color: var(--danger); font-size: var(--text-xs); }
77
76
  .error:empty { display: none; }
78
77
 
79
- .contrast { display: flex; align-items: center; gap: 10px; font-size: var(--text-sm); }
78
+ .contrast { display: flex; align-items: center; gap: var(--space-group); font-size: var(--text-sm); }
80
79
  .sample { display: grid; width: 36px; height: 28px; flex: none; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: var(--picker-bg); color: var(--picker-color); font-weight: 500; }
81
80
  .ratio { font-variant-numeric: tabular-nums; font-weight: 500; min-width: 4.4ch; }
82
81
  .against { flex: 1; min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
83
- .grade { position: relative; display: inline-grid; height: 22px; align-items: center; padding: 0 8px; border-radius: var(--radius-full); corner-shape: round; font-size: var(--text-xs); font-weight: 500; white-space: nowrap; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
82
+ .grade { position: relative; display: inline-grid; height: 22px; align-items: center; padding: 0 var(--space-related); border-radius: var(--radius-full); corner-shape: round; font-size: var(--text-xs); font-weight: 500; white-space: nowrap; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
84
83
  .grade[data-grade="pass"] { background: color-mix(in oklab, var(--success) 14%, transparent); color: var(--success); }
85
84
  .grade[data-grade="large"] { background: color-mix(in oklab, var(--warning) 16%, transparent); color: var(--warning); }
86
85
  .grade[data-grade="fail"] { background: color-mix(in oklab, var(--danger) 12%, transparent); color: var(--danger); }
87
86
 
88
- .swatches { display: flex; align-items: center; gap: 6px; min-height: 32px; }
87
+ .swatches { display: flex; align-items: center; gap: var(--space-related); min-height: 32px; }
89
88
  .add {
90
89
  display: grid; width: 30px; height: 30px; flex: none; place-items: center; padding: 0; border: 1px dashed var(--border-strong); border-radius: 50%; corner-shape: round; background: transparent; color: var(--text-secondary); cursor: pointer;
91
90
  transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);
92
91
  }
93
92
  .add:active { transform: scale(.92); }
94
- .swatchList { display: flex; min-width: 0; flex: 1; gap: 6px; }
93
+ .swatchList { display: flex; min-width: 0; flex: 1; gap: var(--space-related); }
95
94
  .swatchSlot { position: relative; flex: none; touch-action: pan-y; }
96
95
  .swatch { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 50%; corner-shape: round; background: transparent; cursor: pointer; }
97
96
  .swatchChip { width: 26px; height: 26px; transition: scale var(--duration-spring) var(--ease-spring); }
@@ -4,6 +4,7 @@ import { animate, AnimatePresence, motion, useReducedMotion } from "motion/react
4
4
  import { Check, ChevronDown, Search, X } from "lucide-react";
5
5
  import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
6
6
  import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { useNearestItem } from "../../lib/fluid-hover.js";
7
8
  import styles from "./combobox.module.css";
8
9
  /** Follows the listbox height with a critically damped spring, so filtering never snaps the menu. */
9
10
  function AutoHeight({ children, reduceMotion }) {
@@ -32,6 +33,9 @@ export const Combobox = forwardRef(function Combobox({ label, options, value: co
32
33
  const [open, setOpen] = useState(false);
33
34
  const [query, setQuery] = useState("");
34
35
  const [activeIndex, setActiveIndex] = useState(-1);
36
+ // Fluid hover: the mouse makes the nearest option active, gaps and padding included, and a click there picks it.
37
+ // The listbox keeps focus in the input, so a press in its padding never closes the popover.
38
+ const nearest = useNearestItem({ items: '[role="option"]', onPick: (item) => setActiveIndex(Number(item.dataset.index)) });
35
39
  const selectedValue = controlledValue ?? uncontrolledValue;
36
40
  const selectedOption = options.find((option) => option.value === selectedValue);
37
41
  useImperativeHandle(forwardedRef, () => inputRef.current);
@@ -162,6 +166,6 @@ export const Combobox = forwardRef(function Combobox({ label, options, value: co
162
166
  setQuery(event.target.value);
163
167
  setOpen(true);
164
168
  setActiveIndex(-1);
165
- }, onKeyDown: handleKeyDown }), _jsx(AnimatePresence, { initial: false, children: selectedOption && !disabled && (_jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear selection", onMouseDown: event => event.preventDefault(), onClick: clear, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, whileTap: { scale: reduceMotion ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), description && _jsx("span", { id: hintId, className: styles.hint, children: description }), _jsx(AnimatePresence, { initial: false, children: open && (_jsx(motion.div, { className: styles.popover, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { y: -4, scale: 0.98 }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, role: "presentation", children: _jsx(AutoHeight, { reduceMotion: reduceMotion, children: _jsx("div", { id: listboxId, className: styles.listbox, role: "listbox", "aria-label": `${label} options`, tabIndex: -1, children: filteredOptions.length ? filteredOptions.map((option, index) => (_jsxs("div", { ref: (element) => { optionRefs.current[option.value] = element; }, id: `${controlId}-option-${option.value}`, className: styles.option, "data-active": index === activeIndex ? "true" : undefined, "data-disabled": option.disabled ? "true" : undefined, role: "option", "aria-selected": option.value === selectedValue, "aria-disabled": option.disabled || undefined, onMouseDown: (event) => event.preventDefault(), onMouseEnter: () => !option.disabled && setActiveIndex(index), onClick: () => choose(option), children: [_jsx("span", { children: option.label }), option.value === selectedValue && _jsx(Check, { className: styles.check, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }, option.value))) : _jsx(motion.div, { className: styles.empty, role: "status", initial: reduceMotion ? false : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: emptyMessage }) }) }) })) })] }));
169
+ }, onKeyDown: handleKeyDown }), _jsx(AnimatePresence, { initial: false, children: selectedOption && !disabled && (_jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear selection", onMouseDown: event => event.preventDefault(), onClick: clear, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, whileTap: { scale: reduceMotion ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), description && _jsx("span", { id: hintId, className: styles.hint, children: description }), _jsx(AnimatePresence, { initial: false, children: open && (_jsx(motion.div, { className: styles.popover, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { y: -4, scale: 0.98 }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, role: "presentation", children: _jsx(AutoHeight, { reduceMotion: reduceMotion, children: _jsx("div", { id: listboxId, className: styles.listbox, role: "listbox", "aria-label": `${label} options`, tabIndex: -1, onMouseDown: (event) => event.preventDefault(), ...nearest, children: filteredOptions.length ? filteredOptions.map((option, index) => (_jsxs("div", { ref: (element) => { optionRefs.current[option.value] = element; }, id: `${controlId}-option-${option.value}`, className: styles.option, "data-active": index === activeIndex ? "true" : undefined, "data-disabled": option.disabled ? "true" : undefined, role: "option", "aria-selected": option.value === selectedValue, "aria-disabled": option.disabled || undefined, onMouseDown: (event) => event.preventDefault(), "data-index": index, onClick: () => choose(option), children: [_jsx("span", { children: option.label }), option.value === selectedValue && _jsx(Check, { className: styles.check, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }, option.value))) : _jsx(motion.div, { className: styles.empty, role: "status", initial: reduceMotion ? false : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: emptyMessage }) }) }) })) })] }));
166
170
  });
167
171
  Combobox.displayName = "Combobox";
@@ -15,11 +15,11 @@
15
15
  @media (hover: hover) and (pointer: fine) { .clear:hover { background: var(--surface-muted); color: var(--foreground); } }
16
16
  .clear:focus-visible { --focus-outline-offset: 1px; }
17
17
  .hint { color: var(--text-muted); font-size: var(--text-xs); }
18
- .popover { position: absolute; z-index: 80; top: calc(100% + 8px); right: 0; left: 0; overflow: hidden; transform-origin: top center; border: 1px solid var(--border); border-radius: var(--radius-container); padding: 5px; background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); will-change: transform, opacity; }
18
+ .popover { position: absolute; z-index: var(--layer-dropdown); top: calc(100% + var(--space-related)); right: 0; left: 0; overflow: hidden; transform-origin: top center; border: 1px solid var(--border); border-radius: var(--radius-container); padding: var(--space-tight); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); will-change: transform, opacity; }
19
19
  .autoHeight { overflow: hidden; }
20
20
  .listbox { max-height: min(300px, 40vh); overflow-y: auto; overscroll-behavior: contain; }
21
21
  /* Arrow keys move the highlight often, so it changes almost instantly. */
22
- .option { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: var(--space-group); border-radius: calc(var(--radius-container) - 5px); padding: 0 12px; color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; user-select: none; transition: background-color 80ms var(--ease-standard), color 80ms var(--ease-standard); }
22
+ .option { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: var(--space-group); border-radius: calc(var(--radius-container) - var(--space-tight)); padding: 0 var(--space-group); color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; user-select: none; transition: background-color 80ms var(--ease-standard), color 80ms var(--ease-standard); }
23
23
  .option > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
24
  .option[data-active="true"] { background: var(--surface-muted); }
25
25
  .option[data-disabled="true"] { opacity: .42; cursor: not-allowed; }
@@ -4,6 +4,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "re
4
4
  import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
5
5
  import { Command as CommandIcon, Search, X } from "lucide-react";
6
6
  import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { useNearestItem } from "../../lib/fluid-hover.js";
7
8
  import styles from "./command-palette.module.css";
8
9
  const enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
9
10
  const leave = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
@@ -74,6 +75,21 @@ export function CommandPalette({ items, placeholder = "Search commands", onSelec
74
75
  observer.observe(list);
75
76
  return () => observer.disconnect();
76
77
  }, []);
78
+ // Fluid hover: the mouse makes the nearest result active, group headings and padding included, and a click there runs it.
79
+ const nearest = useNearestItem({ items: '[role="option"]', onPick: item => {
80
+ const index = Number(item.dataset.index);
81
+ if (index !== safeActiveIndex) {
82
+ pointer.current = true;
83
+ setActiveIndex(index);
84
+ }
85
+ } });
86
+ function onListPointerMove(event) {
87
+ // Ignore the synthetic moves browsers send while the list scrolls under a still pointer.
88
+ if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y)
89
+ return;
90
+ lastPointer.current = { x: event.clientX, y: event.clientY };
91
+ nearest.onPointerMove(event);
92
+ }
77
93
  // One highlight follows the active result: a spring for the pointer, a 70ms slide for arrow keys, a fade when it first appears.
78
94
  useLayoutEffect(() => {
79
95
  const node = activeId ? document.getElementById(`${inputId}-${activeId}`) : null;
@@ -82,7 +98,7 @@ export function CommandPalette({ items, placeholder = "Search commands", onSelec
82
98
  animate(highlightOpacity, 0, { duration: reduced ? 0 : .1 });
83
99
  return;
84
100
  }
85
- const move = !highlightShown.current || reduced ? { duration: 0 } : pointer.current ? motionTokens.spring.snappy : { duration: .07, ease: [...motionTokens.ease.enter] };
101
+ const move = !highlightShown.current || reduced ? { duration: 0 } : pointer.current ? motionTokens.spring.snappy : { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.enter] };
86
102
  // Rows sit inside positioned groups, so their offset is summed up to the list (layout transforms stay out of it).
87
103
  let top = 0;
88
104
  for (let element = node; element && element !== listRef.current; element = element.offsetParent)
@@ -137,15 +153,6 @@ export function CommandPalette({ items, placeholder = "Search commands", onSelec
137
153
  onClose?.();
138
154
  }
139
155
  }
140
- return _jsxs(motion.div, { className: styles.palette, initial: reduced ? false : { opacity: 0, y: 6, scale: .98 }, animate: { opacity: 1, y: 0, scale: 1 }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsxs("div", { className: styles.searchRow, children: [_jsx("span", { className: styles.searchIcon, children: _jsx(Search, { width: 18, height: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("label", { className: styles.visuallyHidden, htmlFor: inputId, children: label }), _jsx("input", { ref: inputRef, id: inputId, role: "combobox", "aria-autocomplete": "list", "aria-expanded": "true", "aria-controls": `${inputId}-results`, "aria-activedescendant": activeId ? `${inputId}-${activeId}` : undefined, value: query, onChange: event => { setQuery(event.target.value); setActiveIndex(null); }, onKeyDown: handleKeyDown, placeholder: placeholder, autoComplete: "off" }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: query ? _jsx(motion.button, { className: styles.clearButton, type: "button", "aria-label": "Clear search", onClick: () => { setQuery(""); setActiveIndex(null); inputRef.current?.focus(); }, initial: reduced ? false : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: leave }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }, children: _jsx(X, { width: 15, height: 15, "aria-hidden": "true" }) }, "clear") : null }), onClose ? _jsx("button", { className: styles.closeButton, type: "button", "aria-label": "Close command palette", onClick: onClose, children: "Esc" }) : null, _jsx("kbd", { className: styles.commandKey, children: "\u2318 K" })] }), _jsx(motion.div, { className: styles.resultsFrame, initial: false, animate: { height: listHeight }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsxs(motion.div, { ref: listRef, layoutScroll: true, id: `${inputId}-results`, className: styles.results, role: "listbox", "aria-label": "Command results", children: [_jsx(motion.span, { className: styles.highlight, style: { y: highlightY, height: highlightHeight, opacity: highlightOpacity }, "aria-hidden": "true" }), _jsx(AnimatePresence, { mode: "wait", initial: false, children: filtered.length ? _jsx(motion.div, { initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: leave }, transition: enter, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: groupedItems.map(([group, entries]) => _jsxs(motion.div, { layout: glide ? "position" : false, className: styles.group, role: "group", "aria-label": group, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.groupHeading, "aria-hidden": "true", children: group }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: entries.map(({ item, index }) => _jsxs(motion.button, { layout: glide ? "position" : false, id: `${inputId}-${item.id}`, className: `${styles.result} ${index === safeActiveIndex ? styles.activeResult : ""}`, type: "button", role: "option", "aria-selected": index === safeActiveIndex, onClick: () => choose(item), onPointerMove: event => {
141
- // Ignore the synthetic moves browsers send while the list scrolls under a still pointer.
142
- if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y)
143
- return;
144
- lastPointer.current = { x: event.clientX, y: event.clientY };
145
- if (index !== safeActiveIndex) {
146
- pointer.current = true;
147
- setActiveIndex(index);
148
- }
149
- }, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: item.icon ?? _jsx(CommandIcon, { width: 16, height: 16 }) }), _jsxs("span", { className: styles.resultCopy, children: [_jsx("strong", { children: item.label }), item.description && _jsx("small", { children: item.description })] }), item.shortcut && _jsx("kbd", { className: styles.shortcut, children: item.shortcut })] }, item.id)) })] }, group)) }) }, "results") : _jsxs(motion.div, { className: styles.empty, initial: reduced ? false : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: leave }, transition: enter, children: [_jsx("span", { children: _jsx(Search, { width: 20, height: 20, "aria-hidden": "true" }) }), _jsx("strong", { children: "No matching actions" }), _jsx("small", { children: "Try a different word or clear the search." })] }, "empty") })] }) })] });
156
+ return _jsxs(motion.div, { className: styles.palette, initial: reduced ? false : { opacity: 0, y: 6, scale: .98 }, animate: { opacity: 1, y: 0, scale: 1 }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsxs("div", { className: styles.searchRow, children: [_jsx("span", { className: styles.searchIcon, children: _jsx(Search, { width: 18, height: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("label", { className: styles.visuallyHidden, htmlFor: inputId, children: label }), _jsx("input", { ref: inputRef, id: inputId, role: "combobox", "aria-autocomplete": "list", "aria-expanded": "true", "aria-controls": `${inputId}-results`, "aria-activedescendant": activeId ? `${inputId}-${activeId}` : undefined, value: query, onChange: event => { setQuery(event.target.value); setActiveIndex(null); }, onKeyDown: handleKeyDown, placeholder: placeholder, autoComplete: "off" }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: query ? _jsx(motion.button, { className: styles.clearButton, type: "button", "aria-label": "Clear search", onClick: () => { setQuery(""); setActiveIndex(null); inputRef.current?.focus(); }, initial: reduced ? false : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: leave }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }, children: _jsx(X, { width: 15, height: 15, "aria-hidden": "true" }) }, "clear") : null }), onClose ? _jsx("button", { className: styles.closeButton, type: "button", "aria-label": "Close command palette", onClick: onClose, children: "Esc" }) : null, _jsx("kbd", { className: styles.commandKey, children: "\u2318 K" })] }), _jsx(motion.div, { className: styles.resultsFrame, initial: false, animate: { height: listHeight }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsxs(motion.div, { ref: listRef, layoutScroll: true, id: `${inputId}-results`, className: styles.results, role: "listbox", "aria-label": "Command results", onPointerMove: onListPointerMove, onClick: nearest.onClick, children: [_jsx(motion.span, { className: styles.highlight, style: { y: highlightY, height: highlightHeight, opacity: highlightOpacity }, "aria-hidden": "true" }), _jsx(AnimatePresence, { mode: "wait", initial: false, children: filtered.length ? _jsx(motion.div, { initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: leave }, transition: enter, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: groupedItems.map(([group, entries]) => _jsxs(motion.div, { layout: glide ? "position" : false, className: styles.group, role: "group", "aria-label": group, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.groupHeading, "aria-hidden": "true", children: group }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: entries.map(({ item, index }) => _jsxs(motion.button, { layout: glide ? "position" : false, id: `${inputId}-${item.id}`, className: `${styles.result} ${index === safeActiveIndex ? styles.activeResult : ""}`, type: "button", role: "option", "aria-selected": index === safeActiveIndex, onClick: () => choose(item), "data-index": index, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: item.icon ?? _jsx(CommandIcon, { width: 16, height: 16 }) }), _jsxs("span", { className: styles.resultCopy, children: [_jsx("strong", { children: item.label }), item.description && _jsx("small", { children: item.description })] }), item.shortcut && _jsx("kbd", { className: styles.shortcut, children: item.shortcut })] }, item.id)) })] }, group)) }) }, "results") : _jsxs(motion.div, { className: styles.empty, initial: reduced ? false : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: leave }, transition: enter, children: [_jsx("span", { children: _jsx(Search, { width: 20, height: 20, "aria-hidden": "true" }) }), _jsx("strong", { children: "No matching actions" }), _jsx("small", { children: "Try a different word or clear the search." })] }, "empty") })] }) })] });
150
157
  }
151
158
  export default CommandPalette;