1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
/* Light mode's page sits a hair under the card (#faf9f7 under
   #fdfdfb) — a step too fine to read as the contrast Gloss's surface
   system elevates by. The page takes the fill step instead: one
   alias repointed to a value already on the ramp, no new color. Dark
   keeps its own block in tokens.css (its page/card step already
   reads), so the repoint takes that block's scoping inverted: light
   by preference unless forced dark, or forced light. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --gl-color-surface-page: var(--gl-color-surface-fill);
  }
}
:root[data-theme="light"] {
  --gl-color-surface-page: var(--gl-color-surface-fill);
}

/* Pro-tacts' own layer on top of Gloss (see docs/DESIGN.md) — page
   chrome and the detail grid, neither of which Gloss defines a
   component for. */

/* The record: its caption row and its card, one block. The gap is
   the dashboard's section-head-to-card step (s, not the column's m)
   because the back-link line is a caption for the card, the same
   relationship a section heading has to its list — a full column step
   between them read the line as its own block instead. */
.record {
  display: flex;
  flex-direction: column;
  gap: var(--gl-space-s);
}

/* Each record page's one action, on the back-link's line: the
   details page's edit link at the right edge, the editor's add
   button in the same place, pinned for .section-head's reason.
   Centered on the row it acts on, it straddles it a few pixels
   either side, into padding above and gap below that nothing else
   occupies.

   The row is the link's own height — the div is a flex box so the
   anchor sizes it as an item, at the label's step--2/1 — and not
   the body-text line box the div would otherwise inherit, a good
   15px taller, which reads as the page's content starting that much
   lower than a list page's: the site header owes the first ink
   under it the same distance on every page, and a record's back
   link is that ink. Navigation is a link and opening a popover is a
   button, so the pinning rule below reaches for both. */
.record-nav {
  position: relative;
  display: flex;
}
.record-nav .btn,
.record-nav button {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-end: 0;
  translate: 0 -50%;
}

/* A card's h1 carries no margin of its own — Gloss zeroes h3 in a
   card-body the same way — because every h1 in one is a page's
   heading and its separation from what follows is the page's rhythm,
   not the heading element's default. The descendant rule reaches the
   name inside .detail-header too; the direct-child one adds the one
   step of air under a card's own heading, which the card-body's gap
   does not give it — that gap is the prose-block rhythm, and loosening
   it to air a heading would loosen every paragraph after it. */
.card-body h1 { margin: 0; }
.card-body > h1 { margin-block-end: var(--gl-space-xs); }

/* A record card's first content sits a step closer to the card's
   top than the padding the body gives prose: the heading is the
   card's own edge, and the m of air above content a page has
   already headed reads as blank space rather than as room to read
   in, both on the contact's page and in a walk's read screen
   (ContactCard, the one renderer). Every record card takes the
   step, editors and a group's lone name included: this is the gap
   a page's header owes its content (the record-nav row over the
   card, held to the s a section-head's list gets), and an editor's
   fields at the body's m land a good 10px deeper than a list
   page's first row, which the eye reads as the gap changing. */
.record .card-body {
  padding-block-start: var(--gl-space-s);
}

/* The type column both record modes share: the details grid's label
   column and the editor's caption column are the same column, read
   from one place so they cannot drift apart. The one structural rem
   width this layer names — see .detail-grid for what it holds. */
:root {
  --type-column: 6rem;
}

/* A form in a card keeps a tighter rhythm than Gloss's form-body
   defaults: the boxes shed their block padding down to the scale's
   smallest step — the same shedding a dialog's filter does — and
   the stack closes to the detail grid's row gap, because a row is
   now one line tall there too. Scoped to the card-body form rather
   than overriding field.css globally; the label in the input
   selector out-specifies field.css's input rule rather than relying
   on stylesheet order (see .search-dialog's rules). Everything
   here reaches the record's own rows by the child combinator rather
   than by descent: these rules are the record's, and both dialogs'
   fields keep Gloss's own — a 26rem popover is not a record, and the
   two should not diverge just because one of them opens over this
   screen. The cap lifts for every card-body form for the same
   reason: Gloss bounds a bare form at 28rem for the settings-form
   pattern, and a card's form — the record's rows, the upload's one
   field — is the card's whole content: the reading column is its
   bound (invisible until the column passed 28rem, then half a card
   of dead space). */
.card-body form {
  max-width: none;
}
.card-body form.field-stack {
  gap: var(--gl-space-2xs);
}
.card-body form.field-stack > .field > input,
.card-body form.field-stack > .field > textarea,
.card-body form.field-stack > .field > .field-stack > input {
  padding-block: var(--gl-space-3xs);
}

/* The upload's file input (the app's only one): a text input's
   border would float around nothing — Gloss fills inputs with
   surface-card, the color of the card body they sit on — so the
   control reads as a well instead, the one step down a quiet
   button's own hover tint uses, with the native picker button
   raised one step out of it in the quiet button's own geometry.
   Everything else about the control stays the browser's: the two
   strings and the button-before-name order are not CSS's to change,
   and the generic input rule's focus ring still draws (a border
   none only removes the line, not the shadow). */
.card-body form input[type="file"] {
  padding: var(--gl-space-2xs);
  background: var(--gl-color-surface-fill);
  border: none;
}
.card-body form input[type="file"]::file-selector-button {
  padding: var(--gl-space-2xs) var(--gl-space-xs);
  border: none;
  border-radius: var(--gl-radius-sm);
  background: var(--gl-color-surface-card);
  color: var(--gl-color-text-primary);
  font-family: var(--gl-font-mono);
  font-size: var(--gl-step--1);
  font-weight: 500;
  letter-spacing: 0.01em;
  margin-inline-end: var(--gl-space-2xs);
  cursor: pointer;
}

/* An editor row is a detail row that happens to be editable: caption
   in the type column, control in the value column, on .detail-grid's
   own template (docs/DESIGN.md, "Alignment is the layout").
   Gloss's .field stacks its caption over its control, which is right
   for the settings-form pattern it was written for and wrong for a
   record: it spends a line and a gap on every row, and this card is
   eight rows deep before a contact has an email. Baseline for the
   reason .detail-grid gives. The class rather than the element: a
   row is a label when it holds one control and a div when it holds a stack
   of them (an address — see the nested-stack rule below), and both
   are the record's rows. The value track is minmax(0, 1fr) rather
   than a bare 1fr: a bare fr floors the track at its content's
   intrinsic minimum, and a form control's minimum — a select's
   widest option, an input's default size — is wider than a phone's
   value column, and pushes rows past the card's edge. */
.card-body form.field-stack > .field {
  display: grid;
  grid-template-columns: var(--type-column) minmax(0, 1fr);
  column-gap: var(--gl-space-s);
  align-items: baseline;
}
/* The Company row's box has no text to share a baseline with, so the
   baseline rule above floats it over its caption; centred, the two
   read as one row like every other. */
.card-body form.field-stack > .field:has(> input[type="checkbox"]) {
  align-items: center;
}
/* A row whose control is a stack of inputs — an address's component
   fields — holds the multi-line value's own lines in the value
   column (docs/DESIGN.md), so the stack closes to the record's own
   row gap and each input sheds the same block padding every other
   row's control sheds (the selector above it). Reached by descent
   from the record's row, which the nested stack is part of; the
   dialogs' fields are no form.field-stack's children and keep
   Gloss's own. */
.card-body form.field-stack .field > .field-stack {
  gap: var(--gl-space-2xs);
}
/* The birthday row's three controls — month, day, year — sit on one
   line in the value column: a date is one value, not three fields,
   and the line it reads on matches the prose the details page
   renders (Birthday#to_s's "December 10, 1985"). The three share the
   line's space equally beyond their own bases — the select grows to
   carry "September", the inputs grow with it so the column's width
   does not pool in one box. Their base width is the digits and
   spinner they must show plus the box's own chrome — border-box
   widths count the padding and border inside, so a bare 6ch left
   ~1ch of content and nothing to click a caret into. Derived from
   the input's own padding calc so the pair moves with it; the same
   block-padding shed every other record control gets. */
.card-body form.field-stack .field > .date-row {
  display: flex;
  gap: var(--gl-space-2xs);
  position: relative;
}
.card-body form.field-stack .field > .date-row > select {
  flex: 1 1 auto;
  padding-block: var(--gl-space-3xs);
}
/* The two digit boxes by their type, not by being inputs: the row
   also holds the picker's date input, which is not a box anyone
   sizes (below). */
.card-body form.field-stack .field > .date-row > input[type="number"] {
  flex: 1 1 auto;
  width: calc(7ch + 2 * (var(--gl-space-xs) - 1px) + 2px);
  padding-block: var(--gl-space-3xs);
}
/* The picker's date input (Admin::ContactsEdit#birthday_picker),
   which the row never shows: out of flow so it takes no flex item
   and no grid cell in either of the narrow shapes below, at zero
   width in the corner the native picker should hang from. Not
   `display: none` — showPicker() is only promised on an element
   that is rendered — and not `visibility: hidden` either, which
   the same doubt covers. */
.card-body form.field-stack .field > .date-row > input[type="date"] {
  position: absolute;
  inset-block-end: 0;
  inset-inline-end: 0;
  width: 0;
  padding: 0;
  border: 0;
  opacity: 0;
  pointer-events: none;
}
/* The birthday row's two icon controls (Admin::ContactsEdit): Gloss's
   IconButton is already square, flex-none, and quiet — the one thing
   this layer adds is centering them on the date line, whose children
   stretch, and the removal register the card UI claims for itself
   (docs/DESIGN.md): danger only on hover, the icon's stroke
   inheriting it. The hover color is the remove control's alone; the
   picker is not destructive and keeps the quiet register. Their order
   is their identity — the picker renders in both the standing row and
   the added one, the remove only in the standing row, so the picker
   is the first button either way. */
.card-body form.field-stack .field > .date-row > .icon-button {
  align-self: center;
}
.card-body form.field-stack .field > .date-row > button:nth-of-type(2):hover {
  color: var(--gl-color-danger);
}

/* A row's label field (Admin::ContactsEdit#label_field) sits in the
   type column the caption held and the record card's key still holds,
   and at rest it is that caption: Gloss's .field type, mono uppercase
   at the step below the value, with no box around it — the row reads
   as the record does (docs/DESIGN.md, "Alignment is the layout"). The
   box comes back under the pointer, saying the word can be changed,
   and stays while the field has focus, which a click gives it: editing
   on click is the input's own behavior, and markup's job here (see
   Admin::Layout). A word wider than the column ends in an ellipsis
   until then. Lowercase rather than the captions' uppercase: the
   field shows the word as it is stored and as it will be saved, and
   an unlabeled row's placeholder, the kind, reads in the caption's
   color rather than a hint's. */
.card-body form.field-stack > .field > .row-label {
  padding-block: var(--gl-space-3xs);
  padding-inline: 0;
  border-color: transparent;
  background: transparent;
  text-overflow: ellipsis;
  cursor: text;
  font-family: var(--gl-font-mono);
  font-size: var(--gl-step--2);
  font-weight: 500;
  text-transform: none;
  letter-spacing: 0.06em;
  color: var(--gl-color-text-secondary);
}
.card-body form.field-stack > .field > .row-label::placeholder {
  color: var(--gl-color-text-secondary);
}
.card-body form.field-stack > .field > .row-label:is(:hover, :focus) {
  padding-inline: calc(var(--gl-space-xs) - 1px);
  border-color: var(--gl-color-border-default);
  background: var(--gl-color-surface-card);
}
.card-body form.field-stack > .field > .row-label:focus {
  border-color: var(--gl-color-accent);
}
/* Chrome's arrow for the list would take a third of a 6rem column
   from the word; the list still opens on a click or a keystroke. */
.card-body form.field-stack > .field > .row-label::-webkit-calendar-picker-indicator {
  display: none !important;
}

/* The date line breaks in two when its row cannot hold a whole
   date: a month name, two year boxes, and the two icon buttons need
   ~20rem side by side, and on a phone the value column has a
   fraction of that — three controls squeezed into it rendered each
   a stub. The narrow shape is two lines with the buttons in a
   column of their own: the month over the day and year, the month
   name being the wide value and the two digit boxes pairing, and
   the buttons stacked beside them, where desktop's line has them.
   The digit boxes shed their desktop width to fill their half-tracks,
   the address row's stacked fields the same reading. A second
   break, at 21rem, gives each field its own line — the digit pair
   wants its desktop width a box, and a half-track that cannot
   hold its digits is not a pair. The row is
   the query container rather than the viewport because the value
   column's width is the thing being asked about, and the reading
   column, the type column, and the viewport all conspire to set it
   differently per screen; inline-size containment is safe here
   because the field row's width comes from the form's grid track,
   never from its contents. Threshold in the row's own terms: 20rem
   of value column plus the type column and its gap is 27rem. The
   selectors inside repeat the desktop rules' own specificity — a
   container query adds none, and the width it undoes would
   otherwise win. The tracks are minmax(0, 1fr), not bare 1fr — the
   same floor the value column's own rule sheds, without which the
   month select's "September" and the year box's spinners held the
   line wider than a phone's card. */
.card-body form.field-stack > .field:has(> .date-row) {
  container-type: inline-size;
}
@container (width < 27rem) {
  .card-body form.field-stack .field > .date-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
  }
  .card-body form.field-stack .field > .date-row > select {
    grid-area: 1 / 1 / 2 / 3;
  }
  .card-body form.field-stack .field > .date-row > input[type="number"] {
    grid-area: 2 / 1;
    width: auto;
  }
  /* The year box by the box it follows, not by :last-of-type — the
     row's last input child is the picker's date input, which shares
     the tag that selector counts. */
  .card-body form.field-stack .field > .date-row > input[type="number"] + input[type="number"] {
    grid-area: 2 / 2;
  }
  /* The picker beside the month, the remove beside the digits — each
     button on the line whose value it acts on, and neither of them
     auto-placed into a track a field wants. */
  .card-body form.field-stack .field > .date-row > button:nth-of-type(1) {
    grid-area: 1 / 3;
  }
  .card-body form.field-stack .field > .date-row > button:nth-of-type(2) {
    grid-area: 2 / 3;
  }
}
/* The second break: two columns — field and button — one row per
   field. Every grid-area restates the first block's own, because a
   query that matches both resolves ties by file order and the
   narrower block sits later. */
@container (width < 21rem) {
  .card-body form.field-stack .field > .date-row {
    grid-template-columns: minmax(0, 1fr) auto;
  }
  .card-body form.field-stack .field > .date-row > select {
    grid-area: 1 / 1;
  }
  .card-body form.field-stack .field > .date-row > input[type="number"] {
    grid-area: 2 / 1;
  }
  .card-body form.field-stack .field > .date-row > input[type="number"] + input[type="number"] {
    grid-area: 3 / 1;
  }
  .card-body form.field-stack .field > .date-row > button:nth-of-type(1) {
    grid-area: 1 / 2;
  }
  .card-body form.field-stack .field > .date-row > button:nth-of-type(2) {
    grid-area: 2 / 2;
  }
}
/* A multiline control has no single line to align a caption to, and
   what a browser calls its baseline is not settled — a scroll
   container's can be synthesized from its border box, which would
   drop the caption to the bottom of the note. So this row aligns at
   the top instead, and the caption is pushed down by exactly what
   holds the textarea's first line off its own edge: the block
   padding above plus the border. Derived, not eyeballed, so the two
   move together if either changes. */
.card-body form.field-stack > .field:has(textarea) {
  align-items: start;
}
.card-body form.field-stack > .field:has(textarea) > span {
  padding-block-start: calc(var(--gl-space-3xs) + 1px);
}

/* Blank-equals-absent, stated before the save: a row wearing
   data-blank-removes is one whose blanking deletes something
   (Admin::ContactsEdit owns each row's decision to wear it), and
   once every value in the row reads blank the caption strikes and
   takes the danger color — the row's own name crossed out in the
   color Gloss reserves for destructive, which this pending
   deletion is. CSS over :placeholder-shown rather than Alpine,
   because revealing a state over standing elements is the job
   markup keeps here (see Admin::Layout). The single-value rows
   also say it in the blanked box itself, their placeholder dyed
   to match; the structured rows carry the struck caption alone,
   each selector naming its row's own removal condition — an
   address only blank throughout its components, a partial blank
   keeping the line, and the birthday only with the month's own
   empty option picked, both the save's rules. The birthday's asks
   after its digit boxes by type: the picker's date input supports
   no placeholder, so :placeholder-shown never matches it and a
   bare `input` there would read the row as filled forever. The boxes keep
   their neutral borders: Gloss's danger border is the
   aria-invalid contract, and a blank row is a valid save, not a
   refused one. One gap, noted rather than scripted: a
   whitespace-only value hides the placeholder and the strike with
   it, while the save strips before it compares. */
.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > :is(span, .row-label),
.field[data-blank-removes]:has(> textarea:placeholder-shown) > span,
.field[data-blank-removes]:has(> .field-stack):not(:has(> .field-stack > input:not(:placeholder-shown))) > :is(span, .row-label),
.field[data-blank-removes]:has(> .date-row):has(option[value=""]:checked):not(:has(input[type="number"]:not(:placeholder-shown))) > span {
  text-decoration: line-through;
  color: var(--gl-color-danger);
}
.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > input:not(.row-label)::placeholder,
.field[data-blank-removes]:has(> textarea:placeholder-shown) > textarea::placeholder {
  color: var(--gl-color-danger);
}
/* A label field's own placeholder is the kind it stands in for, and
   is struck with the row. A pseudo-element answers no :is() above, so
   it takes its own rule. */
.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > .row-label::placeholder,
.field[data-blank-removes]:has(> .field-stack):not(:has(> .field-stack > input:not(:placeholder-shown))) > .row-label::placeholder {
  text-decoration: line-through;
  color: var(--gl-color-danger);
}

/* Gloss's field contract styles input and select but not textarea
   (base.css only resets it), and the editor's note field is one —
   multiline text beside a .field caption. The box mirrors field.css's
   input rule in the vars that rule itself uses, so the two controls
   read as one component. Line-height is the one var left behind:
   1.15 is the Button-height match, a one-line control's concern, and
   wrapped note text reads better on a looser line. Resize stays
   vertical, the one freedom a text area has that a text input
   does not. */
textarea {
  width: 100%;
  padding: var(--gl-space-xs) calc(var(--gl-space-xs) - 1px);
  background: var(--gl-color-surface-card);
  border: 1px solid var(--gl-color-border-default);
  border-radius: var(--gl-radius-sm);
  color: var(--gl-color-text-primary);
  font-family: var(--gl-font-sans);
  font-size: var(--gl-step--1);
  text-transform: none;
  letter-spacing: normal;
  font-weight: 400;
  outline: none;
  resize: vertical;
}
textarea:focus {
  border-color: var(--gl-color-accent);
  box-shadow: 0 0 0 2px var(--gl-color-accent-soft);
}

/* A chrome bar, not a content gutter: the block padding is the
   scale's second-smallest step, so the header hugs its one line of
   content instead of spacing it like page copy. Inline padding stays
   at m to sit on the same vertical edges .admin-main's does. */
.admin-header {
  display: flex;
  align-items: center;
  gap: var(--gl-space-s);
  padding: var(--gl-space-2xs) var(--gl-space-m);
  border-bottom: 1px solid var(--gl-color-border-hairline);
}
.admin-header a {
  font-family: var(--gl-font-mono);
  font-size: var(--gl-step--1);
  color: var(--gl-color-text-primary);
  text-decoration: none;
}
/* Three columns: the name at the start, the search in the middle,
   and the destinations with the account at the end, held in one box
   (Layout). The outer two share the width left over equally, so the
   middle is the header's own middle whatever the name and the login
   measure, and a column that needs more than its share takes it,
   pushing the search aside rather than squeezing its own contents —
   left to the flex row, the squeeze came out of the account's
   chevron first. Only wider than a
   phone, whose header is two things at its two edges (below). The
   name sits a tier above the links — one type step, the identity
   rendered in type (docs/DESIGN.md) — while they step down a shade:
   destinations, not the name. */
@media (width > 48rem) {
  .admin-header {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
  }
}
.header-end {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--gl-space-s);
  min-width: max-content;
  margin-inline-start: auto;
}
.admin-header nav {
  display: flex;
  align-items: baseline;
  gap: var(--gl-space-s);
}
.admin-header > a {
  font-size: var(--gl-step-0);
  white-space: nowrap;
}
.admin-header nav a {
  color: var(--gl-color-text-secondary);
}

/* The menu repeats home and the destinations for the phone, below;
   wider, the name and the nav carry them. */
.user-menu .menu-nav {
  display: none;
}

/* A phone has no room for the name, the search, two destinations
   and the account in one row: left there, the name broke at its
   hyphen and the account ran past the right edge. So the header
   keeps the two things only it can do, the search and the account.
   The name goes, being the way home and nothing more once the tab
   says which instance this is, and home joins the destinations in
   the account menu (Layout repeats them there). The search is its
   link to the search page, the glyph alone, in place of the
   dialog's button: a phone's screen is the page's whole width, and
   the dialog would only be a smaller one over it. On the search
   page the same glyph's place goes to home (Layout). The login is
   whatever the proxy vouches for, usually an email, so it ends in
   an ellipsis when it runs out of room.

   The bars' sides close a step inside the page column's gutter, so
   the glyph and the account's chevron sit nearer the screen's edges
   than the column's content does: at the gutter they read as inset,
   and at the bars' block step as crowding the edge. The glyph's
   link trades Gloss's fixed icon-button box, which centers the glyph
   in room no token names, for a 2xs step of padding pulled back out
   by the same step: the glyph stands on the bar's edge the way the
   chevron does (.user-menu button, below). */
@media (max-width: 48rem) {
  .admin-header > a,
  .admin-header nav {
    display: none;
  }
  .user-menu .menu-nav {
    display: block;
  }
  .admin-header .search-button {
    display: none;
  }
  .admin-header,
  .admin-footer {
    padding-inline: var(--gl-space-s);
  }
  .admin-header .phone-glyph {
    display: inline-flex;
    width: auto;
    height: auto;
    padding: var(--gl-space-2xs);
    margin-inline-start: calc(-1 * var(--gl-space-2xs));
  }
  .header-end,
  .user-menu {
    min-width: 0;
  }
  .user-menu button {
    min-width: 0;
  }
  .user-menu button span {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}

/* The page's other chrome bar, built off the header's rule: the same
   block and inline padding, and the hairline on the opposite edge, so
   the two rules read as one frame around the column between them.
   `margin-top: auto` is what pins it to the bottom of a short page —
   the body below is a column tall enough to have a bottom — and on a
   page taller than the viewport it simply follows the content. The
   version it carries is centered: it is the only thing in the bar,
   and a build with no label still draws the frame's edge. */
.admin-footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--gl-space-s);
  margin-top: auto;
  padding: var(--gl-space-2xs) var(--gl-space-m);
  border-top: 1px solid var(--gl-color-border-hairline);
}

/* A run with an accent configured (PRO_TACTS_ACCENT; Layout marks the
   root) wears that Gloss accent instead of the default, and the chrome
   bars fill with it, so a working copy cannot pass for the deployment
   at a glance (docs/DESIGN.md). The whole root repoints rather than
   the bars alone, so a view still shows one accent. Dev wears ink
   blue, not clay, which sits too near danger. The fill is the
   accent under accent-fg, the pairing Gloss's primary button uses;
   accent-soft would be a pale band in dark mode, where tokens.css
   never derives it again. */
:root[data-accent="teal"] { --gl-color-accent: var(--gl-accent-teal); }
:root[data-accent="clay"] { --gl-color-accent: var(--gl-accent-clay); }
:root[data-accent="ink-blue"] { --gl-color-accent: var(--gl-accent-ink-blue); }
:root[data-accent="ochre"] { --gl-color-accent: var(--gl-accent-ochre); }
:root[data-accent="plum"] { --gl-color-accent: var(--gl-accent-plum); }
:root[data-accent] :is(.admin-header, .admin-footer) {
  background: var(--gl-color-accent);
  border-color: var(--gl-color-accent);
}
/* The accent-fg pairs with the bar's fill and must stop at the
   chrome: the menu's list is the header's DOM child but floats over
   the page on a card, so left to reach it by descent it paints the
   rows white on surface-card — unreadable. They keep the text color
   their own rule gives them (the menu section below). */
:root[data-accent] :is(.admin-header a, .admin-header .user-menu button,
.admin-header .search-button, .admin-footer .type-label):not(.user-menu ul a) {
  color: var(--gl-color-accent-fg);
}
/* The one focus ring is drawn in the accent, which vanishes against a
   bar filled with it. */
:root[data-accent] :is(.admin-header, .admin-footer) :focus-visible {
  outline-color: var(--gl-color-accent-fg);
}
/* The menu's list is a DOM child of the header but floats over the
   page, where the accent ring is the one every link off the chrome
   bars gets — the rule above would reach it by descent alone. */
:root[data-accent] .admin-header .user-menu ul :focus-visible {
  outline-color: var(--gl-color-accent);
}

/* The row hover Gloss gives is surface-fill — which the page repoint
   at the top of this file made the page's own color, so a hovered
   row filled with the page reads as a hole in the card rather than a
   highlight. The gesture survives one step deeper: fill-2, against
   both the card it sits on and the page it must not become. */
.card > li > a:only-child:hover,
.card > li > button:only-child:hover {
  background: var(--gl-color-surface-fill-2);
}

/* The viewport-tall column the footer's `margin-top: auto` measures
   against. Gloss's own body rule sets type and surface and leaves
   layout to the page. */
body {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* The reading column's width: wide enough that the editor's widest
   row — the birthday's three controls, ~343px at minimum — fits the
   value column with room for the month select to grow (the value
   column at 40rem is ~412px), which 34rem misses by ~30px.

   The declared width is what the body's column costs: auto inline
   margins on a flex item override the cross-axis stretch, leaving it
   shrink-to-fit — which takes the dashboard's two columns down to
   one, its grid tracks being fractions of a width that does not
   exist. The auto margins center it under the cap. */
.admin-main {
  width: 100%;
  max-width: 40rem;
  margin: 0 auto;
  padding: var(--gl-space-l) var(--gl-space-m);
  display: flex;
  flex-direction: column;
  gap: var(--gl-space-m);
}

/* The dashboard's width — two columns beside each other rather than
   the reading width a single column wants. Set by the layout's `wide`
   flag, the one screen that asks for it. */
.admin-main[data-wide] {
  max-width: 68rem;
}

/* The dashboard's two columns (docs/DESIGN.md). `align-items: start`
   keeps a short column from stretching its card to match its
   neighbor. */
.dashboard {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
  gap: var(--gl-space-m);
  align-items: start;
}

/* The import review's two cards: the contact as the file wrote it
   beside the contact that is landing, open in the editor
   (Admin::ImportOriginal, Admin::ContactsEdit), on .dashboard's
   columns for its reason. 22rem rather than the dashboard's 20rem:
   the left card holds content lines, which are longer than a
   contact's name. */
.paired {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
  gap: var(--gl-space-m);
  align-items: start;
}

/* A card's content lines as the file wrote them
   (Admin::ImportOriginal): monospace, because these are the bytes and
   not a rendering of them, and wrapping rather than scrolling
   because a long value is the thing being judged. */
.card-lines {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gl-space-2xs);
  font-family: var(--gl-font-mono);
  font-size: var(--gl-step--1);
  overflow-wrap: anywhere;
}
.card-lines > li {
  display: flex;
  gap: var(--gl-space-s);
  align-items: baseline;
  justify-content: space-between;
}
.card-lines > li > span:first-child {
  min-width: 0;
}

/* A line that is not coming in, struck in the danger color a blanked
   editor row wears (see .field[data-blank-removes] above): losing
   something is what that color means on this surface. The words
   beside it carry the same fact, a strike on its own being a color,
   and they do not wrap -- the line wraps instead. */
.card-lines > li[data-dropped] > span:first-child {
  color: var(--gl-color-danger);
  text-decoration: line-through;
}
.card-lines > li[data-dropped] > .type-label {
  color: var(--gl-color-danger);
  white-space: nowrap;
}

/* The search field (SearchField): the input over its results, which
   take no room before there are any. The row Enter would follow
   wears the hover's fill. */
.search-field,
.search-results {
  display: flex;
  flex-direction: column;
}
.search-field {
  gap: var(--gl-space-s);
}
.search-results {
  gap: var(--gl-space-2xs);
}
.search-results:empty {
  display: none;
}
.search-results .card > li > a[data-active] {
  background: var(--gl-color-surface-fill-2);
}

/* The dialog opens near the top, the space scale's largest step
   down, rather than centered, so the input stays put as the results
   under it grow and shrink; wider than Gloss's 26rem, a result row
   being a whole name and its age. The input sheds its block padding
   as the groups dialog's filter does, the selector out-specifying
   field.css's double-:not input rule rather than relying on
   stylesheet order. The results scroll in
   place under the input rather than the dialog growing past the
   viewport — Gloss gives a dialog overflow: hidden (the groups
   dialog's note below). */
.search-dialog {
  width: 36rem;
  margin-block: var(--gl-space-3xl) auto;
}
.search-dialog .filter > input[type="search"] {
  padding-block: var(--gl-space-3xs);
}
.search-dialog .search-results {
  max-height: 60vh;
  overflow-y: auto;
}

/* The page's field (SearchPage) spans the column: field.css caps a
   form for the settings pattern, and this form is the page. The
   header's phone glyph — the link to this page, or home from it —
   shows only on a phone (above); wider, the dialog's button and the
   name stand in for it. */
.search-page {
  max-width: none;
}
.phone-glyph {
  display: none;
}

/* Clearing a search field: one affordance for both kinds the app
   has — the search dialog's input and the lists' filter, both a
   .filter box — Gloss's sm
   icon-button carrying Lucide's x, overlaid at the field's end.
   Revealed by :has(:not(:placeholder-shown)) on the box the input
   and the control share, so it appears the moment there is text to
   clear, typed-but-unsubmitted included, with no script — the
   filter's button needs Alpine only for its click, which empties
   the state the rows or the results read. The input's end padding
   reserves the control's width by the sm icon-button's own
   geometry (2× its 3xs padding + its 1.15×0.8125rem line), so text
   never runs under it. */
.filter {
  position: relative;
}
.search-clear {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-end: var(--gl-space-2xs);
  translate: 0 -50%;
  display: none;
}
.filter:has(input:not(:placeholder-shown)) .search-clear {
  display: inline-flex;
}
input[type="search"] {
  padding-inline-end: calc(var(--gl-space-xs) - 1px + 2 * var(--gl-space-2xs) +
    2 * var(--gl-space-3xs) + 1.15 * 0.8125rem);
}
/* WebKit and Blink draw their own cancel on a search field; Firefox
   draws none. Ours above is the one affordance everywhere, so the
   native one goes — a field carrying both would clear twice over
   the same corner. */
input[type="search"]::-webkit-search-cancel-button {
  display: none;
}

/* The account menu, the header's third region: the button naming the
   login and the popover it opens. The wrapper names itself the list's
   anchor because a popover promoted to the top layer is positioned
   against the viewport, not the boxes beside it in the DOM — a
   `position: relative` wrapper with an `absolute` list cannot hang
   the list below the button and instead opens it a full viewport
   down, at the page's bottom right. Closed, the Popover API itself
   keeps it out of the layout and the tab order. */
.user-menu {
  anchor-name: --user-menu;
  display: flex;
}
/* The button's own inline padding is pulled into the header's gutter
   at the end, so the chevron ends on the edge the name starts from
   at the other side, and the hover fill runs into the padding. */
.user-menu button {
  margin-inline-end: calc(-1 * var(--gl-space-2xs));
}
/* The UA popover styles center (`position: fixed; inset: 0; margin:
   auto`), overridden to open off the anchor: below the button's
   bottom edge and inline-end-aligned with it, `anchor()` reading
   the button's box wherever the header's right edge falls. A card's
   construction — surface, hairline, the one --gl-shadow-float a
   floated layer carries (Gloss's dialog, here) — at menu size; the
   rows take the quiet button's own hover tint. */
.user-menu ul {
  position: fixed;
  position-anchor: --user-menu;
  inset: auto;
  inset-block-start: calc(anchor(--user-menu bottom) + var(--gl-space-2xs));
  inset-inline-end: anchor(--user-menu right);
  margin: 0;
  padding: var(--gl-space-2xs);
  background: var(--gl-color-surface-card);
  border: 1px solid var(--gl-color-border-hairline);
  border-radius: var(--gl-radius-md);
  box-shadow: var(--gl-shadow-float);
}
/* Without anchor positioning the list cannot find the button at all,
   so it parks at the page's top right — under the header, where the
   button lives — rather than the UA popover's dead center. */
@supports not (anchor-name: --a) {
  .user-menu ul {
    inset-block-start: calc(var(--gl-space-l) + var(--gl-space-2xs));
    inset-inline-end: var(--gl-space-m);
  }
}
.user-menu ul a {
  display: block;
  padding: var(--gl-space-2xs) var(--gl-space-xs);
  border-radius: var(--gl-radius-sm);
  font-family: var(--gl-font-mono);
  font-size: var(--gl-step--1);
  color: var(--gl-color-text-primary);
  text-decoration: none;
  white-space: nowrap;
}
.user-menu ul a:hover {
  background: var(--gl-color-surface-fill);
}
/* The caret says which way the menu opens, and turns around once it
   has — state the markup can express on its own, the header's own
   no-script rule holding where nothing else is needed. */
.user-menu:has(ul:popover-open) svg {
  rotate: 180deg;
}

/* Gloss zeroes every heading's margin (base.css), which leaves a
   section's type-label flush against the list it names; every
   section is a type-label over a list. */
section > h2 {
  margin-bottom: var(--gl-space-s);
}

/* A section heading that carries an action at its right edge — the
   contacts column's quiet add button. The button is centered on the
   heading's line rather than in flow: a Gloss button is half again
   taller than a type-label line, and in flow it pushes the heading's
   baseline below the sibling column's (baseline alignment against
   the taller box) and grows the row, dropping the card under it —
   the two columns' headings and cards would no longer sit on shared
   lines. Centered on the line it carries the row's own geometry,
   and its text lands on the heading's: both are near-identical mono
   sizes, so center-on-line is baseline-on-line. The h2 is not the
   section's direct child here, so the row takes over the heading's
   spacing to the list below. */
.section-head {
  position: relative;
  margin-bottom: var(--gl-space-s);
}
.section-head button {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-end: 0;
  translate: 0 -50%;
}
/* An action that is a type-label link rather than a button — a group
   page's "edit members" — is the heading's own size, so it needs none
   of the pinning above: in flow at the right edge, on the heading's
   baseline. */
.section-head:has(> a.type-label) {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
}

/* The groups block under a search's contacts results. A
   section-head's own step is s, heading to the list it names — this
   one names the next block under the contacts card instead, so it
   takes the page's block step (m, .admin-main's gap): the break
   between the two lists reads as a block boundary, not another row
   of the same list. Scoped to a second class because every other
   section-head opens its section and has nothing above it to step
   from. */
.group-results {
  margin-top: var(--gl-space-m);
}

/* /contacts' rows, one card per first letter (ContactsIndex's
   letter_groups), the letter in a narrow column at the card's left
   rather than a heading over it: a heading row between every pair of
   cards spread the list out, and at the side the letter still marks
   where its card starts. The column fits "#" and a W alike. The
   letter sits on the first row's name line: the row's own top
   padding, then a line box as tall as the row's (Gloss's .card > li
   sets step--1, the body's 1.55 carries), so the label's text
   centers where the name's does. */
.letter-groups {
  display: flex;
  flex-direction: column;
  gap: var(--gl-space-s);
}
.letter-groups > section {
  display: grid;
  grid-template-columns: 1.5rem minmax(0, 1fr);
  column-gap: var(--gl-space-xs);
  align-items: start;
}
.letter-groups h3 {
  margin: 0;
  padding-block-start: var(--gl-space-xs);
  line-height: calc(var(--gl-step--1) * 1.55);
}

/* A row with links inside it (ContactsIndex's rows, whose group
   chips open their groups): Gloss's whole-row control is an anchor
   that is the row's only child, and an anchor cannot hold the chips'
   anchors. So the row keeps Gloss's own padding and flex, the name is
   the link, and its ::after stretches over the whole row to take a
   click anywhere on it; the chips, positioned after it in the tree,
   paint and take clicks above it. The hover fill moves to the row,
   where the one-anchor row draws it, and does not light for a chip,
   which is its own target. The focus ring stays Gloss's one ring, on
   the name. */
.linked-row {
  position: relative;
  transition: background-color var(--gl-dur-fast) var(--gl-ease);
}
.linked-row .row-link {
  display: block;
  color: inherit;
  font-weight: 550;
  text-decoration: none;
}
.linked-row .row-link::after {
  content: "";
  position: absolute;
  inset: 0;
}
.linked-row:has(.row-link:hover) {
  background: var(--gl-color-surface-fill);
}
.linked-row a.tag {
  position: relative;
}

/* Gloss's card clips, which would cut off a group tag's member list
   (Admin::GroupTag). The clip kept a row's hover fill in the card's
   corners, so the end rows take the corners themselves. */
.card:has([role="tooltip"]) {
  overflow: visible;
}
.card:has([role="tooltip"]) > li:first-child {
  border-start-start-radius: inherit;
  border-start-end-radius: inherit;
}
.card:has([role="tooltip"]) > li:last-child {
  border-end-start-radius: inherit;
  border-end-end-radius: inherit;
}
/* Gloss centers a tooltip, which runs a list wider than its tag past
   the edge the tag sits near: a set's tag starts the list with it, a
   lent row's tag, at the column's end, ends it with it. */
.tag-set [role="tooltip"] {
  left: 0;
  transform: none;
}
.detail-grid dd > span > [role="tooltip"] {
  left: auto;
  right: 0;
  transform: none;
}
ol[role="tooltip"] {
  list-style: none;
}
/* base.css's prose gap between list items. */
ol[role="tooltip"] > li {
  margin: 0;
}
/* On a phone a late tag sits too near the edge for a name after it,
   so the set holds the list, which wraps inside it. */
@media (max-width: 48rem) {
  .tag-set {
    position: relative;
  }
  .tag-set > span:has(> [role="tooltip"]) {
    position: static;
  }
  .tag-set [role="tooltip"] {
    width: max-content;
    max-width: 100%;
    white-space: normal;
  }
}

/* The members screen (Admin::GroupsMembers). The form owns the
   picker's Alpine state and so wraps both cards — the record's own,
   holding the filter and the footer, and the list under it —
   because $refs and the state reach only inside their x-data
   element. Gloss bounds a bare form at the settings width and
   spaces its children like fields; this one is a page, so it stacks
   its cards the way main stacks a record and its sections
   (.admin-main's own gap) and runs as wide as the column. */
.member-picker {
  max-width: none;
  gap: var(--gl-space-m);
}
/* The list card and what trails it — the empty line, the show-all
   button — one step apart, the dialog's own spacing under its
   rows. */
.member-list {
  display: flex;
  flex-direction: column;
  gap: var(--gl-space-2xs);
}
/* A card list's row, tickable: the label is the row's clickable
   surface and fills it, .card > li's own geometry doing the rest,
   and its gap is the row's — the label rule's own 2xs is a settings
   row's, and a list row's is the li's. The :has matches the label
   rule's own specificity game, this layer loading after Gloss but
   order not beating it there. */
.card > li > label:has(> input[type="checkbox"]) {
  flex: 1;
  min-width: 0;
  gap: var(--gl-space-xs);
}

/* base.css's `li + li { margin-top: var(--gl-space-3xs); }`
   is a prose-list rule (Markdown bullet lists) that also matches
   Card's row list, adding a stray gap below every row but the last. */
.card > li {
  margin: 0;
}

/* A contact's picture fills the avatar the initials otherwise occupy.
   Gloss's .avatar is already round and clipped (overflow hidden), so a
   picture needs only to cover the box — object-fit rather than a
   resize, because the bytes are the card's own and nothing recodes
   them. No width/height to add: .avatar sets both. */
img.avatar {
  object-fit: cover;
}

/* The rows' avatar, a touch past Gloss's lg — every list surface
   (the dashboard's rows, /contacts, the birthdays) sizes its avatar
   lg, and the anchor of a row wants slightly more presence than the
   default 2.25rem gives it. The initials ride one type step up with
   the box, to keep the share of the circle the default sizes give
   them — the same pairing the xl rule below makes at its scale.
   line-height 1, because the base sheet's 1.55 is inherited here and
   flex centers the line box it grows, not the glyphs; initials are
   the only text an avatar holds, at this the only size they render. */
.avatar[data-size="lg"] {
  --_size: 2.5rem;
  font-size: var(--gl-step--1);
  line-height: 1;
}

/* The initials' optical nudge, split out of the rule above because a
   picture shares the class: a hair of left padding moves the image's
   content box off the middle of the circle object-fit means to fill,
   and only the text needs it — the centering is exact against the
   text's advance width, whose trailing side bearing reads as blank,
   so the ink sits a touch left of the circle's middle without it. */
span.avatar[data-size="lg"] {
  padding-left: 0.125rem;
}

/* The detail header's avatar, one step past Gloss's scale — its lg is
   the dashboard rows' size, and the header's picture should anchor the
   card the way a contacts app's does: bigger than the name beside it,
   not level with it. With the top-right corner to itself, 5.5rem reads
   as photo-dominant without crowding the type-h2; the initials ride
   at step-3 to keep the same share of the circle the default sizes
   give them, rather than floating small in the bigger box. */
.avatar[data-size="xl"] {
  --_size: 5.5rem;
  font-size: var(--gl-step-3);
}

/* A group's photo, at the top of its card: a family picture, shown
   whole rather than cropped to a box, and never stretched past its
   own size. The height cap keeps a portrait from pushing the name off
   the first screen. */
.group-photo {
  display: block;
  max-width: 100%;
  max-height: 28rem;
  margin-inline: auto;
  border-radius: var(--gl-radius-md);
}

/* The detail card's masthead: the name in its own column at the left,
   the avatar anchored top-right (grid rather than float because
   .card-body is a flex column — floats on its children are ignored),
   the property grid below spanning the full width with no corner
   reserved. */
.detail-header {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: var(--gl-space-s);
  align-items: start;
}
/* Breathing room before the property grid — but only when there's a
   grid to make room for. A group that lends nothing renders no
   .detail-grid at all (GroupsShow#lends?), and this rule staying
   silent then is what keeps no dead space under its lone name. */
.detail-header:has(+ .detail-grid) {
  margin-bottom: var(--gl-space-m);
}

/* The detail card's grid: docs/DESIGN.md, "Alignment is the
   layout." `align-items: baseline` rather than the grid default
   (stretch, which top-aligns each cell's content): dt and dd carry
   different type styles — .type-label at --gl-step--2/1, .type-body-sm
   at --gl-step--1/1.5 — so lining up their boxes still leaves their
   text sitting at different heights. Baseline lines up the text itself. */
.detail-grid {
  display: grid;
  grid-template-columns: var(--type-column) 1fr;
  row-gap: var(--gl-space-2xs);
  column-gap: var(--gl-space-s);
  align-items: baseline;
  margin: 0;
}
.detail-grid dt {
  grid-column: 1;
}
.detail-grid dd {
  grid-column: 2;
  margin: 0;
}

/* The group a row is lent by, tagged at the right edge of the value
   column on the row's own first line (ContactCard#row). Under the
   value it would read as a mark between two rows — the grid's row
   gap is wider than a line, but not by enough to say which row a
   line below a value belongs to. Pinned to row 1 of the value cell, it can only
   be read as this row's.

   A nested grid rather than a float or a flex row, because the value
   is several block lines for an address and one for everything else:
   the lines stack down column 1, and the tag
   takes column 2 of the first row alone. No wrapper element to make
   them one flex item, and no source-order inversion — the value is
   still read before the group that lends it.

   Baseline-aligned: a grid item blockifies, so the span the tag's
   tooltip hangs from (Admin::GroupTag), inline-flex in Gloss, becomes
   a flex container, which takes its baseline from its first item —
   the tag, and so the tag's text — and shares the value's. A contact
   card's "edit / new" take the same place as Gloss' button group,
   a flex container like the tag. */
.detail-grid dd:has(> span > .tag),
.detail-grid dd:has(> button),
.detail-grid dd:has(> [role="group"]) {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline;
  column-gap: var(--gl-space-s);
}
.detail-grid dd > span:has(> .tag),
.detail-grid dd > button,
.detail-grid dd > [role="group"] {
  grid-column: 2;
  grid-row: 1;
}
.detail-grid dd:has(> span > .tag) > div,
.detail-grid dd:has(> button) > div,
.detail-grid dd:has(> [role="group"]) > div:not([role="group"]) {
  grid-column: 1;
}
/* The slash between the pair: the group's own ::after, ordered
   between its two children so it belongs to neither and no press of
   it is a press of edit or new, and empty alt text so a screen reader
   reads two buttons rather than a punctuation mark. In the buttons'
   own type so it sits on their baseline rather than a step larger
   beside it. The two give up their inner padding toward it, the mono
   glyph's own bearings being the space between them; the outer
   padding stays, so a hover still fills a box. */
.detail-grid dd > [role="group"] {
  align-items: baseline;
}
.detail-grid dd > [role="group"] > :first-child {
  order: 0;
  padding-inline-end: 0;
}
.detail-grid dd > [role="group"]::after {
  content: "/" / "";
  order: 1;
  font-family: var(--gl-font-mono);
  font-size: 0.8125rem;
  line-height: 1.15;
  color: var(--gl-color-text-secondary);
}
.detail-grid dd > [role="group"] > :last-child {
  order: 2;
  padding-inline-start: 0;
}
/* On a phone the group stacks and the slash goes, the line break
   saying what it said: side by side the two take the tags' column
   down to a word. With nothing between them the two keep their whole
   padding, so their right edges stand together, and the two pads
   between the labels are drawn out entirely, so the pair reads as
   one control rather than two rows. */
@media (max-width: 48rem) {
  .detail-grid dd > [role="group"] {
    flex-direction: column;
    align-items: flex-end;
  }
  .detail-grid dd > [role="group"]::after {
    content: none;
  }
  .detail-grid dd > [role="group"] > :first-child,
  .detail-grid dd > [role="group"] > :last-child {
    padding-inline: var(--_pad-x);
  }
  .detail-grid dd > [role="group"] > :last-child {
    margin-block-start: calc(-2 * var(--gl-space-3xs));
  }
}
/* A nameless group shown by its id (Admin::GroupLabel): set in mono,
   a step quieter, and at regular weight even inside a bold heading or
   row, so it reads as an identifier standing in for a name rather than
   as the name itself. */
[data-nameless] {
  font-family: var(--gl-font-mono);
  font-weight: 400;
  color: var(--gl-color-text-secondary);
}

/* A tag that opens a record is still a chip, not running text, so it
   sheds Gloss' link underline the way Gloss' own a.btn does. */
a.tag {
  text-decoration: none;
}

/* A dialog's title and action bars close to the smallest step the
   record's rows use, a line and its buttons being all either holds;
   Gloss's own spend more of a short dialog on chrome than on content.
   The body takes one step on every side and the bars take it on
   theirs, so the title, the fields, and the buttons share one edge:
   Gloss's side step leaves the buttons adrift from the corner, and a
   narrower step for the bars alone breaks the edge. */
dialog > header,
dialog > footer {
  padding: var(--gl-space-2xs) var(--gl-space-s);
}
dialog > :not(header):not(footer) {
  padding: var(--gl-space-s);
}

/* An editor's card ends in a dialog's action bar (ContactsEdit,
   GroupsEdit), restated for a card, which has no footer of its own.
   Padded evenly — the buttons set in from the card body's own m,
   which the hairline above them already makes room for: the bar
   reads as its own frame rather than as the body's last row. */
.card > footer {
  display: flex;
  justify-content: flex-end;
  gap: var(--gl-space-2xs);
  padding: var(--gl-space-xs);
  border-top: 1px solid var(--gl-color-border-hairline);
}

/* A dialog's lists of one-line rows — the groups dialog's checkboxes
   (Admin::GroupDialog), and the add dialog's radios (ContactsEdit)
   through the first rule — are not the settings form Gloss spaces its
   stacks for, so they close up the way the record's form does
   (.card-body form.field-stack). The groups dialog's filter stands a
   step apart from the list it filters, and its box sheds its block
   padding. */
dialog .field-stack {
  gap: var(--gl-space-2xs);
}
dialog form.field-stack {
  gap: var(--gl-space-3xs);
}
dialog > .field-stack:has(> .filter) {
  gap: var(--gl-space-xs);
}
dialog > .field-stack > .filter > input[type="search"] {
  padding-block: var(--gl-space-3xs);
}

/* The groups dialog's list is the one that can outgrow the screen, so
   it is the one that scrolls, not the popover, which clips its footer
   (docs/plans/2026-09-22-a-few-groups-at-a-time.md). The cap is
   still what keeps the opening list short; this is for the two lists
   the cap does not shorten, the whole list once it is asked for and a
   contact's own memberships, which are never capped. The filter and
   the buttons sit outside the scroller, so both stay put while it
   moves. */
#edit-groups form.field-stack {
  max-height: 50vh;
  overflow-y: auto;
}

/* The stored card in its own collapsed card under the record — the
   column's gap spaces the two, so no margin here. <details> is native,
   needing no script; the summary rides the label
   register, the pre scrolls rather than wraps: the bytes are the wire
   format, folded lines and all. */
.card-body summary {
  cursor: pointer;
}
.card-body pre {
  margin: var(--gl-space-2xs) 0 0;
  overflow-x: auto;
}

/* The change log in the third card, on the record's own grid so the
   two read as one page (ContactsShow#change_rows). The margin is the
   pre's above, for the same reason: a summary and the block it
   discloses are one line apart, not a card's gap. */
.change-log {
  margin-top: var(--gl-space-2xs);
}
/* The etag, cut to a short hash (Format.short_etag) with the whole of
   it in the title. Dotted rather than the solid underline a link
   wears, and the help cursor over it: the mark has to say there is
   more here without saying this navigates, since it does not. Both
   are spelled out rather than left to the browser, whose defaults for
   abbr[title] differ on each of them. */
.change-log abbr {
  cursor: help;
  text-decoration: underline dotted;
}

/* The lines a write moved, under the etag they hashed to. Removed
   above added, the order a diff hunk reads in, and the sign in front
   of each line is what says which — the color is a second telling of
   it, not the only one, so a row survives being read in one hue. Both
   wrap for the etag's reason: a line here is a card's, and a card's
   lines run past 75 octets often enough that the RFC has a folding
   rule for it. */
.diff {
  margin-top: var(--gl-space-2xs);
}
.diff > div {
  overflow-wrap: anywhere;
}
.diff-removed {
  color: var(--gl-color-danger);
}
.diff-added {
  color: var(--gl-color-success);
}

/* The import's walk: the contacts down one side, whatever is being
   read beside them (Admin::ImportSidebar). Asymmetric where .paired
   and .dashboard are even, because these columns are not peers -- one
   is a place in a list and the other is the thing being looked at --
   so it names a width for the first and gives the rest to the second,
   and collapses to one column where the two cannot both stand. The
   sidebar goes on top there rather than under: it is how you got to
   what is below it.

   Both columns are .record blocks, caption row over card, so their
   cards start at the same height -- two columns beginning a rung
   apart read as a mistake before they read as anything else. */
.walk {
  display: grid;
  grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
  gap: var(--gl-space-m);
  align-items: start;
}

/* Wider than the wide column, because three things stand in it: the
   list, the card as exported, and the editor. The editor's own widest
   row is sized against the value column the reading width names (see
   .admin-main above), so a third of 68rem would put the pair one
   under the other. */
.admin-main:has(.walk) {
  max-width: 92rem;
}

/* The pair inside the walk is two columns or one, never the auto-fit
   in between: at this width auto-fit's fallback is always the stack, and a
   card read beside its own source is the whole point of the screen.
   It collapses with the walk itself, on the walk's own breakpoint. */
.walk .paired {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}

@media (max-width: 64rem) {
  .walk,
  .walk .paired {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Whether a row is in the book yet, which is the one thing the walk
   turns on: the verdict at every row's trailing edge, a circle that
   gains a check when the row is — in the success color, confirmed
   being that color's one meaning (docs/DESIGN.md) — and the name a
   step quieter with it, a done thing receding so the rows still to
   do carry the walk's own ink — not an accent rail on the rows
   still to do, which says which are left without saying the other
   half. The word is still in the row for a screen reader, which cannot see a
   check.

   Both rules below carry `:only-child` for the specificity rather
   than for the match: a whole-row link is Gloss's `all: unset`
   (components.css, .card > li > a:only-child), which resets
   color along with everything else, and a selector that does not
   out-weigh it loses whatever it declares here. The hover rule
   further up this file names the same shape for the same reason. */
.walk-list a:only-child[data-state="saved"] > div {
  color: var(--gl-color-text-secondary);
}
.walk-list a:only-child[data-state="saved"] > svg {
  color: var(--gl-color-success);
}

/* The row being read. The fill is the hover gesture too, so the name
   takes the weight that says this one is not merely under a pointer.
   Not accent-soft, for the accent block's reason above. */
.walk-list a:only-child[aria-current] {
  background: var(--gl-color-surface-fill-2);
}
.walk-list a:only-child[aria-current] > div {
  font-weight: 700;
}

/* The count a row is losing, at its trailing edge: a type-label in
   the danger color, the pairing a struck line on the card as exported
   already wears (.card-lines above). */
.walk-list .diff-removed {
  white-space: nowrap;
}

/* What a walk's row saves into (Admin::ImportTarget): Gloss's
   segmented tabs, whose track is one line of its own width, centered
   over the editor it asks its question for — and a wrapped track
   centers its lines with it. A match is a person's whole name and
   there can be three of them, so the track wraps between segments
   rather than inside one — a name broken over two lines reads as two
   segments. The track is a filled pill among the stack's one-line
   rows, so it takes a step past the field rhythm under it. Above it
   is the record card's own top padding: a filled track pulled up
   toward the card's edge ran its fill into the page behind it.
   The form's own selector for the weight over Gloss's `all: unset`. */
.import-target {
  text-align: center;
  margin-bottom: var(--gl-space-2xs);
}
.card-body form [role="tablist"][data-variant="segmented"] {
  flex-wrap: wrap;
  justify-content: center;
}
.card-body form [role="tablist"][data-variant="segmented"] [role="tab"] {
  white-space: nowrap;
}