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;
}