@apliteni/apliteni-ui 0.31.0 → 0.33.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.
@@ -80,14 +80,24 @@
80
80
  }
81
81
  .ui-input-group__icon svg { width: 100%; height: 100%; stroke: currentColor; fill: none; stroke-width: 2.2; }
82
82
 
83
+ /* The chevron is a data URI, and a data URI cannot read a token: `var()` does not
84
+ substitute inside url() and `currentColor` does not cross into the image's own
85
+ document. So the ink is written twice, once per theme, each the theme's --muted —
86
+ which is the colour every other quiet glyph on a field takes. It was one frozen
87
+ hex until #295, dark --muted's old value, which the ladder's re-pick left behind
88
+ at 2.49:1 on the light field. Held by stories/colour-tokens.test.js.
89
+ why: docs/specification.md#colour-and-contrast */
83
90
  .ui-select {
84
91
  appearance: none;
85
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23948fa8' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
92
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a29db6' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
86
93
  background-repeat: no-repeat;
87
94
  background-position: right 15px center;
88
95
  padding-right: 40px;
89
96
  cursor: pointer;
90
97
  }
98
+ :root[data-theme="light"] .ui-select {
99
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6270' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
100
+ }
91
101
 
92
102
  /* Checkbox / radio — custom, token-driven */
93
103
  .ui-check {
@@ -178,8 +188,13 @@
178
188
  width: calc(var(--sw-h) - 6px);
179
189
  height: calc(var(--sw-h) - 6px);
180
190
  background: var(--control-knob);
191
+ /* --shadow-sm was the knob's only edge until #295, and a white knob on a light
192
+ off-track has 1.20:1 to spare. --border-strong and not --border, which measures
193
+ 1.03:1 against that track and draws nothing — the checkbox above edges itself
194
+ the same way. why: docs/specification.md#elevation */
195
+ border: 1px solid var(--border-strong);
196
+ box-sizing: border-box;
181
197
  border-radius: 50%;
182
- box-shadow: var(--shadow-sm);
183
198
  transition: transform var(--dur-med) var(--ease);
184
199
  }
185
200
  .ui-switch input:checked + .ui-switch__track { background: var(--accent); }
@@ -16,6 +16,11 @@
16
16
  background: var(--bg);
17
17
  color: var(--text);
18
18
  font-family: var(--font-sans);
19
+ /* The rail's width, and the one property the fold moves: the nav's own column
20
+ plus the inset either side plus the rule, so the shell has no geometry of its
21
+ own to drift from the rows inside it. why: docs/specification.md#the-page-shell */
22
+ --ui-rail-rule: 1px;
23
+ --ui-rail-w: calc(var(--ui-nav-col) + 2 * var(--space-4) + var(--ui-rail-rule));
19
24
  }
20
25
  /* With a topbar above it, the shell starts below one. 52px is .topbar's height
21
26
  in topbar.css; the topbar sticks so the rail below it can too. */
@@ -33,21 +38,27 @@
33
38
  flex-direction: column;
34
39
  gap: var(--space-5);
35
40
  padding: var(--space-5) var(--space-4);
36
- border-right: 1px solid var(--border);
41
+ border-right: var(--ui-rail-rule) solid var(--border);
37
42
  background: var(--surface-2);
38
43
  position: sticky;
39
44
  top: var(--ui-app-top, 0px);
40
45
  height: calc(100vh - var(--ui-app-top, 0px));
46
+ width: var(--ui-rail-w);
47
+ /* hidden across, auto down: the fold clips what leaves the screen rather than
48
+ laying it out again. Two longhands, not the shorthand — the gates' CSS engine
49
+ does not read the pair. why: docs/specification.md#the-page-shell */
50
+ overflow-x: hidden;
41
51
  overflow-y: auto;
52
+ transition: width var(--dur-med) var(--ease);
42
53
  }
54
+ /* Each block of the rail keeps the open column while the box closes over it. The
55
+ nav declares its own; these are the two that are not the nav. */
56
+ .ui-app__head,
57
+ .ui-app__user { width: var(--ui-nav-col); flex: none; }
43
58
  /* The rail's hover WAS --surface-2, so moving the rail onto it would have made
44
- hovering a row do nothing. Scoped, so a sidebarNav() anywhere else is
45
- untouched and the destructive row is left out, because this rule ties with
46
- nav.css's danger wash on specificity and wins on source order alone. Sign out
47
- was quietly losing the --pink wash the guideline page cites nav.css for. The
48
- current row is left out for the same reason: this rule outranks nav.css's
49
- active hover, and painting --surface-3 over a row already resting on it is
50
- what made the row you are standing on the one row that ignores the pointer. */
59
+ hovering a row do nothing. The danger row and the current row are left out by
60
+ name, because this rule outranks the states nav.css writes for them.
61
+ why: CONTRIBUTING.md#a-rule-that-outranks-another-cancels-every-state-that-other-one-writes */
51
62
  .ui-app__rail .ui-nav__item:not(.is-danger):not(.is-active):hover { background: var(--surface-3); }
52
63
  .ui-app__brand {
53
64
  display: flex;
@@ -55,6 +66,10 @@
55
66
  gap: var(--space-2);
56
67
  padding: 2px 6px;
57
68
  text-decoration: none;
69
+ /* The lockup leaves on the words' clock, `visibility` with it so the link leaves
70
+ the tab order — `linear`, as every discrete property in the kit is.
71
+ why: docs/specification.md#the-page-shell */
72
+ transition: opacity var(--dur-fast) var(--ease), visibility var(--dur-fast) linear;
58
73
  /* display: brand — the rail's lockup is the same mark topbar.css sets, and the
59
74
  two have to be the same face or the shell reads as two products. */
60
75
  font-family: var(--font-display);
@@ -66,39 +81,61 @@
66
81
  .ui-app .ui-app__brand { color: var(--strong); }
67
82
  .ui-app__brand svg { display: block; flex: none; }
68
83
 
84
+ /* The head band: the product's mark, and the rail's own control at the far end of
85
+ the same line. `min-width: 0` so a long word shrinks the lockup rather than pushing
86
+ the control off the band. why: docs/specification.md#the-page-shell */
87
+ .ui-app__head {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: var(--space-2);
91
+ padding-bottom: var(--space-3);
92
+ border-bottom: 1px solid var(--border);
93
+ }
94
+ .ui-app__head > .ui-app__brand { min-width: 0; }
95
+
69
96
  /* The nav grows, so the reader block below it pins to the bottom of a full-height
70
- rail instead of hugging the last nav row, and the nav's own footer sign out
71
- sits directly above it. */
97
+ rail instead of hugging the last nav row. The nav's own footer slot is unused
98
+ sign out is in the reader's menu, because it ends a session rather than going
99
+ anywhere — so the rail's foot is the account block alone. */
72
100
  .ui-app__rail .ui-nav--side { flex: 1 1 auto; }
73
- .ui-app__rail .ui-nav__foot { margin-top: auto; }
74
101
 
75
102
  /* Which row is current is structural here, not chromatic: the resting glyphs
76
103
  step down so the current one is the brightest whatever --accent is doing, and
77
104
  the bar beside it carries the hue. A 3x17px bar on a 45x44 tile was the
78
105
  smallest of three weak signals doing the most work, so it grows with the row. */
79
- .ui-app__rail .ui-nav__ic svg { opacity: 0.62; }
106
+ /* Re-tuned at #295, which took the light rail down a step with the page. Below 720px
107
+ the glyph is the whole row, so it is held at text grade rather than at 1.4.11's bar —
108
+ stories/apps/shell-states.test.js:1340 `a resting glyph is dimmer but still legible`. */
109
+ .ui-app__rail .ui-nav__ic svg { opacity: 0.66; }
80
110
  .ui-app__rail .ui-nav__item.is-active .ui-nav__ic svg,
81
111
  .ui-app__rail .ui-nav__item.is-current .ui-nav__ic svg { opacity: 1; stroke: currentColor; } /* motion: still — the glyph brightens with its row, whose own highlight already transitions */
82
112
  .ui-app__rail .ui-nav__item.is-active::before { width: 3px; height: 62%; }
83
113
 
84
- /* Sign out is a navigation row and rests like one. The danger intent is the
85
- step nav.css writes for it, which a resting --muted was pre-empting — it made
86
- the last row of the rail the quietest thing in it. Both of nav.css's danger
87
- states are named here, not hover alone: this rule outranks them, so a state
88
- it forgets is a state it silently cancels. */
114
+ /* A destructive row of the rail is a navigation row and rests like one; the danger
115
+ intent is the step nav.css writes for it. Both of nav.css's danger states are
116
+ named, not hover alone.
117
+ why: CONTRIBUTING.md#a-rule-that-outranks-another-cancels-every-state-that-other-one-writes */
89
118
  .ui-app__rail .ui-nav__item.is-danger:not(:hover):not(:focus-visible) { color: var(--text); }
90
119
 
91
- /* One rule closes the rail, not two twenty pixels apart: the nav's own footer
92
- keeps its hairline and the reader block is separated by space alone it has
93
- an avatar and a weight change already. */
120
+ /* One rule closes the rail, and it is this one: the nav's footer slot is empty now
121
+ that sign out is in the menu this block opens, so the hairline that fenced sign
122
+ out off moved down onto the block it was fencing. Two of them twenty pixels apart
123
+ read as a third region of the rail rather than as its foot. */
94
124
  .ui-app__user {
95
125
  display: flex;
96
126
  align-items: center;
97
127
  gap: var(--space-2);
98
- padding: var(--space-5) var(--space-1) 0;
128
+ padding-top: var(--space-5);
129
+ border-top: 1px solid var(--border);
99
130
  }
131
+ /* The avatar is the reader block's own glyph, so half the difference between the
132
+ glyph column and itself is the inset that centres it on the line every glyph above
133
+ it stands on. The arithmetic and not the 5.5 it comes to: both numbers are declared
134
+ elsewhere. why: docs/specification.md#the-page-shell */
135
+ .ui-app { --ui-app-av: 30px; }
100
136
  .ui-app__av {
101
- width: 30px; height: 30px; flex: none;
137
+ width: var(--ui-app-av); height: var(--ui-app-av); flex: none;
138
+ margin-inline-start: calc((var(--ui-nav-strip) - var(--ui-app-av)) / 2);
102
139
  border-radius: var(--radius-pill);
103
140
  background: var(--surface-3);
104
141
  color: var(--strong);
@@ -112,6 +149,112 @@
112
149
  the rail column is max-content, so a long one widens the rail instead. */
113
150
  .ui-app__who span { display: block; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
114
151
 
152
+ /* -- the reader's menu ---------------------------------------------------- */
153
+ /* The account block is a dropdown() trigger, so it takes the rail's own row skin
154
+ rather than the dropdown's pill — in place it is the last row of the rail. The
155
+ dropdown's own ring is left standing. why: docs/specification.md#the-page-shell */
156
+ .ui-app__user .ui-dropdown { width: 100%; }
157
+ .ui-app .ui-app__user-trigger {
158
+ width: 100%;
159
+ gap: var(--space-2);
160
+ /* As tall as the mark inside it plus its own padding — 30 + 4 + 4, written out
161
+ rather than as the calc() it comes from, and held to that arithmetic by
162
+ stories/apps/shell-states.test.js.
163
+ why: CONTRIBUTING.md#an-unresolved-var-measures-nothing-and-reports-green */
164
+ min-height: 38px;
165
+ padding: var(--space-1) 0;
166
+ border: 0;
167
+ border-radius: var(--radius-sm);
168
+ background: none;
169
+ color: var(--text);
170
+ font-size: inherit;
171
+ font-weight: inherit;
172
+ letter-spacing: normal;
173
+ }
174
+ .ui-app .ui-app__user-trigger:hover { background: var(--surface-3); }
175
+ .ui-app__user-trigger .ui-app__who { flex: 1 1 auto; text-align: left; }
176
+ /* The panel is portalled onto <body>, so nothing here may hang off .ui-app: by the
177
+ time it is open it is no longer inside the rail that drew it.
178
+ why: docs/specification.md#the-dropdown-panel */
179
+ .ui-app__user-panel .ui-dropdown__head { display: flex; flex-direction: column; gap: 2px; }
180
+ .ui-app__user-panel .ui-dropdown__head b { display: block; color: var(--strong); font-size: 12.5px; font-weight: var(--weight-semibold); }
181
+ /* The address wraps rather than truncating: nothing bounds this line, so
182
+ `text-overflow` would have nothing to fire against — the inert pair
183
+ .ui-app__who's own comment warns about. `anywhere` keeps an address with no
184
+ break in it from widening the panel instead. */
185
+ .ui-app__user-panel .ui-dropdown__head span { display: block; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
186
+
187
+ /* The toggle's cell, at the far end of the brand row: the auto margin puts it there
188
+ with or without a wordmark beside it, and the offset below rides it back onto the
189
+ glyph column as the rail closes. `position: relative` and not a transform, because
190
+ the chip inside is `position: fixed`. why: docs/specification.md#the-page-shell */
191
+ .ui-app__fold-row {
192
+ flex: none;
193
+ margin-inline-start: auto;
194
+ position: relative;
195
+ inset-inline-start: 0;
196
+ transition: inset-inline-start var(--dur-med) var(--ease);
197
+ }
198
+ /* One box on the glyph column at both widths — --ui-nav-strip, the width nav.css
199
+ derives the closed rail from. `gap: 0` leaves that column the whole of the
200
+ content box, so the label below is squeezed to nothing until the chip rule lifts
201
+ it out. why: docs/specification.md#the-page-shell */
202
+ .ui-app__fold { width: var(--ui-nav-strip); gap: 0; }
203
+ /* The chip rule lifts the name out of the flow, and a 17px glyph is shorter than
204
+ the line the name occupied — so the button lost 0.39px on hover and moved every
205
+ row under it. The glyph box carries the line instead, in both states.
206
+ why: docs/specification.md#the-page-shell */
207
+ .ui-app__fold .ui-nav__ic { height: 1lh; }
208
+ .ui-app .ui-app__fold { color: var(--dim); }
209
+ .ui-app .ui-app__fold:hover,
210
+ .ui-app .ui-app__fold:focus-visible { color: var(--strong); }
211
+ /* The width's own clock, not the words', so the mark and the closing edge arrive
212
+ together; no `!important`, so the reduced-motion net still takes it. 6 is not a
213
+ number: shell.js draws the frame 3..21 and the seam at 9, and 6 mirrors it about
214
+ the centre. why: docs/specification.md#the-page-shell */
215
+ .ui-app__fold .ui-app__fold-seam { transition: transform var(--dur-med) var(--ease); }
216
+ .ui-app.is-collapsed .ui-app__fold .ui-app__fold-seam { transform: translateX(6px); }
217
+
218
+ /* -- The collapsed rail --------------------------------------------------- */
219
+ /* Each rule here has its twin in the 720px block below; nothing lays a row out
220
+ again. why: docs/specification.md#the-page-shell */
221
+ .ui-app.is-collapsed { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
222
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__cap,
223
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__label,
224
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__badge,
225
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__caret,
226
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-app__who,
227
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-dropdown__chevron,
228
+ :where(.ui-app.is-collapsed) .ui-app__brand span { opacity: 0; pointer-events: none; }
229
+ /* The toggle rides onto the glyph column — the two widths' own difference — and the
230
+ lockup goes whole, since that column is where its mark stood. The mark may only go
231
+ where the control arrives: below 720px the toggle is not drawn, so the 720px block
232
+ gives the lockup back rather than leaving an empty band with a hairline under it.
233
+ why: docs/specification.md#the-page-shell */
234
+ :where(.ui-app.is-collapsed) .ui-app__fold-row { inset-inline-start: calc(var(--ui-nav-strip) - var(--ui-nav-col)); }
235
+ :where(.ui-app.is-collapsed) .ui-app__brand { opacity: 0; visibility: hidden; pointer-events: none; }
236
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
237
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
238
+ /* The label IS the chip. `fixed` is the fallback placement, out of the rail's own
239
+ clip; the @supports branch below pins it to the row and is the only one that
240
+ survives a scroll. The toggle's selector is unscoped, because it is wordless at
241
+ both widths. why: docs/specification.md#the-page-shell */
242
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
243
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who,
244
+ .ui-app__rail .ui-app__fold:is(:hover, :focus-visible) > .ui-nav__label { position: fixed; left: calc(var(--ui-rail-w) + var(--space-2)); z-index: var(--z-dropdown); width: max-content; max-width: 15rem; margin-block: -7px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--surface-3); color: var(--strong); box-shadow: var(--shadow-md); opacity: 1; pointer-events: none; }
245
+ @supports (anchor-name: --a) and (anchor-scope: --a) {
246
+ .ui-app.is-collapsed,
247
+ .ui-app:has(.ui-app__fold) { anchor-scope: --ui-rail; }
248
+ :where(.ui-app.is-collapsed) .ui-app__rail,
249
+ .ui-app__rail:has(.ui-app__fold) { anchor-name: --ui-rail; }
250
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item,
251
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-app__user-trigger,
252
+ .ui-app__rail .ui-app__fold { anchor-name: --ui-rail-row; anchor-scope: --ui-rail-row; }
253
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
254
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who,
255
+ .ui-app__rail .ui-app__fold:is(:hover, :focus-visible) > .ui-nav__label { position-anchor: --ui-rail-row; left: calc(anchor(--ui-rail right) + var(--space-2)); top: anchor(top); bottom: anchor(bottom); align-self: center; margin-block: 0; }
256
+ }
257
+
115
258
  /* The reading column centres in the track it is given — without width: 100% it
116
259
  was hugging the rail, 519px of dead background on the right at 1728px wide.
117
260
  The width is what keeps the auto margins honest: an auto inline margin takes
@@ -163,45 +306,51 @@
163
306
  .ui-toolbar > .ui-input-group { flex: 1 1 6rem; min-width: 0; }
164
307
 
165
308
  /* -- The narrow rail ------------------------------------------------------ */
166
- /* Below this width the rail folds to an icon strip instead of disappearing.
167
- sidebarNav() names every row at every width, so folding the label out of view
168
- costs a screen reader nothing. Scoped to the shell's own rail a sidebarNav()
169
- somewhere else keeps its labels. The 44px floor is the touch target, and the
170
- column is that floor plus the rail's own padding and border measured, not
171
- guessed. Nothing here resizes the glyph: an icon-sizing rule inside a media
172
- query is one src/styles/icon-size.test.js cannot measure, and it says so. */
309
+ /* Below this width the rail folds to the icon strip instead of disappearing, and
310
+ it is the fold the reader's own press makes every rule here has its twin
311
+ above. Scoped to the shell's own rail; a sidebarNav() elsewhere keeps its
312
+ labels. Nothing here resizes a glyph: an icon-sizing rule inside a media query
313
+ is one src/styles/icon-size.test.js cannot measure, and it says so. */
173
314
  @media (max-width: 720px) {
174
- .ui-app { grid-template-columns: 58px 1fr; }
175
- .ui-app__rail { padding: var(--space-3) 6px; gap: var(--space-3); }
176
- .ui-app__rail .ui-nav--side { width: auto; }
315
+ .ui-app { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
316
+ /* The strip is the only layout this width has, so there is nothing to fold. */
317
+ .ui-app__rail .ui-app__fold-row { display: none; }
318
+ /* …and with it the band, when the band has nothing else in it: a head with no
319
+ wordmark is the toggle alone, so at this width it is padding and a hairline
320
+ drawn over nothing. The reader's fold cannot take this rule — there the toggle
321
+ IS drawn. why: docs/specification.md#the-page-shell */
322
+ .ui-app__head:not(:has(> .ui-app__brand)) { display: none; }
323
+ /* And where the band does hold the mark, the mark stays — at this width the press
324
+ that folded the rail on a desktop still reads out of the cookie, and the control
325
+ that would undo it is the one above. A head band faded to nothing here is an empty
326
+ 41px box over a rule, with no way home. The reader's fold cannot take this rule —
327
+ there the toggle lands on the column the mark gives up.
328
+ why: docs/specification.md#the-page-shell */
329
+ .ui-app__brand { opacity: 1; visibility: visible; pointer-events: auto; }
177
330
  .ui-app__rail .ui-nav__cap,
178
331
  .ui-app__rail .ui-nav__label,
179
332
  .ui-app__rail .ui-nav__badge,
180
333
  .ui-app__rail .ui-nav__caret,
181
334
  .ui-app__rail .ui-app__who,
182
- .ui-app__brand span { display: none; }
183
- .ui-app__rail .ui-nav__item { justify-content: center; gap: 0; padding: 0; min-height: 44px; }
184
- .ui-app__rail .ui-nav__item.is-active::before { left: 3px; }
185
- .ui-app__rail .ui-nav__foot { padding-top: var(--space-2); }
186
- .ui-app__brand { justify-content: center; padding: 2px 0; }
187
- .ui-app__user { justify-content: center; padding-left: 0; padding-right: 0; }
188
- /* A group's children are rail rows at this width too. The nested list is NOT
189
- folded away: wireNav() toggles the `hidden` attribute and nothing else, so
190
- a display:none here left the toggle announcing aria-expanded over a list it
191
- could not reach — and took the row carrying aria-current with it. What goes
192
- instead is the indent and its guide hairline; a 46px column has no room. */
193
- .ui-app__rail .ui-nav__sub { margin: 0; padding-left: 0; border-left: 0; }
194
- .ui-app__rail .ui-nav__item--sub.is-active::before { left: 3px; }
195
- /* sidebarNav() takes `icon` as optional at every level, and the glyph is the
196
- whole of a row on screen here — so a row without one, at any level, would be
197
- a blank 44px target. It gets a dot in the row's own ink instead; the
198
- aria-label names it either way. */
199
- .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after {
200
- content: "";
201
- width: 5px; height: 5px;
202
- border-radius: 50%;
203
- background: currentColor;
204
- opacity: 0.62;
335
+ .ui-app__rail .ui-dropdown__chevron,
336
+ .ui-app__brand span { opacity: 0; pointer-events: none; }
337
+ .ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
338
+ .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
339
+ /* The one line of this block that is deliberately NOT the reader's fold: a finger
340
+ is the only pointer this width has, so a row and the account block are held to
341
+ 44px. Neither floor can spread to the press, where growing the box is the one
342
+ thing the travel promises nothing does. why: docs/specification.md#the-page-shell */
343
+ .ui-app__rail .ui-nav__item,
344
+ .ui-app__rail .ui-app__user-trigger { min-height: 44px; }
345
+ .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
346
+ .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who { position: fixed; left: calc(var(--ui-rail-w) + var(--space-2)); z-index: var(--z-dropdown); width: max-content; max-width: 15rem; margin-block: -7px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--surface-3); color: var(--strong); box-shadow: var(--shadow-md); opacity: 1; pointer-events: none; }
347
+ @supports (anchor-name: --a) and (anchor-scope: --a) {
348
+ .ui-app { anchor-scope: --ui-rail; }
349
+ .ui-app__rail { anchor-name: --ui-rail; }
350
+ .ui-app__rail .ui-nav__item,
351
+ .ui-app__rail .ui-app__user-trigger { anchor-name: --ui-rail-row; anchor-scope: --ui-rail-row; }
352
+ .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
353
+ .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who { position-anchor: --ui-rail-row; left: calc(anchor(--ui-rail right) + var(--space-2)); top: anchor(top); bottom: anchor(bottom); align-self: center; margin-block: 0; }
205
354
  }
206
355
  .ui-app__main { padding: 28px var(--space-4) 56px; }
207
356
  .ui-app__main h1 { font-size: 25px; }
@@ -226,10 +375,12 @@
226
375
  z-index: 1;
227
376
  width: 100%;
228
377
  max-width: var(--panel-md);
229
- background: var(--surface);
378
+ /* Floats over a spotlit page, so it takes the floating step and the hairline.
379
+ why: docs/specification.md#elevation */
380
+ background: var(--bg-elevated);
381
+ border: 1px solid var(--border);
230
382
  border-radius: 22px;
231
383
  padding: 38px 36px 32px;
232
- box-shadow: var(--shadow-lg);
233
384
  }
234
385
  .ui-auth__brand { display: inline-flex; align-items: center; gap: 10px; margin-bottom: 26px; }
235
386
  .ui-auth__brand span { font-size: 15px; font-weight: 600; color: var(--strong); }
@@ -64,13 +64,13 @@
64
64
  to { opacity: 1; filter: blur(0); }
65
65
  }
66
66
 
67
- /* -- Micro-interactions ---------------------------------------------------- */
67
+ /* -- Micro-interactions ----------------------------------------------------
68
+ * .m-lift is the travel only; it cast --shadow-md until #295. */
68
69
  .m-lift {
69
- transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-med) var(--ease);
70
+ transition: transform var(--dur-fast) var(--ease);
70
71
  }
71
72
  .m-lift:hover {
72
73
  transform: translateY(-3px);
73
- box-shadow: var(--shadow-md);
74
74
  }
75
75
  .m-press {
76
76
  transition: transform var(--dur-instant) var(--ease-sharp);
@@ -9,12 +9,32 @@
9
9
  .ui-nav :where(ul, ol) { list-style: none; margin: 0; padding: 0; } /* floor, not ceiling */
10
10
 
11
11
  /* -- Sidebar rail -------------------------------------------------------- */
12
+ /* The rail's two widths, and the one place they are written. --ui-nav-col is the
13
+ open column. --ui-nav-strip is twice a row's own glyph centre — the row's
14
+ padding plus half a glyph — so a strip that wide has the glyph standing in the
15
+ middle of it, exactly where it stood in the open rail. That is what lets the
16
+ fold animate: the column keeps --ui-nav-col and is clipped to the strip, so
17
+ the width travels and nothing inside it is laid out again. layout.css adds the
18
+ shell rail's own inset to both. stories/apps/shell-states.test.js derives the
19
+ strip from the resolved cascade and refuses a number that has drifted from it.
20
+ Declared for the shell as well as the nav, because the shell's rail is this
21
+ nav with an inset around it, and two copies of 41 is the drift itself. */
22
+ .ui-nav--side,
23
+ .ui-app { --ui-nav-col: 216px; --ui-nav-strip: 41px; }
12
24
  .ui-nav--side {
13
25
  display: flex;
14
26
  flex-direction: column;
15
27
  gap: 4px;
16
- width: 216px;
28
+ width: var(--ui-nav-col);
29
+ /* The width is the only property the fold travels. Nothing is clipped here:
30
+ inside the shell it is the rail that clips, and a clip of its own would have
31
+ been a second scroll box cutting the rows off at the rail's height. */
32
+ transition: width var(--dur-med) var(--ease);
17
33
  }
34
+ /* Every block keeps the open column while the nav clips to the strip. Without
35
+ this each one stretches to whatever the nav is wide and every row inside it is
36
+ laid out again on each frame of the fold. */
37
+ .ui-nav--side > * { width: var(--ui-nav-col); flex: none; }
18
38
  .ui-nav__section + .ui-nav__section { margin-top: 14px; }
19
39
  .ui-nav__list { display: flex; flex-direction: column; gap: 2px; }
20
40
  .ui-nav__cap {
@@ -23,6 +43,9 @@
23
43
  font-weight: var(--weight-medium);
24
44
  color: var(--muted);
25
45
  padding: 4px 12px 8px;
46
+ /* Goes on the labels' clock — a section heading is words, and the fold takes
47
+ every word on the rail together. */
48
+ transition: opacity var(--dur-fast) var(--ease);
26
49
  }
27
50
 
28
51
  /* Item row — link, group toggle, and leaf share the same skin */
@@ -72,7 +95,15 @@
72
95
  .ui-nav__ic svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2.2; opacity: 0.72; }
73
96
  .ui-nav__item.is-active .ui-nav__ic svg,
74
97
  .ui-nav__item.is-current .ui-nav__ic svg { opacity: 1; stroke: var(--accent); } /* motion: still — the glyph brightens with its row, whose own highlight already transitions */
75
- .ui-nav__label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
98
+ .ui-nav__label {
99
+ flex: 1 1 auto; min-width: 0;
100
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
101
+ /* The words go before the closing edge reaches them, which is most of why the
102
+ fold is worth animating — a label cut through by that edge reads as the rail
103
+ eating it. --dur-fast against the width's --dur-med, as the reference times
104
+ its own. */
105
+ transition: opacity var(--dur-fast) var(--ease);
106
+ }
76
107
 
77
108
  /* Destructive row (sign out, revoke). Quiet at rest, --pink once a pointer or
78
109
  the keyboard reaches it — as .ui-btn--danger and .ui-dropdown__item do. */
@@ -111,6 +142,10 @@
111
142
  line-height: 1;
112
143
  background: var(--surface-3);
113
144
  color: var(--dim);
145
+ /* The count fades with the words. It is never lost: sidebarNav() spells it
146
+ into the row's aria-label, so "Pending 3" is what a reader hears at either
147
+ width. */
148
+ transition: opacity var(--dur-fast) var(--ease);
114
149
  }
115
150
  .ui-nav__badge.is-accent { background: var(--glow-purple); color: var(--accent); }
116
151
  .ui-nav__badge.is-live { background: var(--chip-success-fill); color: var(--chip-success-ink); }
@@ -135,7 +170,7 @@
135
170
  border-right: 1.6px solid var(--muted);
136
171
  border-bottom: 1.6px solid var(--muted);
137
172
  transform: rotate(-45deg);
138
- transition: transform var(--dur-fast) var(--ease);
173
+ transition: transform var(--dur-fast) var(--ease), opacity var(--dur-fast) var(--ease);
139
174
  }
140
175
  .ui-nav__group.is-open > .ui-nav__toggle .ui-nav__caret { transform: rotate(45deg); }
141
176
  .ui-nav__sub {
@@ -164,16 +199,59 @@
164
199
  /* Footer slot (divider + trailing links, e.g. sign-out) */
165
200
  .ui-nav__foot { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
166
201
 
167
- /* Collapsed (icon-only) rail */
168
- .ui-nav--side.is-collapsed { width: 60px; align-items: stretch; }
202
+ /* A row with no glyph, on a rail folded to its glyphs, would be a blank target.
203
+ It gets a dot on the glyph column instead — out of flow, so the open rail's
204
+ labels stay where they are, and resting at nothing, so what the fold does is
205
+ raise it rather than conjure it. 18px is the row's own 12px padding plus half
206
+ a 17px glyph less half the dot: the column the glyph beside it stands on.
207
+ sidebarNav() takes `icon` as optional at every level, sub rows included. */
208
+ .ui-nav--side .ui-nav__item:not(:has(.ui-nav__ic))::after {
209
+ content: "";
210
+ position: absolute;
211
+ left: 18px; top: 50%;
212
+ width: 5px; height: 5px;
213
+ margin-top: -2.5px;
214
+ border-radius: 50%;
215
+ background: currentColor;
216
+ opacity: 0;
217
+ transition: opacity var(--dur-fast) var(--ease);
218
+ }
219
+
220
+ /* The nested list's indent, which the strip has no room for. It travels with the
221
+ width rather than vanishing under it: the guide hairline thins to nothing and
222
+ the children walk back onto the glyph column their parent stands on. */
223
+ .ui-nav__sub {
224
+ transition: margin-inline-start var(--dur-med) var(--ease),
225
+ padding-inline-start var(--dur-med) var(--ease),
226
+ border-inline-start-width var(--dur-med) var(--ease);
227
+ }
228
+
229
+ /* Collapsed (icon-only) rail — the same fold the shell's rail makes, drawn once
230
+ rather than pressed: the column keeps its width and the nav clips to the
231
+ strip, so no row is laid out again and no glyph moves. A group's children stay
232
+ rows here, because wireNav() toggles the list's `hidden` attribute and nothing
233
+ else, so folding .ui-nav__sub away left the toggle announcing a list it could
234
+ not open. What goes is the indent and the words, and sidebarNav({ collapsed })
235
+ hands each row its name as a `title` so a pointer can still read it. The
236
+ shell's rail draws a chip of its own instead, on keyboard focus as well — see
237
+ layout.css. */
238
+ .ui-nav--side.is-collapsed {
239
+ width: var(--ui-nav-strip);
240
+ /* clip, not hidden: a standalone rail has nothing around it to clip for it,
241
+ and `hidden` would make this a scroll box in both axes and cut the rows off
242
+ at whatever height it was given. `clip` across leaves `visible` down alone,
243
+ which is the one pair that does. */
244
+ overflow-x: clip;
245
+ overflow-y: visible;
246
+ }
169
247
  .ui-nav--side.is-collapsed .ui-nav__cap,
170
248
  .ui-nav--side.is-collapsed .ui-nav__label,
171
249
  .ui-nav--side.is-collapsed .ui-nav__badge,
172
- .ui-nav--side.is-collapsed .ui-nav__caret { display: none; } /* motion: still — folding the rail is a layout change of the whole rail; animating these would reflow the page */
173
- .ui-nav--side.is-collapsed .ui-nav__item { justify-content: center; padding: 11px 0; gap: 0; }
174
- .ui-nav--side.is-collapsed .ui-nav__ic svg { width: 19px; height: 19px; stroke-width: 2; }
175
- .ui-nav--side.is-collapsed .ui-nav__item.is-active::before { left: 3px; }
176
- .ui-nav--side.is-collapsed .ui-nav__foot { text-align: center; }
250
+ .ui-nav--side.is-collapsed .ui-nav__caret { opacity: 0; pointer-events: none; }
251
+ .ui-nav--side.is-collapsed .ui-nav__sub {
252
+ margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0;
253
+ }
254
+ .ui-nav--side.is-collapsed .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
177
255
 
178
256
  /* -- Horizontal tabs ----------------------------------------------------- */
179
257
  .ui-nav--tabs {
@@ -4,6 +4,7 @@
4
4
  * --single one page of content: the size control alone, no steps
5
5
  * The controls are .ui-btn ghost/sm and the size control is a .ui-select, so
6
6
  * everything here is layout and the two states those sheets do not own.
7
+ * why: CONTRIBUTING.md#pagination-layout
7
8
  * ========================================================================== */
8
9
 
9
10
  .ui-pager {
@@ -30,8 +30,7 @@
30
30
  .ui-seg button[aria-pressed="true"],
31
31
  .ui-seg button[aria-selected="true"] {
32
32
  color: var(--strong);
33
- background: var(--seg-active-bg);
34
- box-shadow: var(--shadow-seg);
33
+ background: var(--seg-active-bg); /* the step is the lift; nothing casts. #295 */
35
34
  }
36
35
 
37
36
  .ui-seg--sm button { padding: 5px 13px; font-size: 12px; }