@visns-studio/visns-components 6.3.16 → 6.3.18

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.
@@ -1,24 +1,36 @@
1
1
  @use 'controls' as *;
2
2
 
3
+ /* ============================================================================
4
+ Profile — density hooks
5
+ ----------------------------------------------------------------------------
6
+ The screen is an account-settings page: a tab rail on the left, and a panel
7
+ whose tabs are a stack of titled sections rather than one flat form. Each
8
+ section is the classic settings two-column — what the section is on the
9
+ left, the controls on the right — so the panel's right half is used rather
10
+ than left empty.
11
+
12
+ Every measurement below is written `var(--hook, <the value it always had>)`,
13
+ so an unset hook is a no-op and a project retunes the page from `:root`
14
+ without forking the component:
15
+
16
+ --profile-rail-width the tab rail column. default: 14rem
17
+ --profile-content-width the measure the sections and the footer share,
18
+ so Save lands under the right edge of the
19
+ fields. default: 68rem
20
+ --profile-aside-width the section's title/description column.
21
+ default: 18rem
22
+ --profile-signature-width the signature pad. default: 29rem
23
+
24
+ Literal fallbacks throughout: `--spacing-*`, `--font-size-*`, `--radius-*`,
25
+ `--muted-color` and `--surface-color` are not in every consuming project's
26
+ token set, and a bare `var()` for a token that does not exist is invalid at
27
+ computed-value time — the whole declaration is dropped, which is how a form
28
+ like this silently loses its padding.
29
+ ========================================================================= */
30
+
3
31
  .btn {
4
32
  @include button-primary;
5
33
  }
6
- /* Profile.module.scss */
7
-
8
- /* Form field label animation */
9
- input:not(:placeholder-shown) + .fi__span,
10
- textarea:not(:placeholder-shown) + .fi__span,
11
- select:not(:placeholder-shown) + .fi__span,
12
- input:focus + .fi__span,
13
- textarea:focus + .fi__span,
14
- select:focus + .fi__span {
15
- transform: translateY(-40%) translateX(10px) scale(0.9);
16
- opacity: 1;
17
- padding: 0 0.25rem;
18
- background: var(--tertiary-color);
19
- font-weight: 400;
20
- border-radius: var(--br);
21
- }
22
34
 
23
35
  .grid {
24
36
  width: 100%;
@@ -50,260 +62,406 @@ select:focus + .fi__span {
50
62
  box-shadow: 0 10px 50px rgba(var(--primary-rgb), 0.05);
51
63
  }
52
64
 
65
+ /* The rail/panel row.
66
+
67
+ It was a wrapping flex row of `flex: 1` against `flex: 0 1 80%` — two
68
+ percentage bases plus a gap, which is the arrangement that puts the panel's
69
+ right edge past the page's right padding while the rail keeps its left one,
70
+ and the reason the page read as lopsided. Two grid tracks always sum to the
71
+ container, gap included, so the two cards sit inside the same gutters. */
53
72
  .grid__subrow {
54
73
  width: 100%;
55
- display: flex;
56
- flex-wrap: wrap;
57
- height: auto;
58
- gap: 0.35rem;
74
+ display: grid;
75
+ grid-template-columns:
76
+ minmax(0, var(--profile-rail-width, 14rem))
77
+ minmax(0, 1fr);
78
+ /* The rail is as tall as its two tabs. `stretch` — the flex and grid
79
+ default — ran it the full height of the panel beside it, so a card
80
+ holding two links was most of a screen of empty white. */
81
+ align-items: start;
82
+ gap: var(--spacing-md, 1rem);
83
+
84
+ @media (max-width: 900px) {
85
+ grid-template-columns: minmax(0, 1fr);
86
+ }
59
87
  }
60
88
 
89
+ /* The tab rail. Same spec as `.grid__subnav` in GenericIndex/GenericDetail —
90
+ a flat surface, a hairline, and the framing of the items left to
91
+ TableFilter's own stylesheet, which is what actually draws them.
92
+
93
+ Deliberately not sticky: neither of the two pages that share this rail is,
94
+ and the app's nav bar is itself `position: sticky` at `top: 0` on a higher
95
+ z-index, so a rail stuck to the top of the viewport would slide underneath
96
+ it. */
61
97
  .grid__subnav {
62
- flex: 1;
63
- background: white;
98
+ background: var(--surface-color, #fff);
99
+ border: 1px solid var(--border-color, #e1e1e1);
64
100
  border-radius: var(--br);
65
- border: 1px solid var(--border-color);
66
101
  box-sizing: border-box;
67
- padding: 5px;
102
+ padding: var(--spacing-xs, 0.25rem);
68
103
  margin: 8px 0;
69
104
  height: auto;
70
- box-shadow: 0 10px 50px rgba(var(--primary-rgb), 0.05);
71
- }
72
-
73
- .grid__subnav > ul {
74
- width: 100%;
75
- list-style: none;
76
- padding: 0;
77
- margin: 0;
78
- display: flex;
79
- flex-wrap: wrap;
80
- }
81
-
82
- .grid__subnav > ul li {
83
- width: 100%;
84
- margin-bottom: 3px;
105
+ box-shadow: none;
85
106
  }
86
107
 
87
- .grid__subnav > ul li a {
88
- width: 100%;
89
- padding: 0.35rem 1rem;
90
- box-sizing: border-box;
91
- display: block;
92
- text-decoration: none;
93
- background: rgba(var(--primary-rgb), 0.05);
94
- color: var(--primary-color);
95
- border-radius: 5px;
96
- transition: background 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
97
- outline: none;
98
- cursor: pointer;
99
- }
100
-
101
- .grid__subnav > ul li:hover a {
102
- background: var(--primary-color);
103
- color: var(--tertiary-color);
104
- }
105
-
106
- .grid__subnav > ul li .subactive {
107
- font-weight: 700;
108
- background: var(--primary-color);
109
- color: var(--tertiary-color);
108
+ /* `.tableFilterContainer` carries a 20px bottom margin, which is the right
109
+ amount of room under a rail that runs the height of the page and dead space
110
+ under one that hugs its two items. */
111
+ .grid__subnav > div {
112
+ margin-bottom: 0;
110
113
  }
111
114
 
112
115
  .grid__subcontent {
113
- flex: 0 1 80%;
114
- background: var(--tertiary-color);
116
+ background: var(--surface-color, #fff);
115
117
  border-radius: var(--br);
116
- border: 1px solid rgba(var(--primary-rgb), 0.05);
118
+ border: 1px solid var(--border-color, #e1e1e1);
117
119
  box-sizing: border-box;
118
- padding: 2px;
120
+ /* The card has none of its own: its header, sections and footer each own
121
+ their padding, and a couple of pixels here left the header's hairline
122
+ floating short of the card edge. */
123
+ padding: 0;
119
124
  margin: 8px 0;
125
+ min-width: 0;
120
126
  height: auto;
121
127
  position: relative;
122
128
  }
123
129
 
124
- .grid__subcontent h2 {
125
- color: var(--primary-color);
126
- font-size: 1.35em;
127
- padding: 0;
128
- margin: 0 0 30px 0;
130
+ /* The tab panel: header, sections, footer. The card around it has no padding
131
+ of its own, so each of those three owns its inset — which is what lets the
132
+ header's and the footer's hairlines run the full width of the card while the
133
+ sections in between stay on a measure. */
134
+ .gridtxt {
135
+ display: flex;
136
+ flex-direction: column;
129
137
  }
130
138
 
139
+ /* The panel header. Same spec as the unified page header documented at the top
140
+ of GenericIndex.module.scss — the panel's own surface, one bottom hairline,
141
+ the title left-aligned on the padding the body below it uses, one step down
142
+ in type size because this names a tab inside a page rather than the page. */
131
143
  .gridtxt__header {
132
144
  width: 100%;
133
- display: block;
134
- background: rgba(var(--primary-rgb), 0.05);
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ gap: var(--spacing-md, 1rem);
135
149
  box-sizing: border-box;
136
- padding: 10px 20px;
137
- border-radius: var(--br);
150
+ padding: var(--spacing-md, 1rem) var(--spacing-lg, 1.5rem);
151
+ background: none;
152
+ border-bottom: 1px solid var(--border-color, #e1e1e1);
153
+ border-radius: 0;
138
154
  margin-bottom: 0;
139
- font-weight: 700;
140
- text-align: center;
155
+ text-align: left;
141
156
  color: var(--paragraph-color);
142
157
  }
143
158
 
144
159
  .gridtxt__header span {
145
160
  font-weight: 700;
161
+ font-size: var(--font-size-lg, 1.125rem);
162
+ line-height: 1.25;
146
163
  }
147
164
 
148
- .gridtxt > ul {
149
- width: 100%;
150
- display: flex;
151
- justify-content: flex-start;
152
- flex-direction: row;
153
- flex-wrap: wrap;
154
- list-style: none;
165
+ /* --- Sections -------------------------------------------------------------
166
+ A settings tab is a list of decisions, not one continuous form. Each of them
167
+ gets a title, a line saying what it is for, and only the controls that
168
+ belong to it; a hairline separates one from the next.
169
+
170
+ The two columns are what keeps the panel from having an empty right half:
171
+ the prose that used to have nowhere to live now occupies the left third, and
172
+ the fields take a measure a name actually wants rather than 600px of empty
173
+ box. */
174
+ .form {
155
175
  box-sizing: border-box;
156
- padding: 0.85rem;
157
- margin: 0;
176
+ max-width: var(--profile-content-width, 68rem);
177
+ padding: 0 var(--spacing-lg, 1.5rem);
158
178
  }
159
179
 
160
- .gridtxt > ul li {
161
- flex: 0 0 calc(50% - 10px);
162
- padding: 0.85rem;
163
- box-sizing: border-box;
164
- border: 1px solid rgba(var(--primary-rgb), 0.095);
165
- color: var(--paragraph-color);
166
- background: rgba(var(--primary-rgb), 0.015);
167
- margin: 5px;
168
- border-radius: var(--br);
169
- line-height: 1.45;
170
- font-size: 1rem;
180
+ .section {
181
+ display: grid;
182
+ grid-template-columns:
183
+ minmax(0, var(--profile-aside-width, 18rem))
184
+ minmax(0, 1fr);
185
+ align-items: start;
186
+ gap: var(--spacing-md, 1rem) var(--spacing-xl, 2rem);
187
+ padding: var(--spacing-lg, 1.5rem) 0;
188
+
189
+ @media (max-width: 900px) {
190
+ grid-template-columns: minmax(0, 1fr);
191
+ gap: var(--spacing-sm, 0.5rem);
192
+ }
171
193
  }
172
194
 
173
- .gridtxt > ul .fw-grid-item {
174
- flex: 0 0 calc(100% - 10px);
195
+ .section + .section {
196
+ border-top: 1px solid var(--border-color, #e1e1e1);
175
197
  }
176
198
 
177
- .gridtxt > ul .notecolor {
178
- border: 1px solid var(--secondary-color);
199
+ .sectionAside {
200
+ min-width: 0;
179
201
  }
180
202
 
181
- .formcontainer {
182
- width: 100%;
203
+ .sectionTitle {
204
+ margin: 0;
205
+ font-size: var(--font-size-md, 0.9375rem);
206
+ font-weight: var(--font-weight-semibold, 600);
207
+ line-height: 1.3;
208
+ letter-spacing: 0;
209
+ color: var(--heading-color, #10192a);
183
210
  }
184
211
 
185
- .modalForm {
186
- width: 100%;
212
+ .sectionDescription {
213
+ margin: 0.35rem 0 0;
214
+ max-width: 42ch;
215
+ font-size: var(--font-size-sm, 0.8125rem);
216
+ line-height: 1.55;
217
+ color: var(--muted-color, #6b7285);
218
+ }
219
+
220
+ .sectionFields {
187
221
  display: flex;
222
+ flex-direction: column;
188
223
  align-items: flex-start;
189
- flex-wrap: wrap;
224
+ gap: var(--spacing-md, 1rem);
225
+ min-width: 0;
190
226
  }
191
227
 
228
+ /* Two fields on a line where the column is wide enough for two, one where it
229
+ is not. `auto-fit` measures the column it is actually in, which a media
230
+ query on the viewport cannot do — the fields column is two-thirds of the
231
+ panel, and the panel is a fraction of the page. */
232
+ .fieldPair {
233
+ width: 100%;
234
+ display: grid;
235
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
236
+ align-items: start;
237
+ gap: var(--spacing-md, 1rem);
238
+ }
239
+
240
+ /* --- Fields ---------------------------------------------------------------
241
+ The notched floating label — a `<span>` absolutely positioned inside the
242
+ box, sliding up into a gap in the border once the field held something —
243
+ was the last of that treatment in the library, and it read a decade older
244
+ than every other control on the page. The label is a line of text above the
245
+ box now, on the same uppercase/semibold/muted spec the library gives a small
246
+ label everywhere else (`h5`/`h6` in global.css, the section headings in
247
+ GenericDetail).
248
+
249
+ Profile owns these classes outright: nothing here reaches the shared
250
+ `Field`/`Form` components, whose floating treatment is left exactly as it
251
+ is. */
192
252
  .formItem {
193
- width: 50%;
194
- padding: 0.35em;
195
- position: relative;
253
+ display: flex;
254
+ flex-direction: column;
255
+ gap: 0.35rem;
256
+ min-width: 0;
196
257
  box-sizing: border-box;
258
+ }
197
259
 
198
- @media (max-width: 768px) {
199
- width: 100%;
260
+ .fieldLabel {
261
+ font-size: var(--font-size-xs, 0.75rem);
262
+ font-weight: var(--font-weight-semibold, 600);
263
+ letter-spacing: 0.06em;
264
+ text-transform: uppercase;
265
+ line-height: 1.3;
266
+ color: var(--muted-color, #6b7285);
267
+ }
268
+
269
+ /* Why the `!important`s.
270
+ ----------------------------------------------------------------------------
271
+ `global.css` — which this component imports — dresses every text input on
272
+ the page through
273
+
274
+ textarea, select,
275
+ input[type]:not([type='search'])…:not(.react-datepicker__year-select)
276
+
277
+ Thirteen `:not()`s put that selector at specificity (0, 14, 1). A CSS module
278
+ can only ever produce one hashed class, (0, 1, 0), so *every* property that
279
+ rule sets — background, border, radius, padding, font-size, line-height,
280
+ box-shadow — lands on these fields no matter what is written here, and the
281
+ only way a scoped stylesheet can restate one is `!important`. That is the
282
+ sole reason for them below; a property the global rule does not touch
283
+ (`min-height`, `font-family`, `::placeholder`) is left plain and comes from
284
+ the shared `input-base` mixin as usual.
285
+
286
+ This is also why the previous pass's field styling never showed: it was
287
+ correct CSS that never won. Fixing it at the source means rewriting a
288
+ selector every component in the library is sitting under, which is a
289
+ separate job. */
290
+ .input {
291
+ /* The shared control shape: one height, one radius, one border, the same
292
+ as every other input in 6.3. */
293
+ @include input-base;
294
+
295
+ padding: 0 0.65rem !important;
296
+ border: 1px solid var(--border-color) !important;
297
+ border-radius: var(--radius-sm) !important;
298
+ background: var(--paper) !important;
299
+ font-size: var(--font-size-md) !important;
300
+ line-height: 1.25 !important;
301
+
302
+ &:hover:not(:disabled):not([readonly]):not(.inputError) {
303
+ border-color: var(--border-color-dark) !important;
200
304
  }
201
- }
202
305
 
203
- .fi__label {
204
- position: relative;
205
- display: block;
206
- }
306
+ &:focus {
307
+ outline: none;
308
+ border-color: var(--primary-color) !important;
309
+ /* A ring rather than a thicker border, so nothing shifts on focus. */
310
+ box-shadow: var(--focus-ring, 0 0 0 3px rgba(0, 118, 192, 0.32)) !important;
311
+ }
207
312
 
208
- .fi__span {
209
- position: absolute;
210
- transition: all 200ms;
211
- opacity: 1;
212
- left: 0;
213
- padding: 19px 20px;
214
- transform-origin: top left;
215
- cursor: text;
313
+ /* Email is filled from the profile and only an administrator can change
314
+ it. Same surface the shared mixin gives a disabled control, so it reads
315
+ as "not yours to edit" rather than as a field that has gone wrong — but
316
+ the value keeps full-strength ink, because it is still information the
317
+ user came here to read. */
318
+ &[readonly] {
319
+ background: var(--bg-color-secondary, #f4f5f7) !important;
320
+ cursor: default;
321
+ }
216
322
  }
217
323
 
218
- .prefocus {
219
- transform: translateY(-40%) translateX(10px) scale(0.75);
220
- opacity: 1;
221
- padding: 0 4px;
222
- background: var(--tertiary-color);
223
- font-weight: 300;
224
- border-radius: var(--br);
225
- z-index: 500;
324
+ /* Set from JS as `styles.inputError`, appended to the input's class list. It is
325
+ written as a compound so it outranks `.input:focus` above — an invalid field
326
+ that has been focused to correct it should still look invalid. */
327
+ .input.inputError {
328
+ border-color: var(--danger-color, #e74c3c) !important;
329
+ box-shadow: 0 0 0 3px var(--danger-light, rgba(231, 76, 60, 0.14)) !important;
330
+ }
331
+
332
+ /* --- Password strength ---------------------------------------------------
333
+ `react-password-strength-bar` styles its own markup inline, so a class can
334
+ only reach it with `!important` — that is what every rule below is for, and
335
+ the only reason for it. The bar itself is four flex children; the score word
336
+ is the `<p>` after them.
337
+
338
+ The meter is rendered only once the field has a value (see Profile.jsx), so
339
+ nothing here has to hide the "too short" state that used to sit under an
340
+ empty password box telling the user off before they had typed. */
341
+ .strengthMeter {
342
+ /* Sits directly under the input it measures, close enough to read as part
343
+ of the field rather than as a rule floating beneath it. The distance is
344
+ `.formItem`'s own gap — this only cancels the library's default so the
345
+ two do not stack. */
346
+ margin-top: 0 !important;
347
+
348
+ /* The segments: 2px hairlines with 4px of white between them read as four
349
+ unrelated underlines. A little more weight and a rounded cap makes them
350
+ one meter. */
351
+ div > div {
352
+ height: 4px !important;
353
+ border-radius: var(--radius-pill, 999px) !important;
354
+ }
226
355
  }
227
356
 
228
- .input,
229
- .textarea,
230
- .select {
231
- background: var(--item-color);
232
- border-radius: var(--br);
233
- border: 1px solid transparent;
234
- padding: 1rem;
235
- outline: none;
357
+ .strengthWord {
358
+ margin: 0.3rem 0 0 !important;
359
+ font-size: var(--font-size-xs, 0.75rem) !important;
360
+ line-height: 1.2 !important;
361
+ color: var(--muted-color, #6b7688) !important;
362
+ text-align: right !important;
363
+ }
364
+
365
+ /* --- Signature ------------------------------------------------------------
366
+ signature_pad maps a pointer to a canvas coordinate through the element's
367
+ bounding rect and assumes that rect is the drawing surface. A border and
368
+ padding on the canvas itself sit inside the rect and outside the surface, so
369
+ every stroke landed up and to the left of the cursor by exactly the border
370
+ plus the padding. The frame belongs on the wrapper; the canvas keeps its own
371
+ box model and nothing else.
372
+
373
+ The pad's size comes from `--profile-signature-width` and the `height` prop
374
+ in Profile.jsx together. The wrapper library writes `width`/`height`
375
+ attributes of `offsetWidth * dpr` by `height prop * dpr` and scales the
376
+ context by `dpr`; with `width: 100%` and `height: auto` the canvas's own
377
+ intrinsic ratio then renders it at exactly `height` CSS pixels, so the
378
+ attribute geometry and the CSS geometry agree and a stroke lands under the
379
+ cursor. Change one of the two and check the other.
380
+
381
+ Neither may be made smaller. Profile loads a stored signature into the pad
382
+ on mount and `handleSubmitProfile` re-exports the canvas on any save, so a
383
+ smaller pad writes back a cropped signature without anyone asking it to —
384
+ the full reasoning is at the `height` prop in Profile.jsx. */
385
+ .signatureField {
236
386
  width: 100%;
237
- box-sizing: border-box;
238
- color: var(--paragraph-color);
239
- transition: box-shadow 0.15s linear;
240
- }
241
-
242
- .input:hover,
243
- .textarea:hover,
244
- .select:hover {
245
- border: 1px solid rgba(var(--highlight-color-rgb), 0.85);
246
- transition: border 0.15s linear;
387
+ display: flex;
388
+ flex-direction: column;
389
+ gap: 0.4rem;
390
+ /* A signature is a fixed-size mark, not a field that gains anything from
391
+ more room. Left to fill the row it became the largest thing on the
392
+ page. */
393
+ max-width: var(--profile-signature-width, 29rem);
247
394
  }
248
395
 
249
- .input:focus,
250
- .textarea:focus,
251
- .select:focus {
252
- border: 1px solid rgba(var(--highlight-color-rgb), 0.85);
396
+ /* The caption and the Clear action share the line above the pad. Clear used to
397
+ be promoted into the page footer beside Save, where a control that wipes one
398
+ field sat at the same rank as the one that saves the screen. */
399
+ .signatureHeader {
400
+ display: flex;
401
+ align-items: center;
402
+ justify-content: space-between;
403
+ gap: var(--spacing-sm, 0.5rem);
253
404
  }
254
405
 
255
- .sigcanvas {
256
- border: 2px solid #ccc;
257
- border-radius: 10px;
258
- padding: 10px;
406
+ .signatureCaption {
407
+ font-size: var(--font-size-xs, 0.75rem);
408
+ font-weight: var(--font-weight-semibold, 600);
409
+ letter-spacing: 0.06em;
410
+ text-transform: uppercase;
411
+ line-height: 1.3;
412
+ color: var(--muted-color, #6b7285);
259
413
  }
260
414
 
261
- /* Removed signatureContainer and signatureLabel styles as they're no longer needed */
262
-
263
415
  .signaturePadWrapper {
264
416
  width: 100%;
265
- margin-bottom: 0.5rem;
266
417
  position: relative;
267
418
  z-index: 1;
419
+ box-sizing: border-box;
420
+ padding: var(--spacing-xs, 0.25rem);
421
+ border: 1px solid var(--border-color, #e1e1e1);
422
+ border-radius: var(--radius-sm, 6px);
423
+ background: var(--paper, #fff);
268
424
 
269
425
  canvas {
426
+ display: block;
427
+ width: 100%;
270
428
  max-width: 100%;
271
429
  height: auto !important;
272
430
  }
273
431
  }
274
432
 
433
+ /* Nothing here changes the canvas's box model — see the note above. */
434
+ .sigcanvas {
435
+ cursor: crosshair;
436
+ touch-action: none;
437
+ }
438
+
439
+ /* A small, quiet button for an action that belongs to one field rather than to
440
+ the page. */
441
+ .btnQuiet {
442
+ @include button-secondary;
443
+ @include button-small;
444
+ }
445
+
446
+ /* The panel's footer. Save was `position: fixed` in the bottom-right corner of
447
+ the viewport, floating over whatever the user had scrolled to. It is the
448
+ bottom edge of the card now, on the same hairline-and-padding spec as the
449
+ header, and it shares the sections' measure so it lands under the right edge
450
+ of the fields rather than out at the card's edge. */
275
451
  .polActions {
276
- position: fixed;
277
- bottom: 20px;
278
- right: 20px;
279
- width: auto;
280
452
  display: flex;
281
- gap: 0.5em;
282
- z-index: 10;
283
-
284
- @media (max-width: 768px) {
285
- position: static;
286
- margin: 1.5rem 0;
287
- justify-content: flex-end;
288
- width: 100%;
289
- }
453
+ align-items: center;
454
+ justify-content: flex-end;
455
+ gap: var(--spacing-sm, 0.5rem);
456
+ width: 100%;
457
+ max-width: var(--profile-content-width, 68rem);
458
+ box-sizing: border-box;
459
+ padding: var(--spacing-md, 1rem) var(--spacing-lg, 1.5rem);
460
+ border-top: 1px solid var(--border-color, #e1e1e1);
290
461
  }
291
462
 
292
463
  .polActions button {
293
- padding: 0.65em 1em;
294
464
  margin: 0;
295
- transition: all 0.2s ease;
296
-
297
- &:disabled {
298
- opacity: 0.6;
299
- cursor: not-allowed;
300
- }
301
- }
302
-
303
-
304
-
305
- .inputError {
306
- border-color: red !important;
307
465
  }
308
466
 
309
467
  .loading {
@@ -331,166 +489,158 @@ select:focus + .fi__span {
331
489
  }
332
490
  }
333
491
 
334
- /* Two-Factor Authentication Styles */
335
- .twoFactorSection {
336
- padding: 0.75rem;
337
- width: 100%;
492
+ /* --- Two-Factor Authentication -------------------------------------------
493
+ The tab is sectioned exactly like Overview — the status in one section, the
494
+ pairing walkthrough in the next — so moving between the two tabs does not
495
+ feel like moving between two different pages. */
496
+ .twoFactorStatus {
338
497
  display: flex;
339
498
  flex-direction: column;
340
- }
341
-
342
- .twoFactorStatus {
343
- margin-bottom: 1.5rem;
499
+ align-items: flex-start;
500
+ gap: var(--spacing-md, 1rem);
344
501
 
345
- h3 {
346
- margin-bottom: 0.75rem;
347
- font-size: 1rem;
502
+ h4 {
503
+ margin: 0;
348
504
  display: flex;
349
505
  align-items: center;
350
506
  gap: 0.5rem;
351
- font-weight: 500;
352
- }
353
-
354
- p {
355
- margin-bottom: 1.25rem;
356
- line-height: 1.4;
357
- color: var(--paragraph-color);
358
- font-size: 0.9rem;
507
+ font-size: var(--font-size-xs, 0.75rem);
508
+ font-weight: var(--font-weight-semibold, 600);
509
+ letter-spacing: 0.06em;
510
+ text-transform: uppercase;
511
+ color: var(--muted-color, #6b7285);
359
512
  }
360
513
  }
361
514
 
362
515
  .statusEnabled {
363
- color: #2ecc71;
364
- font-weight: 500;
365
- padding: 0.2rem 0.4rem;
366
- background-color: rgba(46, 204, 113, 0.1);
367
- border-radius: 4px;
368
- font-size: 0.85rem;
516
+ color: var(--success-color, #2ecc71);
517
+ font-weight: var(--font-weight-semibold, 600);
518
+ padding: 0.15rem 0.45rem;
519
+ background-color: var(--success-color-light, rgba(46, 204, 113, 0.1));
520
+ border-radius: var(--radius-sm, 6px);
521
+ font-size: var(--font-size-xs, 0.75rem);
522
+ letter-spacing: 0.04em;
369
523
  }
370
524
 
371
525
  .statusDisabled {
372
- color: #e74c3c;
373
- font-weight: 500;
374
- padding: 0.2rem 0.4rem;
375
- background-color: rgba(231, 76, 60, 0.1);
376
- border-radius: 4px;
377
- font-size: 0.85rem;
526
+ color: var(--danger-color, #e74c3c);
527
+ font-weight: var(--font-weight-semibold, 600);
528
+ padding: 0.15rem 0.45rem;
529
+ background-color: var(--danger-light, rgba(231, 76, 60, 0.1));
530
+ border-radius: var(--radius-sm, 6px);
531
+ font-size: var(--font-size-xs, 0.75rem);
532
+ letter-spacing: 0.04em;
378
533
  }
379
534
 
380
535
  .confirmedMessage {
381
- margin: 1rem 0;
536
+ margin: 0;
382
537
  padding: 0.75rem;
383
- background-color: rgba(46, 204, 113, 0.1);
384
- border-left: 3px solid #2ecc71;
385
- border-radius: var(--br);
386
- color: #333;
387
- font-size: 0.9rem;
538
+ background-color: var(--success-color-light, rgba(46, 204, 113, 0.1));
539
+ border-left: 3px solid var(--success-color, #2ecc71);
540
+ border-radius: var(--radius-sm, 6px);
541
+ color: var(--paragraph-color, #333);
542
+ font-size: var(--font-size-sm, 0.8125rem);
543
+ line-height: 1.5;
388
544
  }
389
545
 
390
- .btnPrimary {
391
- background-color: var(--primary-color);
392
- color: white;
393
- font-size: 0.85rem;
394
- padding: 0.5rem 0.85rem;
546
+ /* The confirmed message and the button that undoes it, stacked and left-hung
547
+ under the status line. */
548
+ .statusActions {
549
+ display: flex;
550
+ flex-direction: column;
551
+ align-items: flex-start;
552
+ gap: var(--spacing-md, 1rem);
553
+ }
395
554
 
396
- &:hover:not(:disabled) {
397
- background-color: rgba(var(--primary-rgb), 0.9);
398
- }
555
+ /* The three variants each carried their own font size and padding, so a row of
556
+ them next to `.btn` — which comes from the shared `_controls` mixins — sat a
557
+ few pixels short and half a step smaller. They are colour only now; the
558
+ shape comes from the one place that defines it. */
559
+ .btnPrimary {
560
+ @include button-primary;
399
561
  }
400
562
 
401
563
  .btnSecondary {
402
- background-color: #f1f1f1;
403
- color: #333;
404
- border: 1px solid #ddd;
405
- font-size: 0.85rem;
406
- padding: 0.5rem 0.85rem;
407
-
408
- &:hover:not(:disabled) {
409
- background-color: var(--bg-color, #f5f3ef);
410
- border-color: var(--border-color, #e1e1e1);
411
- }
564
+ @include button-secondary;
412
565
  }
413
566
 
414
567
  .btnDanger {
415
- background-color: #e74c3c;
416
- color: white;
417
- font-size: 0.85rem;
418
- padding: 0.5rem 0.85rem;
568
+ @include button-base;
569
+
570
+ background: var(--danger-color, #e74c3c);
571
+ border-color: var(--danger-color, #e74c3c);
572
+ color: #fff;
419
573
 
420
574
  &:hover:not(:disabled) {
421
- background-color: rgba(231, 76, 60, 0.85);
575
+ /* Deliberately still a solid fill rather than the library's ghost
576
+ `button-danger`: this is the control that turns a second factor off,
577
+ and it should not read as quieter than the button that turned it
578
+ on. */
579
+ filter: brightness(0.9);
422
580
  }
423
581
  }
424
582
 
583
+ /* The setup walkthrough. It used to be a bordered, shadowed card inside the
584
+ panel card, carrying a heading that repeated the heading above it. The
585
+ section owns the title and the separation now, so this is only the steps. */
425
586
  .twoFactorSetup {
426
- margin-top: 1.5rem;
427
- padding: 1.5rem;
428
- background-color: white;
429
- border-radius: var(--br);
430
- border: 1px solid #e0e0e0;
431
- box-shadow: 0 3px 8px rgba(0, 0, 0, 0.05);
432
587
  width: 100%;
433
-
434
- h3 {
435
- margin-bottom: 1.25rem;
436
- font-size: 1rem;
437
- font-weight: 500;
438
- color: var(--primary-color);
439
- padding-bottom: 0.75rem;
440
- border-bottom: 1px solid #f0f0f0;
441
- }
588
+ min-width: 0;
442
589
  }
443
590
 
444
591
  .setupSteps {
445
- margin-left: 0;
446
- margin-bottom: 1.5rem;
447
- font-size: 0.9rem;
592
+ margin: 0;
593
+ padding: 0;
594
+ font-size: var(--font-size-sm, 0.8125rem);
448
595
  counter-reset: step-counter;
449
596
  list-style-type: none;
450
597
 
451
598
  li {
452
- margin-bottom: 1.5rem;
453
- padding-left: 2.5rem;
599
+ margin-bottom: var(--spacing-lg, 1.5rem);
600
+ padding-left: 2.25rem;
454
601
  position: relative;
455
602
 
603
+ &:last-child {
604
+ margin-bottom: 0;
605
+ }
606
+
456
607
  &:before {
457
608
  content: counter(step-counter);
458
609
  counter-increment: step-counter;
459
610
  position: absolute;
460
611
  left: 0;
461
612
  top: 0;
462
- width: 1.75rem;
463
- height: 1.75rem;
613
+ width: 1.5rem;
614
+ height: 1.5rem;
464
615
  background-color: var(--primary-color);
465
- color: white;
616
+ color: #fff;
466
617
  border-radius: 50%;
467
618
  display: flex;
468
619
  align-items: center;
469
620
  justify-content: center;
470
- font-size: 0.85rem;
471
- font-weight: 500;
621
+ font-size: var(--font-size-xs, 0.75rem);
622
+ font-weight: var(--font-weight-semibold, 600);
472
623
  }
473
624
 
474
625
  p {
475
- margin-bottom: 0.75rem;
476
- font-weight: 500;
477
- font-size: 0.9rem;
478
- color: #333;
626
+ margin-bottom: var(--spacing-sm, 0.5rem);
627
+ font-weight: var(--font-weight-medium, 500);
628
+ font-size: var(--font-size-sm, 0.8125rem);
629
+ line-height: 1.5;
630
+ color: var(--text-color, #333);
479
631
  }
480
632
 
481
633
  ul {
482
- margin-left: 0;
483
- margin-bottom: 1rem;
634
+ margin: 0 0 var(--spacing-md, 1rem);
484
635
  list-style-type: none;
485
- background-color: #f9f9f9;
486
- padding: 0.75rem 1rem;
487
- border-radius: var(--br);
636
+ background: var(--bg-color-secondary, #f9f9f9);
637
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
638
+ border-radius: var(--radius-sm, 6px);
488
639
  border-left: 3px solid var(--primary-color);
489
640
 
490
641
  li {
491
- margin-bottom: 0.5rem;
492
- font-size: 0.85rem;
493
- padding-left: 0;
642
+ margin-bottom: 0.4rem;
643
+ font-size: var(--font-size-sm, 0.8125rem);
494
644
  position: relative;
495
645
  padding-left: 1rem;
496
646
 
@@ -515,7 +665,7 @@ select:focus + .fi__span {
515
665
  a {
516
666
  color: var(--primary-color);
517
667
  text-decoration: none;
518
- font-weight: 500;
668
+ font-weight: var(--font-weight-medium, 500);
519
669
 
520
670
  &:hover {
521
671
  text-decoration: underline;
@@ -528,20 +678,20 @@ select:focus + .fi__span {
528
678
 
529
679
  .qrCodeContainer {
530
680
  display: flex;
531
- justify-content: center;
532
- margin: 0.75rem 0;
533
- padding: 1rem;
534
- background-color: white;
535
- border-radius: var(--br);
536
- border: 1px solid #e0e0e0;
537
- width: 100%;
538
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
539
- transition: transform 0.2s ease, box-shadow 0.2s ease;
540
-
541
- &:hover {
542
- transform: translateY(-2px);
543
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
544
- }
681
+ justify-content: flex-start;
682
+ margin: 0 0 var(--spacing-sm, 0.5rem);
683
+ padding: var(--spacing-md, 1rem);
684
+ background: var(--paper, #fff);
685
+ border-radius: var(--radius-sm, 6px);
686
+ border: 1px solid var(--border-color, #e0e0e0);
687
+ /* Was full width with the code centred in it, which drew a wide empty
688
+ panel around a 180px square. It is the size of what it holds. */
689
+ width: max-content;
690
+ max-width: 100%;
691
+ /* The panel used to lift and deepen its shadow on hover. Nothing in it is
692
+ clickable — it is a QR code to point a phone at — so the affordance was
693
+ promising an interaction that does not exist. */
694
+ box-shadow: none;
545
695
  }
546
696
 
547
697
  .qrCodeSvg {
@@ -561,9 +711,9 @@ select:focus + .fi__span {
561
711
  display: flex;
562
712
  align-items: center;
563
713
  justify-content: center;
564
- background-color: #f5f5f5;
565
- color: #666;
566
- font-size: 0.85rem;
714
+ background-color: var(--bg-color-secondary, #f5f5f5);
715
+ color: var(--muted-color, #666);
716
+ font-size: var(--font-size-sm, 0.8125rem);
567
717
  border-radius: 4px;
568
718
  animation: pulse 1.5s infinite ease-in-out;
569
719
  }
@@ -583,146 +733,131 @@ select:focus + .fi__span {
583
733
  .secretKeyContainer {
584
734
  display: flex;
585
735
  align-items: center;
586
- margin: 0.75rem 0;
587
- gap: 0.5rem;
588
- background-color: #f9f9f9;
589
- padding: 0.75rem;
590
- border-radius: var(--br);
591
- border: 1px solid #e0e0e0;
736
+ margin: 0 0 var(--spacing-sm, 0.5rem);
737
+ gap: var(--spacing-sm, 0.5rem);
738
+ background: var(--bg-color-secondary, #f9f9f9);
739
+ padding: var(--spacing-sm, 0.5rem);
740
+ border-radius: var(--radius-sm, 6px);
741
+ border: 1px solid var(--border-color, #e0e0e0);
592
742
  width: 100%;
743
+ box-sizing: border-box;
593
744
  }
594
745
 
595
746
  .secretKey {
596
- padding: 0.5rem 0.75rem;
597
- background-color: white;
598
- border-radius: var(--br);
747
+ padding: 0.4rem 0.65rem;
748
+ background: var(--paper, #fff);
749
+ border-radius: var(--radius-sm, 6px);
599
750
  font-family: monospace;
600
- font-size: 0.85rem;
751
+ font-size: var(--font-size-sm, 0.8125rem);
601
752
  letter-spacing: 1px;
602
- color: #333;
753
+ color: var(--text-color, #333);
603
754
  user-select: all;
604
- border: 1px solid #ddd;
755
+ border: 1px solid var(--border-color, #ddd);
605
756
  flex-grow: 1;
606
- box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
757
+ min-width: 0;
758
+ overflow-wrap: anywhere;
607
759
  }
608
760
 
609
761
  .copyButton {
610
- padding: 0.4rem 0.75rem;
611
- background-color: var(--primary-color);
612
- color: white;
613
- border: none;
614
- border-radius: var(--br);
615
- cursor: pointer;
616
- font-size: 0.8rem;
617
- transition: all 0.2s ease;
618
- display: flex;
619
- align-items: center;
620
-
621
- &:hover {
622
- filter: brightness(90%);
623
- }
624
-
625
- &:active {
626
- transform: translateY(1px);
627
- }
762
+ @include button-secondary;
763
+ @include button-small;
628
764
  }
629
765
 
630
766
  .verificationCodeContainer {
631
767
  display: flex;
632
768
  align-items: center;
633
- gap: 0.75rem;
634
- margin: 0.75rem 0;
635
- background-color: #f9f9f9;
636
- padding: 1rem;
637
- border-radius: var(--br);
638
- border: 1px solid #e0e0e0;
639
- width: 100%;
769
+ flex-wrap: wrap;
770
+ gap: var(--spacing-sm, 0.5rem);
771
+ /* Was a tinted, bordered strip around an input and a button — a third
772
+ surface for two controls that already have their own edges. */
773
+ margin: 0;
640
774
  }
641
775
 
776
+ /* Same fight with `global.css` as `.input` — see the note there. */
642
777
  .verificationCodeInput {
643
- padding: 0.75rem;
644
- font-size: 1.1rem;
778
+ @include input-base;
779
+
780
+ width: 9rem !important;
781
+ flex: 0 0 auto;
782
+ padding: 0 0.65rem !important;
783
+ border: 1px solid var(--border-color) !important;
784
+ border-radius: var(--radius-sm) !important;
785
+ background: var(--paper) !important;
786
+ font-size: 1.05rem !important;
787
+ line-height: 1.25 !important;
645
788
  letter-spacing: 4px;
646
- width: 140px;
647
789
  text-align: center;
648
- border: 1px solid #ddd;
649
- border-radius: var(--br);
650
790
  font-family: monospace;
651
- font-weight: 600;
652
- color: #333;
653
- background-color: white;
654
- box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
791
+ font-weight: var(--font-weight-semibold, 600);
655
792
 
656
793
  &:focus {
657
- border-color: var(--primary-color);
658
794
  outline: none;
659
- box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
795
+ border-color: var(--primary-color) !important;
796
+ box-shadow: var(--focus-ring, 0 0 0 3px rgba(0, 118, 192, 0.32)) !important;
660
797
  }
661
798
 
662
799
  &::placeholder {
663
- color: #bbb;
800
+ color: var(--muted-color, #bbb);
664
801
  letter-spacing: 2px;
665
802
  }
666
803
  }
667
804
 
668
805
  .recoveryCodes {
669
- margin: 0.75rem 0;
670
- padding: 1rem;
671
- background-color: #f9f9f9;
672
- border-radius: var(--br);
673
- border: 1px solid #e0e0e0;
806
+ margin: 0 0 var(--spacing-sm, 0.5rem);
807
+ padding: var(--spacing-md, 1rem);
808
+ background: var(--bg-color-secondary, #f9f9f9);
809
+ border-radius: var(--radius-sm, 6px);
810
+ border: 1px solid var(--border-color, #e0e0e0);
674
811
  width: 100%;
812
+ box-sizing: border-box;
675
813
  }
676
814
 
677
815
  .recoveryCodesContainer {
678
816
  display: grid;
679
- grid-template-columns: repeat(2, 1fr);
680
- gap: 0.75rem;
681
- margin-bottom: 1rem;
682
-
683
- @media (max-width: 768px) {
684
- grid-template-columns: 1fr;
685
- }
817
+ /* Was two fixed columns, which on the narrower fields column left the
818
+ codes stretched to twice the width of the text in them. */
819
+ grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
820
+ gap: var(--spacing-sm, 0.5rem);
821
+ margin-bottom: var(--spacing-md, 1rem);
686
822
  }
687
823
 
688
824
  .recoveryCode {
689
- padding: 0.5rem 0.75rem;
690
- background-color: white;
691
- border-radius: var(--br);
692
- border: 1px solid #ddd;
825
+ padding: 0.4rem 0.65rem;
826
+ background: var(--paper, #fff);
827
+ border-radius: var(--radius-sm, 6px);
828
+ border: 1px solid var(--border-color, #ddd);
693
829
  font-family: monospace;
694
- font-size: 0.85rem;
830
+ font-size: var(--font-size-sm, 0.8125rem);
695
831
  letter-spacing: 1px;
696
- color: #333;
832
+ color: var(--text-color, #333);
697
833
  user-select: all;
698
- box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
699
834
  text-align: center;
700
835
  }
701
836
 
702
837
  .noRecoveryCodes {
703
- padding: 1rem;
838
+ padding: var(--spacing-md, 1rem);
704
839
  text-align: center;
705
- color: #666;
840
+ color: var(--muted-color, #666);
706
841
  font-style: italic;
707
- font-size: 0.9rem;
842
+ font-size: var(--font-size-sm, 0.8125rem);
708
843
  }
709
844
 
710
845
  .recoveryCodesWarning {
711
- margin-top: 0.75rem;
846
+ margin: 0;
712
847
  padding: 0.75rem;
713
- background-color: rgba(231, 76, 60, 0.1);
714
- border-left: 3px solid #e74c3c;
715
- border-radius: var(--br);
716
- color: #333;
717
- font-size: 0.85rem;
848
+ background-color: var(--danger-light, rgba(231, 76, 60, 0.1));
849
+ border-left: 3px solid var(--danger-color, #e74c3c);
850
+ border-radius: var(--radius-sm, 6px);
851
+ color: var(--paragraph-color, #333);
852
+ font-size: var(--font-size-sm, 0.8125rem);
853
+ line-height: 1.5;
718
854
  }
719
855
 
720
856
  .setupActions {
721
857
  display: flex;
722
- justify-content: flex-end;
723
- margin-top: 1.5rem;
724
- padding-top: 1rem;
725
- border-top: 1px solid #f0f0f0;
858
+ /* The steps hang off the left margin; so does the way out of them. */
859
+ justify-content: flex-start;
860
+ margin-top: var(--spacing-lg, 1.5rem);
726
861
  }
727
862
 
728
863
  .crmtitle {