@ptbdynamic/plinth 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -2
  3. package/package.json +99 -4
  4. package/packages/css/dist/avatar.css +122 -0
  5. package/packages/css/dist/badge.css +76 -0
  6. package/packages/css/dist/button.css +273 -0
  7. package/packages/css/dist/callout.css +143 -0
  8. package/packages/css/dist/code-block.css +283 -0
  9. package/packages/css/dist/confirm-dialog.css +183 -0
  10. package/packages/css/dist/data-table.css +470 -0
  11. package/packages/css/dist/dialog.css +113 -0
  12. package/packages/css/dist/drawer.css +156 -0
  13. package/packages/css/dist/fonts/geist/LICENSE +93 -0
  14. package/packages/css/dist/fonts/geist/geist-cyrillic-ext-wght-normal.woff2 +0 -0
  15. package/packages/css/dist/fonts/geist/geist-cyrillic-wght-normal.woff2 +0 -0
  16. package/packages/css/dist/fonts/geist/geist-latin-ext-wght-normal.woff2 +0 -0
  17. package/packages/css/dist/fonts/geist/geist-latin-wght-normal.woff2 +0 -0
  18. package/packages/css/dist/fonts/geist/geist-vietnamese-wght-normal.woff2 +0 -0
  19. package/packages/css/dist/fonts/geist-mono/LICENSE +93 -0
  20. package/packages/css/dist/fonts/geist-mono/geist-mono-cyrillic-ext-wght-normal.woff2 +0 -0
  21. package/packages/css/dist/fonts/geist-mono/geist-mono-cyrillic-wght-normal.woff2 +0 -0
  22. package/packages/css/dist/fonts/geist-mono/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  23. package/packages/css/dist/fonts/geist-mono/geist-mono-latin-wght-normal.woff2 +0 -0
  24. package/packages/css/dist/fonts/geist-mono/geist-mono-symbols2-wght-normal.woff2 +0 -0
  25. package/packages/css/dist/fonts/geist-mono/geist-mono-vietnamese-wght-normal.woff2 +0 -0
  26. package/packages/css/dist/fonts/index.css +109 -0
  27. package/packages/css/dist/header.css +110 -0
  28. package/packages/css/dist/icon.css +51 -0
  29. package/packages/css/dist/list-card.css +76 -0
  30. package/packages/css/dist/list-row.css +164 -0
  31. package/packages/css/dist/menu-sheet.css +84 -0
  32. package/packages/css/dist/menu.css +245 -0
  33. package/packages/css/dist/modal.css +367 -0
  34. package/packages/css/dist/page-header.css +124 -0
  35. package/packages/css/dist/pill-row.css +197 -0
  36. package/packages/css/dist/plinth.css +4446 -0
  37. package/packages/css/dist/section-menu.css +226 -0
  38. package/packages/css/dist/sheet.css +239 -0
  39. package/packages/css/dist/text-field.css +230 -0
  40. package/packages/css/dist/textarea.css +169 -0
  41. package/packages/css/dist/tokens.css +413 -0
  42. package/packages/react/dist/PlinthAvatar.d.ts +17 -0
  43. package/packages/react/dist/PlinthBadge.d.ts +7 -0
  44. package/packages/react/dist/PlinthButton.d.ts +29 -0
  45. package/packages/react/dist/PlinthCallout.d.ts +16 -0
  46. package/packages/react/dist/PlinthCodeBlock.d.ts +24 -0
  47. package/packages/react/dist/PlinthConfirmDialog.d.ts +38 -0
  48. package/packages/react/dist/PlinthDataTable.d.ts +11 -0
  49. package/packages/react/dist/PlinthDataTableCell.d.ts +15 -0
  50. package/packages/react/dist/PlinthDataTableColumn.d.ts +13 -0
  51. package/packages/react/dist/PlinthDataTableEmpty.d.ts +9 -0
  52. package/packages/react/dist/PlinthDataTableRow.d.ts +9 -0
  53. package/packages/react/dist/PlinthDataTableSort.d.ts +4 -0
  54. package/packages/react/dist/PlinthDialog.d.ts +26 -0
  55. package/packages/react/dist/PlinthDrawer.d.ts +29 -0
  56. package/packages/react/dist/PlinthIcon.d.ts +10 -0
  57. package/packages/react/dist/PlinthIconButton.d.ts +26 -0
  58. package/packages/react/dist/PlinthListCard.d.ts +14 -0
  59. package/packages/react/dist/PlinthListRow.d.ts +29 -0
  60. package/packages/react/dist/PlinthMenu.d.ts +12 -0
  61. package/packages/react/dist/PlinthMenuGroup.d.ts +9 -0
  62. package/packages/react/dist/PlinthMenuItem.d.ts +26 -0
  63. package/packages/react/dist/PlinthMenuSeparator.d.ts +4 -0
  64. package/packages/react/dist/PlinthMenuSheet.d.ts +24 -0
  65. package/packages/react/dist/PlinthPageHeader.d.ts +17 -0
  66. package/packages/react/dist/PlinthPill.d.ts +17 -0
  67. package/packages/react/dist/PlinthPillRow.d.ts +13 -0
  68. package/packages/react/dist/PlinthSectionMenu.d.ts +11 -0
  69. package/packages/react/dist/PlinthSectionMenuItem.d.ts +20 -0
  70. package/packages/react/dist/PlinthSheet.d.ts +26 -0
  71. package/packages/react/dist/PlinthTextField.d.ts +24 -0
  72. package/packages/react/dist/PlinthTextarea.d.ts +24 -0
  73. package/packages/react/dist/avatar.d.ts +12 -0
  74. package/packages/react/dist/button.d.ts +21 -0
  75. package/packages/react/dist/callout.d.ts +19 -0
  76. package/packages/react/dist/code-block.d.ts +16 -0
  77. package/packages/react/dist/confirm-dialog.d.ts +23 -0
  78. package/packages/react/dist/data-table.d.ts +15 -0
  79. package/packages/react/dist/dialog.d.ts +10 -0
  80. package/packages/react/dist/drawer.d.ts +20 -0
  81. package/packages/react/dist/heading.d.ts +5 -0
  82. package/packages/react/dist/icon.d.ts +5 -0
  83. package/packages/react/dist/index.d.ts +49 -0
  84. package/packages/react/dist/index.js +1326 -0
  85. package/packages/react/dist/list-row.d.ts +5 -0
  86. package/packages/react/dist/markup.d.ts +9 -0
  87. package/packages/react/dist/menu.d.ts +7 -0
  88. package/packages/react/dist/page-header.d.ts +5 -0
  89. package/packages/react/dist/sheet.d.ts +12 -0
  90. package/packages/react/dist/size.d.ts +13 -0
  91. package/packages/react/dist/swipe.d.ts +15 -0
  92. package/packages/react/dist/text-field.d.ts +10 -0
  93. package/packages/react/dist/tones.d.ts +5 -0
  94. package/packages/tokens/dist/index.d.ts +230 -0
  95. package/packages/tokens/dist/index.js +4091 -0
  96. package/packages/tokens/dist/tailwind-theme.css +80 -0
  97. package/packages/tokens/dist/tokens.css +413 -0
  98. package/packages/tokens/dist/tokens.json +3875 -0
  99. package/packages/vue/dist/PlinthAvatar.vue.d.ts +24 -0
  100. package/packages/vue/dist/PlinthBadge.vue.d.ts +21 -0
  101. package/packages/vue/dist/PlinthButton.vue.d.ts +42 -0
  102. package/packages/vue/dist/PlinthCallout.vue.d.ts +31 -0
  103. package/packages/vue/dist/PlinthCodeBlock.vue.d.ts +49 -0
  104. package/packages/vue/dist/PlinthConfirmDialog.vue.d.ts +51 -0
  105. package/packages/vue/dist/PlinthDataTable.vue.d.ts +21 -0
  106. package/packages/vue/dist/PlinthDataTableCell.vue.d.ts +29 -0
  107. package/packages/vue/dist/PlinthDataTableColumn.vue.d.ts +26 -0
  108. package/packages/vue/dist/PlinthDataTableEmpty.vue.d.ts +19 -0
  109. package/packages/vue/dist/PlinthDataTableRow.vue.d.ts +19 -0
  110. package/packages/vue/dist/PlinthDataTableSort.vue.d.ts +13 -0
  111. package/packages/vue/dist/PlinthDialog.vue.d.ts +45 -0
  112. package/packages/vue/dist/PlinthDrawer.vue.d.ts +48 -0
  113. package/packages/vue/dist/PlinthIcon.vue.d.ts +23 -0
  114. package/packages/vue/dist/PlinthIconButton.vue.d.ts +37 -0
  115. package/packages/vue/dist/PlinthListCard.vue.d.ts +26 -0
  116. package/packages/vue/dist/PlinthListRow.vue.d.ts +38 -0
  117. package/packages/vue/dist/PlinthMenu.vue.d.ts +24 -0
  118. package/packages/vue/dist/PlinthMenuGroup.vue.d.ts +17 -0
  119. package/packages/vue/dist/PlinthMenuItem.vue.d.ts +34 -0
  120. package/packages/vue/dist/PlinthMenuSeparator.vue.d.ts +3 -0
  121. package/packages/vue/dist/PlinthMenuSheet.vue.d.ts +48 -0
  122. package/packages/vue/dist/PlinthPageHeader.vue.d.ts +27 -0
  123. package/packages/vue/dist/PlinthPill.vue.d.ts +28 -0
  124. package/packages/vue/dist/PlinthPillRow.vue.d.ts +22 -0
  125. package/packages/vue/dist/PlinthSectionMenu.vue.d.ts +22 -0
  126. package/packages/vue/dist/PlinthSectionMenuItem.vue.d.ts +28 -0
  127. package/packages/vue/dist/PlinthSheet.vue.d.ts +45 -0
  128. package/packages/vue/dist/PlinthTextField.vue.d.ts +51 -0
  129. package/packages/vue/dist/PlinthTextarea.vue.d.ts +42 -0
  130. package/packages/vue/dist/avatar.d.ts +12 -0
  131. package/packages/vue/dist/button.d.ts +5 -0
  132. package/packages/vue/dist/callout.d.ts +19 -0
  133. package/packages/vue/dist/code-block.d.ts +16 -0
  134. package/packages/vue/dist/confirm-dialog.d.ts +23 -0
  135. package/packages/vue/dist/data-table.d.ts +15 -0
  136. package/packages/vue/dist/dialog.d.ts +10 -0
  137. package/packages/vue/dist/drawer.d.ts +20 -0
  138. package/packages/vue/dist/heading.d.ts +5 -0
  139. package/packages/vue/dist/icon.d.ts +5 -0
  140. package/packages/vue/dist/index.d.ts +53 -0
  141. package/packages/vue/dist/index.js +1732 -0
  142. package/packages/vue/dist/list-row.d.ts +5 -0
  143. package/packages/vue/dist/menu.d.ts +8 -0
  144. package/packages/vue/dist/page-header.d.ts +5 -0
  145. package/packages/vue/dist/sheet.d.ts +12 -0
  146. package/packages/vue/dist/size.d.ts +13 -0
  147. package/packages/vue/dist/slots.d.ts +3 -0
  148. package/packages/vue/dist/swipe.d.ts +12 -0
  149. package/packages/vue/dist/text-field.d.ts +10 -0
  150. package/packages/vue/dist/tones.d.ts +5 -0
@@ -0,0 +1,4446 @@
1
+ /* Built from packages/tokens/tokens/*.json. Do not edit by hand. */
2
+
3
+ @layer plinth {
4
+ :root {
5
+ --plinth-ref-color-neutral-0: oklch(1 0 0);
6
+ --plinth-ref-color-neutral-50: oklch(0.985 0.002 260);
7
+ --plinth-ref-color-neutral-100: oklch(0.965 0.003 260);
8
+ --plinth-ref-color-neutral-150: oklch(0.945 0.004 260);
9
+ --plinth-ref-color-neutral-200: oklch(0.92 0.004 260);
10
+ --plinth-ref-color-neutral-300: oklch(0.82 0.005 260);
11
+ --plinth-ref-color-neutral-400: oklch(0.71 0.008 260);
12
+ --plinth-ref-color-neutral-500: oklch(0.54 0.01 260);
13
+ --plinth-ref-color-neutral-600: oklch(0.45 0.01 260);
14
+ --plinth-ref-color-neutral-700: oklch(0.37 0.01 260);
15
+ --plinth-ref-color-neutral-800: oklch(0.27 0.008 260);
16
+ --plinth-ref-color-neutral-850: oklch(0.235 0.007 260);
17
+ --plinth-ref-color-neutral-900: oklch(0.205 0.006 260);
18
+ --plinth-ref-color-neutral-950: oklch(0.18 0.005 260);
19
+ --plinth-ref-color-neutral-1000: oklch(0.145 0.004 260);
20
+ --plinth-ref-color-brand-50: #eff6ff;
21
+ --plinth-ref-color-brand-100: #dbeafe;
22
+ --plinth-ref-color-brand-200: #bfdbfe;
23
+ --plinth-ref-color-brand-300: #93c5fd;
24
+ --plinth-ref-color-brand-400: #60a5fa;
25
+ --plinth-ref-color-brand-500: #3b82f6;
26
+ --plinth-ref-color-brand-600: #2563eb;
27
+ --plinth-ref-color-brand-700: #1d4ed8;
28
+ --plinth-ref-color-brand-800: #1e40af;
29
+ --plinth-ref-color-brand-900: #1e3a8a;
30
+ --plinth-ref-color-brand-950: #172554;
31
+ --plinth-ref-color-green-50: #f0fdf4;
32
+ --plinth-ref-color-green-100: #dcfce7;
33
+ --plinth-ref-color-green-200: #bbf7d0;
34
+ --plinth-ref-color-green-400: #4ade80;
35
+ --plinth-ref-color-green-600: #16a34a;
36
+ --plinth-ref-color-green-700: #15803d;
37
+ --plinth-ref-color-green-800: #166534;
38
+ --plinth-ref-color-green-950: #052e16;
39
+ --plinth-ref-color-amber-50: #fffbeb;
40
+ --plinth-ref-color-amber-100: #fef3c7;
41
+ --plinth-ref-color-amber-200: #fde68a;
42
+ --plinth-ref-color-amber-400: #fbbf24;
43
+ --plinth-ref-color-amber-500: #f59e0b;
44
+ --plinth-ref-color-amber-700: #b45309;
45
+ --plinth-ref-color-amber-800: #92400e;
46
+ --plinth-ref-color-amber-950: #451a03;
47
+ --plinth-ref-color-red-50: #fef2f2;
48
+ --plinth-ref-color-red-100: #fee2e2;
49
+ --plinth-ref-color-red-200: #fecaca;
50
+ --plinth-ref-color-red-400: #f87171;
51
+ --plinth-ref-color-red-600: #dc2626;
52
+ --plinth-ref-color-red-700: #b91c1c;
53
+ --plinth-ref-color-red-800: #991b1b;
54
+ --plinth-ref-color-red-950: #450a0a;
55
+ --plinth-ref-color-black-alpha-10: #0000001a;
56
+ --plinth-ref-color-black-alpha-15: #00000026;
57
+ --plinth-ref-color-black-alpha-30: #0000004d;
58
+ --plinth-ref-color-black-alpha-50: #00000080;
59
+ --plinth-ref-color-black-alpha-60: #00000099;
60
+ --plinth-ref-color-black-alpha-70: #000000b3;
61
+ --plinth-ref-color-ink-alpha-6: oklch(0.18 0.005 260 / 0.06);
62
+ --plinth-ref-color-ink-alpha-10: oklch(0.18 0.005 260 / 0.1);
63
+ --plinth-ref-color-ink-alpha-20: oklch(0.18 0.005 260 / 0.2);
64
+ --plinth-ref-color-chalk-alpha-6: oklch(0.965 0.003 260 / 0.06);
65
+ --plinth-ref-color-chalk-alpha-10: oklch(0.965 0.003 260 / 0.1);
66
+ --plinth-ref-color-chalk-alpha-20: oklch(0.965 0.003 260 / 0.2);
67
+ --plinth-ref-color-white-alpha-12: #ffffff1f;
68
+ --plinth-ref-space-0: 0;
69
+ --plinth-ref-space-1: 0.25rem;
70
+ --plinth-ref-space-2: 0.5rem;
71
+ --plinth-ref-space-3: 0.75rem;
72
+ --plinth-ref-space-4: 1rem;
73
+ --plinth-ref-space-5: 1.25rem;
74
+ --plinth-ref-space-6: 1.5rem;
75
+ --plinth-ref-space-8: 2rem;
76
+ --plinth-ref-space-10: 2.5rem;
77
+ --plinth-ref-space-12: 3rem;
78
+ --plinth-ref-space-14: 3.5rem;
79
+ --plinth-ref-space-16: 4rem;
80
+ --plinth-ref-space-0-5: 0.125rem;
81
+ --plinth-ref-size-16: 1rem;
82
+ --plinth-ref-size-20: 1.25rem;
83
+ --plinth-ref-size-24: 1.5rem;
84
+ --plinth-ref-size-28: 1.75rem;
85
+ --plinth-ref-size-32: 2rem;
86
+ --plinth-ref-size-40: 2.5rem;
87
+ --plinth-ref-size-44: 2.75rem;
88
+ --plinth-ref-size-56: 3.5rem;
89
+ --plinth-ref-size-160: 10rem;
90
+ --plinth-ref-size-192: 12rem;
91
+ --plinth-ref-size-320: 20rem;
92
+ --plinth-ref-size-400: 25rem;
93
+ --plinth-ref-size-480: 30rem;
94
+ --plinth-ref-radius-0: 0;
95
+ --plinth-ref-radius-2: 0.125rem;
96
+ --plinth-ref-radius-4: 0.25rem;
97
+ --plinth-ref-radius-6: 0.375rem;
98
+ --plinth-ref-radius-8: 0.5rem;
99
+ --plinth-ref-radius-10: 0.625rem;
100
+ --plinth-ref-radius-12: 0.75rem;
101
+ --plinth-ref-radius-16: 1rem;
102
+ --plinth-ref-radius-full: 9999px;
103
+ --plinth-ref-border-width-1: 1px;
104
+ --plinth-ref-border-width-2: 2px;
105
+ --plinth-ref-border-width-3: 3px;
106
+ --plinth-ref-font-family-sans: 'Geist Variable', Geist, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
107
+ --plinth-ref-font-family-mono: 'Geist Mono Variable', 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
108
+ --plinth-ref-font-size-12: 0.75rem;
109
+ --plinth-ref-font-size-13: 0.8125rem;
110
+ --plinth-ref-font-size-14: 0.875rem;
111
+ --plinth-ref-font-size-15: 0.9375rem;
112
+ --plinth-ref-font-size-16: 1rem;
113
+ --plinth-ref-font-size-18: 1.125rem;
114
+ --plinth-ref-font-size-24: 1.5rem;
115
+ --plinth-ref-font-size-30: 1.875rem;
116
+ --plinth-ref-font-size-36: 2.25rem;
117
+ --plinth-ref-font-size-48: 3rem;
118
+ --plinth-ref-font-size-64: 4rem;
119
+ --plinth-ref-font-weight-400: 400;
120
+ --plinth-ref-font-weight-500: 500;
121
+ --plinth-ref-font-weight-600: 600;
122
+ --plinth-ref-font-weight-700: 700;
123
+ --plinth-ref-font-leading-105: 1.05;
124
+ --plinth-ref-font-leading-125: 1.25;
125
+ --plinth-ref-font-leading-150: 1.5;
126
+ --plinth-ref-font-tracking-0: 0;
127
+ --plinth-ref-font-tracking-neg-1: -0.01em;
128
+ --plinth-ref-font-tracking-neg-2: -0.02em;
129
+ --plinth-ref-font-tracking-neg-4: -0.04em;
130
+ --plinth-ref-duration-120: 120ms;
131
+ --plinth-ref-duration-200: 200ms;
132
+ --plinth-ref-duration-320: 320ms;
133
+ --plinth-ref-easing-standard: cubic-bezier(0.2, 0, 0, 1);
134
+ --plinth-ref-easing-enter: cubic-bezier(0, 0, 0, 1);
135
+ --plinth-ref-easing-exit: cubic-bezier(0.3, 0, 1, 1);
136
+ --plinth-space-0: var(--plinth-ref-space-0);
137
+ --plinth-space-1: var(--plinth-ref-space-1);
138
+ --plinth-space-2: var(--plinth-ref-space-2);
139
+ --plinth-space-3: var(--plinth-ref-space-3);
140
+ --plinth-space-4: var(--plinth-ref-space-4);
141
+ --plinth-space-5: var(--plinth-ref-space-5);
142
+ --plinth-space-6: var(--plinth-ref-space-6);
143
+ --plinth-space-8: var(--plinth-ref-space-8);
144
+ --plinth-space-10: var(--plinth-ref-space-10);
145
+ --plinth-space-12: var(--plinth-ref-space-12);
146
+ --plinth-space-14: var(--plinth-ref-space-14);
147
+ --plinth-space-16: var(--plinth-ref-space-16);
148
+ --plinth-space-0-5: var(--plinth-ref-space-0-5);
149
+ --plinth-radius-control: var(--plinth-ref-radius-6);
150
+ --plinth-radius-container: var(--plinth-ref-radius-10);
151
+ --plinth-radius-pill: var(--plinth-ref-radius-full);
152
+ --plinth-border-width-thin: var(--plinth-ref-border-width-1);
153
+ --plinth-border-width-thick: var(--plinth-ref-border-width-2);
154
+ --plinth-opacity-disabled: 0.5;
155
+ --plinth-focus-ring-width: var(--plinth-ref-border-width-2);
156
+ --plinth-focus-ring-offset: 0;
157
+ --plinth-focus-ring-offset-filled: var(--plinth-ref-border-width-2);
158
+ --plinth-focus-ring-offset-inset: -2px;
159
+ --plinth-font-family-body: var(--plinth-ref-font-family-sans);
160
+ --plinth-font-family-mono: var(--plinth-ref-font-family-mono);
161
+ --plinth-font-size-xs: var(--plinth-ref-font-size-12);
162
+ --plinth-font-size-sm: var(--plinth-ref-font-size-13);
163
+ --plinth-font-size-md: var(--plinth-ref-font-size-14);
164
+ --plinth-font-size-lg: var(--plinth-ref-font-size-15);
165
+ --plinth-font-size-xl: var(--plinth-ref-font-size-18);
166
+ --plinth-font-size-2xl: var(--plinth-ref-font-size-24);
167
+ --plinth-font-size-3xl: var(--plinth-ref-font-size-30);
168
+ --plinth-font-size-4xl: var(--plinth-ref-font-size-36);
169
+ --plinth-font-size-5xl: var(--plinth-ref-font-size-48);
170
+ --plinth-font-size-6xl: var(--plinth-ref-font-size-64);
171
+ --plinth-font-size-field: var(--plinth-ref-font-size-16);
172
+ --plinth-font-weight-regular: var(--plinth-ref-font-weight-400);
173
+ --plinth-font-weight-medium: var(--plinth-ref-font-weight-500);
174
+ --plinth-font-weight-semibold: var(--plinth-ref-font-weight-600);
175
+ --plinth-font-weight-bold: var(--plinth-ref-font-weight-700);
176
+ --plinth-font-leading-display: var(--plinth-ref-font-leading-105);
177
+ --plinth-font-leading-tight: var(--plinth-ref-font-leading-125);
178
+ --plinth-font-leading-normal: var(--plinth-ref-font-leading-150);
179
+ --plinth-font-tracking-normal: var(--plinth-ref-font-tracking-0);
180
+ --plinth-font-tracking-snug: var(--plinth-ref-font-tracking-neg-1);
181
+ --plinth-font-tracking-tight: var(--plinth-ref-font-tracking-neg-2);
182
+ --plinth-font-tracking-tighter: var(--plinth-ref-font-tracking-neg-4);
183
+ --plinth-duration-fast: var(--plinth-ref-duration-120);
184
+ --plinth-duration-normal: var(--plinth-ref-duration-200);
185
+ --plinth-duration-slow: var(--plinth-ref-duration-320);
186
+ --plinth-easing-standard: var(--plinth-ref-easing-standard);
187
+ --plinth-easing-enter: var(--plinth-ref-easing-enter);
188
+ --plinth-easing-exit: var(--plinth-ref-easing-exit);
189
+ --plinth-control-sm: var(--plinth-ref-size-28);
190
+ --plinth-control-md: var(--plinth-ref-size-32);
191
+ --plinth-control-lg: var(--plinth-ref-size-40);
192
+ --plinth-touch-target: var(--plinth-ref-size-40);
193
+ --plinth-size-icon-sm: var(--plinth-ref-size-16);
194
+ --plinth-size-icon-md: var(--plinth-ref-size-20);
195
+ --plinth-size-icon-lg: var(--plinth-ref-size-24);
196
+ --plinth-size-row: var(--plinth-ref-size-44);
197
+ --plinth-size-avatar-sm: var(--plinth-ref-size-24);
198
+ --plinth-size-avatar-md: var(--plinth-ref-size-32);
199
+ --plinth-size-avatar-lg: var(--plinth-ref-size-40);
200
+ --plinth-size-avatar-xl: var(--plinth-ref-size-56);
201
+ --plinth-size-dialog: var(--plinth-ref-size-480);
202
+ --plinth-size-panel: var(--plinth-ref-size-400);
203
+ --plinth-size-peek: var(--plinth-ref-size-160);
204
+ --plinth-size-menu-min: var(--plinth-ref-size-192);
205
+ --plinth-size-menu-max: var(--plinth-ref-size-320);
206
+ --plinth-layer-base: 0;
207
+ --plinth-layer-sticky: 100;
208
+ --plinth-layer-overlay: 200;
209
+ --plinth-layer-sheet: 300;
210
+ --plinth-layer-toast: 400;
211
+ --plinth-breakpoint-md: 768px;
212
+ --plinth-breakpoint-lg: 1280px;
213
+ --plinth-page-header-height: var(--plinth-space-14);
214
+ --plinth-page-header-offset: var(--plinth-space-0);
215
+ --plinth-data-table-row-height: var(--plinth-size-row);
216
+ --plinth-list-row-height: var(--plinth-size-row);
217
+ --plinth-pill-height: var(--plinth-control-md);
218
+ --plinth-dialog-width: var(--plinth-size-dialog);
219
+ --plinth-sheet-side-width: var(--plinth-size-panel);
220
+ --plinth-drawer-peek-height: var(--plinth-size-peek);
221
+ --plinth-section-menu-height: var(--plinth-space-14);
222
+ --plinth-section-menu-item-height: var(--plinth-control-lg);
223
+ --plinth-menu-min-width: var(--plinth-size-menu-min);
224
+ --plinth-menu-max-width: var(--plinth-size-menu-max);
225
+ --plinth-text-field-trailing-size: var(--plinth-control-md);
226
+ }
227
+
228
+ :root,
229
+ [data-theme="light"] {
230
+ color-scheme: light;
231
+ --plinth-color-surface: var(--plinth-ref-color-neutral-50);
232
+ --plinth-color-surface-raised: var(--plinth-ref-color-neutral-0);
233
+ --plinth-color-surface-sunken: var(--plinth-ref-color-neutral-100);
234
+ --plinth-color-surface-hover: var(--plinth-ref-color-neutral-150);
235
+ --plinth-color-text: var(--plinth-ref-color-neutral-950);
236
+ --plinth-color-text-muted: var(--plinth-ref-color-neutral-600);
237
+ --plinth-color-text-subtle: var(--plinth-ref-color-neutral-500);
238
+ --plinth-color-border: var(--plinth-ref-color-ink-alpha-10);
239
+ --plinth-color-border-strong: var(--plinth-ref-color-ink-alpha-20);
240
+ --plinth-color-divider: var(--plinth-ref-color-ink-alpha-6);
241
+ --plinth-color-primary: var(--plinth-ref-color-neutral-950);
242
+ --plinth-color-primary-hover: var(--plinth-ref-color-neutral-800);
243
+ --plinth-color-primary-active: var(--plinth-ref-color-neutral-700);
244
+ --plinth-color-on-primary: var(--plinth-ref-color-neutral-0);
245
+ --plinth-color-primary-edge: var(--plinth-ref-color-white-alpha-12);
246
+ --plinth-color-accent: var(--plinth-ref-color-brand-600);
247
+ --plinth-color-accent-text: var(--plinth-ref-color-brand-700);
248
+ --plinth-color-on-accent: var(--plinth-ref-color-neutral-0);
249
+ --plinth-color-focus-ring: var(--plinth-ref-color-brand-600);
250
+ --plinth-color-overlay: var(--plinth-ref-color-black-alpha-50);
251
+ --plinth-color-info: var(--plinth-ref-color-brand-600);
252
+ --plinth-color-info-surface: var(--plinth-ref-color-brand-100);
253
+ --plinth-color-info-text: var(--plinth-ref-color-brand-800);
254
+ --plinth-color-success: var(--plinth-ref-color-green-600);
255
+ --plinth-color-success-surface: var(--plinth-ref-color-green-100);
256
+ --plinth-color-success-text: var(--plinth-ref-color-green-800);
257
+ --plinth-color-warning: var(--plinth-ref-color-amber-500);
258
+ --plinth-color-warning-surface: var(--plinth-ref-color-amber-100);
259
+ --plinth-color-warning-text: var(--plinth-ref-color-amber-800);
260
+ --plinth-color-danger: var(--plinth-ref-color-red-600);
261
+ --plinth-color-danger-surface: var(--plinth-ref-color-red-100);
262
+ --plinth-color-danger-text: var(--plinth-ref-color-red-800);
263
+ --plinth-color-accent-hover: var(--plinth-ref-color-brand-700);
264
+ --plinth-color-accent-active: var(--plinth-ref-color-brand-800);
265
+ --plinth-color-danger-solid: var(--plinth-ref-color-red-600);
266
+ --plinth-color-danger-solid-hover: var(--plinth-ref-color-red-700);
267
+ --plinth-color-danger-solid-active: var(--plinth-ref-color-red-800);
268
+ --plinth-color-on-danger: var(--plinth-ref-color-neutral-0);
269
+ --plinth-color-accent-subtle: var(--plinth-ref-color-brand-50);
270
+ --plinth-color-accent-subtle-hover: var(--plinth-ref-color-brand-100);
271
+ --plinth-color-accent-subtle-active: var(--plinth-ref-color-brand-200);
272
+ --plinth-elevation-1: 0 1px 2px 0 #0000001a;
273
+ --plinth-elevation-2: 0 4px 12px 0 #00000026;
274
+ --plinth-elevation-3: 0 12px 32px 0 #00000026;
275
+ --plinth-callout-neutral-surface: var(--plinth-color-surface-sunken);
276
+ --plinth-callout-neutral-border: var(--plinth-color-border);
277
+ --plinth-callout-info-surface: color-mix(in srgb, var(--plinth-color-info) 6%, transparent);
278
+ --plinth-callout-info-border: color-mix(in srgb, var(--plinth-color-info) 24%, transparent);
279
+ --plinth-callout-success-surface: color-mix(in srgb, var(--plinth-color-success) 6%, transparent);
280
+ --plinth-callout-success-border: color-mix(in srgb, var(--plinth-color-success) 24%, transparent);
281
+ --plinth-callout-warning-surface: color-mix(in srgb, var(--plinth-color-warning) 6%, transparent);
282
+ --plinth-callout-warning-border: color-mix(in srgb, var(--plinth-color-warning) 24%, transparent);
283
+ --plinth-callout-danger-surface: color-mix(in srgb, var(--plinth-color-danger) 6%, transparent);
284
+ --plinth-callout-danger-border: color-mix(in srgb, var(--plinth-color-danger) 24%, transparent);
285
+ }
286
+
287
+ [data-theme="dark"],
288
+ .dark {
289
+ color-scheme: dark;
290
+ --plinth-color-surface: var(--plinth-ref-color-neutral-950);
291
+ --plinth-color-surface-raised: var(--plinth-ref-color-neutral-900);
292
+ --plinth-color-surface-sunken: var(--plinth-ref-color-neutral-1000);
293
+ --plinth-color-surface-hover: var(--plinth-ref-color-neutral-850);
294
+ --plinth-color-text: var(--plinth-ref-color-neutral-100);
295
+ --plinth-color-text-muted: var(--plinth-ref-color-neutral-300);
296
+ --plinth-color-text-subtle: var(--plinth-ref-color-neutral-400);
297
+ --plinth-color-border: var(--plinth-ref-color-chalk-alpha-10);
298
+ --plinth-color-border-strong: var(--plinth-ref-color-chalk-alpha-20);
299
+ --plinth-color-divider: var(--plinth-ref-color-chalk-alpha-6);
300
+ --plinth-color-primary: var(--plinth-ref-color-neutral-100);
301
+ --plinth-color-primary-hover: var(--plinth-ref-color-neutral-200);
302
+ --plinth-color-primary-active: var(--plinth-ref-color-neutral-300);
303
+ --plinth-color-on-primary: var(--plinth-ref-color-neutral-950);
304
+ --plinth-color-primary-edge: var(--plinth-ref-color-black-alpha-10);
305
+ --plinth-color-accent: var(--plinth-ref-color-brand-600);
306
+ --plinth-color-accent-text: var(--plinth-ref-color-brand-300);
307
+ --plinth-color-on-accent: var(--plinth-ref-color-neutral-0);
308
+ --plinth-color-focus-ring: var(--plinth-ref-color-brand-300);
309
+ --plinth-color-overlay: var(--plinth-ref-color-black-alpha-70);
310
+ --plinth-color-info: var(--plinth-ref-color-brand-400);
311
+ --plinth-color-info-surface: var(--plinth-ref-color-brand-950);
312
+ --plinth-color-info-text: var(--plinth-ref-color-brand-200);
313
+ --plinth-color-success: var(--plinth-ref-color-green-400);
314
+ --plinth-color-success-surface: var(--plinth-ref-color-green-950);
315
+ --plinth-color-success-text: var(--plinth-ref-color-green-200);
316
+ --plinth-color-warning: var(--plinth-ref-color-amber-400);
317
+ --plinth-color-warning-surface: var(--plinth-ref-color-amber-950);
318
+ --plinth-color-warning-text: var(--plinth-ref-color-amber-200);
319
+ --plinth-color-danger: var(--plinth-ref-color-red-400);
320
+ --plinth-color-danger-surface: var(--plinth-ref-color-red-950);
321
+ --plinth-color-danger-text: var(--plinth-ref-color-red-200);
322
+ --plinth-color-accent-hover: var(--plinth-ref-color-brand-700);
323
+ --plinth-color-accent-active: var(--plinth-ref-color-brand-800);
324
+ --plinth-color-danger-solid: var(--plinth-ref-color-red-600);
325
+ --plinth-color-danger-solid-hover: var(--plinth-ref-color-red-700);
326
+ --plinth-color-danger-solid-active: var(--plinth-ref-color-red-800);
327
+ --plinth-color-on-danger: var(--plinth-ref-color-neutral-0);
328
+ --plinth-color-accent-subtle: var(--plinth-ref-color-brand-950);
329
+ --plinth-color-accent-subtle-hover: var(--plinth-ref-color-brand-900);
330
+ --plinth-color-accent-subtle-active: var(--plinth-ref-color-brand-800);
331
+ --plinth-elevation-1: 0 1px 2px 0 #0000004d;
332
+ --plinth-elevation-2: 0 4px 12px 0 #00000080;
333
+ --plinth-elevation-3: 0 12px 32px 0 #00000099;
334
+ --plinth-callout-neutral-surface: var(--plinth-color-surface-sunken);
335
+ --plinth-callout-neutral-border: var(--plinth-color-border);
336
+ --plinth-callout-info-surface: color-mix(in srgb, var(--plinth-color-info) 8%, transparent);
337
+ --plinth-callout-info-border: color-mix(in srgb, var(--plinth-color-info) 24%, transparent);
338
+ --plinth-callout-success-surface: color-mix(in srgb, var(--plinth-color-success) 8%, transparent);
339
+ --plinth-callout-success-border: color-mix(in srgb, var(--plinth-color-success) 24%, transparent);
340
+ --plinth-callout-warning-surface: color-mix(in srgb, var(--plinth-color-warning) 8%, transparent);
341
+ --plinth-callout-warning-border: color-mix(in srgb, var(--plinth-color-warning) 24%, transparent);
342
+ --plinth-callout-danger-surface: color-mix(in srgb, var(--plinth-color-danger) 8%, transparent);
343
+ --plinth-callout-danger-border: color-mix(in srgb, var(--plinth-color-danger) 24%, transparent);
344
+ }
345
+
346
+ @media (prefers-color-scheme: dark) {
347
+ :root:not([data-theme="light"], .light) {
348
+ color-scheme: dark;
349
+ --plinth-color-surface: var(--plinth-ref-color-neutral-950);
350
+ --plinth-color-surface-raised: var(--plinth-ref-color-neutral-900);
351
+ --plinth-color-surface-sunken: var(--plinth-ref-color-neutral-1000);
352
+ --plinth-color-surface-hover: var(--plinth-ref-color-neutral-850);
353
+ --plinth-color-text: var(--plinth-ref-color-neutral-100);
354
+ --plinth-color-text-muted: var(--plinth-ref-color-neutral-300);
355
+ --plinth-color-text-subtle: var(--plinth-ref-color-neutral-400);
356
+ --plinth-color-border: var(--plinth-ref-color-chalk-alpha-10);
357
+ --plinth-color-border-strong: var(--plinth-ref-color-chalk-alpha-20);
358
+ --plinth-color-divider: var(--plinth-ref-color-chalk-alpha-6);
359
+ --plinth-color-primary: var(--plinth-ref-color-neutral-100);
360
+ --plinth-color-primary-hover: var(--plinth-ref-color-neutral-200);
361
+ --plinth-color-primary-active: var(--plinth-ref-color-neutral-300);
362
+ --plinth-color-on-primary: var(--plinth-ref-color-neutral-950);
363
+ --plinth-color-primary-edge: var(--plinth-ref-color-black-alpha-10);
364
+ --plinth-color-accent: var(--plinth-ref-color-brand-600);
365
+ --plinth-color-accent-text: var(--plinth-ref-color-brand-300);
366
+ --plinth-color-on-accent: var(--plinth-ref-color-neutral-0);
367
+ --plinth-color-focus-ring: var(--plinth-ref-color-brand-300);
368
+ --plinth-color-overlay: var(--plinth-ref-color-black-alpha-70);
369
+ --plinth-color-info: var(--plinth-ref-color-brand-400);
370
+ --plinth-color-info-surface: var(--plinth-ref-color-brand-950);
371
+ --plinth-color-info-text: var(--plinth-ref-color-brand-200);
372
+ --plinth-color-success: var(--plinth-ref-color-green-400);
373
+ --plinth-color-success-surface: var(--plinth-ref-color-green-950);
374
+ --plinth-color-success-text: var(--plinth-ref-color-green-200);
375
+ --plinth-color-warning: var(--plinth-ref-color-amber-400);
376
+ --plinth-color-warning-surface: var(--plinth-ref-color-amber-950);
377
+ --plinth-color-warning-text: var(--plinth-ref-color-amber-200);
378
+ --plinth-color-danger: var(--plinth-ref-color-red-400);
379
+ --plinth-color-danger-surface: var(--plinth-ref-color-red-950);
380
+ --plinth-color-danger-text: var(--plinth-ref-color-red-200);
381
+ --plinth-color-accent-hover: var(--plinth-ref-color-brand-700);
382
+ --plinth-color-accent-active: var(--plinth-ref-color-brand-800);
383
+ --plinth-color-danger-solid: var(--plinth-ref-color-red-600);
384
+ --plinth-color-danger-solid-hover: var(--plinth-ref-color-red-700);
385
+ --plinth-color-danger-solid-active: var(--plinth-ref-color-red-800);
386
+ --plinth-color-on-danger: var(--plinth-ref-color-neutral-0);
387
+ --plinth-color-accent-subtle: var(--plinth-ref-color-brand-950);
388
+ --plinth-color-accent-subtle-hover: var(--plinth-ref-color-brand-900);
389
+ --plinth-color-accent-subtle-active: var(--plinth-ref-color-brand-800);
390
+ --plinth-elevation-1: 0 1px 2px 0 #0000004d;
391
+ --plinth-elevation-2: 0 4px 12px 0 #00000080;
392
+ --plinth-elevation-3: 0 12px 32px 0 #00000099;
393
+ --plinth-callout-neutral-surface: var(--plinth-color-surface-sunken);
394
+ --plinth-callout-neutral-border: var(--plinth-color-border);
395
+ --plinth-callout-info-surface: color-mix(in srgb, var(--plinth-color-info) 8%, transparent);
396
+ --plinth-callout-info-border: color-mix(in srgb, var(--plinth-color-info) 24%, transparent);
397
+ --plinth-callout-success-surface: color-mix(in srgb, var(--plinth-color-success) 8%, transparent);
398
+ --plinth-callout-success-border: color-mix(in srgb, var(--plinth-color-success) 24%, transparent);
399
+ --plinth-callout-warning-surface: color-mix(in srgb, var(--plinth-color-warning) 8%, transparent);
400
+ --plinth-callout-warning-border: color-mix(in srgb, var(--plinth-color-warning) 24%, transparent);
401
+ --plinth-callout-danger-surface: color-mix(in srgb, var(--plinth-color-danger) 8%, transparent);
402
+ --plinth-callout-danger-border: color-mix(in srgb, var(--plinth-color-danger) 24%, transparent);
403
+ }
404
+ }
405
+
406
+ @media (prefers-reduced-motion: reduce) {
407
+ :root {
408
+ --plinth-duration-fast: 0ms;
409
+ --plinth-duration-normal: 0ms;
410
+ --plinth-duration-slow: 0ms;
411
+ }
412
+ }
413
+ }
414
+
415
+ /* avatar.css */
416
+ /*
417
+ * Avatar: a person or an organisation, as an image, initials, or a person glyph.
418
+ *
419
+ * <span class="plinth-avatar" data-size="md" role="img" aria-label="Ana Lima, Online">
420
+ * <img class="plinth-avatar-image" src="…" alt="">
421
+ * or <span class="plinth-avatar-initials" aria-hidden="true">AL</span>
422
+ * or <svg class="plinth-avatar-glyph" aria-hidden="true">…</svg>
423
+ * <span class="plinth-avatar-status" data-tone="success" aria-hidden="true"></span>
424
+ * </span>
425
+ *
426
+ * The root carries the name (role="img", aria-label), with the status label after a comma. A
427
+ * decorative avatar, next to the name it shows, has aria-hidden="true" and no role or label.
428
+ * Image first, then initials (from the name, or given), then the glyph.
429
+ *
430
+ * Sizes (data-size): sm 24px, md 32px (default), lg 40px, xl 56px. Round by default
431
+ * (radius.pill), so a brand can square it. The status dot takes the badge's tones (data-tone:
432
+ * neutral, success, warning, danger) and a ring in the page colour; on another surface, set
433
+ * --plinth-avatar-ring on the container (a card: --plinth-color-surface-raised).
434
+ */
435
+
436
+ @layer plinth {
437
+ .plinth-avatar {
438
+ --plinth-avatar-size: var(--plinth-size-avatar-md);
439
+ --plinth-avatar-surface: var(--plinth-color-surface-sunken);
440
+ --plinth-avatar-status: var(--plinth-color-text-subtle);
441
+ --plinth-avatar-ring: var(--plinth-color-surface);
442
+
443
+ position: relative;
444
+ display: inline-flex;
445
+ flex: none;
446
+ align-items: center;
447
+ justify-content: center;
448
+ inline-size: var(--plinth-avatar-size);
449
+ block-size: var(--plinth-avatar-size);
450
+ font-family: var(--plinth-font-family-body);
451
+ font-size: var(--plinth-font-size-xs);
452
+ font-weight: var(--plinth-font-weight-medium);
453
+ line-height: 1;
454
+ vertical-align: middle;
455
+ color: var(--plinth-color-text-muted);
456
+ user-select: none;
457
+ background-color: var(--plinth-avatar-surface);
458
+ border-radius: var(--plinth-radius-pill);
459
+ }
460
+
461
+ /*
462
+ * The hairline ring: over an image (positioned boxes paint after in-flow ones) and under the
463
+ * status dot (which comes later). A border, so forced-colours mode keeps the circle.
464
+ */
465
+ .plinth-avatar::before {
466
+ position: absolute;
467
+ inset: 0;
468
+ box-sizing: border-box;
469
+ pointer-events: none;
470
+ content: '';
471
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
472
+ border-radius: inherit;
473
+ }
474
+
475
+ .plinth-avatar[data-size='sm'] {
476
+ --plinth-avatar-size: var(--plinth-size-avatar-sm);
477
+ }
478
+
479
+ .plinth-avatar[data-size='lg'] {
480
+ --plinth-avatar-size: var(--plinth-size-avatar-lg);
481
+
482
+ font-size: var(--plinth-font-size-md);
483
+ }
484
+
485
+ .plinth-avatar[data-size='xl'] {
486
+ --plinth-avatar-size: var(--plinth-size-avatar-xl);
487
+
488
+ font-size: var(--plinth-font-size-xl);
489
+ }
490
+
491
+ .plinth-avatar-image {
492
+ inline-size: 100%;
493
+ block-size: 100%;
494
+ object-fit: cover;
495
+ border-radius: inherit;
496
+ }
497
+
498
+ .plinth-avatar-initials {
499
+ letter-spacing: var(--plinth-font-tracking-snug);
500
+ }
501
+
502
+ .plinth-avatar-glyph {
503
+ inline-size: 60%;
504
+ block-size: 60%;
505
+ }
506
+
507
+ .plinth-avatar-status {
508
+ position: absolute;
509
+ inset-block-end: 0;
510
+ inset-inline-end: 0;
511
+ inline-size: calc(var(--plinth-avatar-size) * 0.3);
512
+ block-size: calc(var(--plinth-avatar-size) * 0.3);
513
+ background-color: var(--plinth-avatar-status);
514
+
515
+ /* Stays round when a brand squares the avatar. */
516
+ border-radius: 50%;
517
+ box-shadow: 0 0 0 var(--plinth-border-width-thick) var(--plinth-avatar-ring);
518
+ }
519
+
520
+ .plinth-avatar-status[data-tone='success'] {
521
+ --plinth-avatar-status: var(--plinth-color-success);
522
+ }
523
+
524
+ .plinth-avatar-status[data-tone='warning'] {
525
+ --plinth-avatar-status: var(--plinth-color-warning);
526
+ }
527
+
528
+ .plinth-avatar-status[data-tone='danger'] {
529
+ --plinth-avatar-status: var(--plinth-color-danger);
530
+ }
531
+
532
+ @media (forced-colors: active) {
533
+ .plinth-avatar-status {
534
+ background-color: CanvasText;
535
+ }
536
+ }
537
+ }
538
+
539
+ /* badge.css */
540
+ /*
541
+ * Badge: a short label with an optional dot and a tone.
542
+ *
543
+ * <span class="plinth-badge" data-tone="success">
544
+ * <span class="plinth-badge-dot" aria-hidden="true"></span>
545
+ * <span class="plinth-badge-label">Paid</span>
546
+ * </span>
547
+ *
548
+ * Tones: neutral (default, also when data-tone is missing), success, warning, danger.
549
+ * The label carries the meaning; the tone and the dot only reinforce it.
550
+ */
551
+
552
+ @layer plinth {
553
+ .plinth-badge {
554
+ --plinth-badge-surface: var(--plinth-color-surface-sunken);
555
+ --plinth-badge-text: var(--plinth-color-text-muted);
556
+ --plinth-badge-accent: var(--plinth-color-text-muted);
557
+
558
+ display: inline-flex;
559
+ gap: var(--plinth-space-1);
560
+ align-items: center;
561
+ min-inline-size: 0;
562
+ max-inline-size: 100%;
563
+ padding-block: var(--plinth-space-0-5);
564
+ padding-inline: var(--plinth-space-2);
565
+ font-family: var(--plinth-font-family-body);
566
+ font-size: var(--plinth-font-size-xs);
567
+ font-weight: var(--plinth-font-weight-medium);
568
+ line-height: var(--plinth-font-leading-normal);
569
+ vertical-align: middle;
570
+ color: var(--plinth-badge-text);
571
+ white-space: nowrap;
572
+ background-color: var(--plinth-badge-surface);
573
+
574
+ /* Invisible normally; drawn by the system in forced-colours mode so the badge keeps its edge. */
575
+ border: 1px solid transparent;
576
+ border-radius: var(--plinth-radius-pill);
577
+ }
578
+
579
+ .plinth-badge[data-tone='success'] {
580
+ --plinth-badge-surface: var(--plinth-color-success-surface);
581
+ --plinth-badge-text: var(--plinth-color-success-text);
582
+ --plinth-badge-accent: var(--plinth-color-success);
583
+ }
584
+
585
+ .plinth-badge[data-tone='warning'] {
586
+ --plinth-badge-surface: var(--plinth-color-warning-surface);
587
+ --plinth-badge-text: var(--plinth-color-warning-text);
588
+ --plinth-badge-accent: var(--plinth-color-warning);
589
+ }
590
+
591
+ .plinth-badge[data-tone='danger'] {
592
+ --plinth-badge-surface: var(--plinth-color-danger-surface);
593
+ --plinth-badge-text: var(--plinth-color-danger-text);
594
+ --plinth-badge-accent: var(--plinth-color-danger);
595
+ }
596
+
597
+ .plinth-badge-dot {
598
+ flex: none;
599
+ inline-size: var(--plinth-space-2);
600
+ block-size: var(--plinth-space-2);
601
+ background-color: var(--plinth-badge-accent);
602
+ border-radius: var(--plinth-radius-pill);
603
+ }
604
+
605
+ .plinth-badge-label {
606
+ overflow: hidden;
607
+ text-overflow: ellipsis;
608
+ }
609
+
610
+ @media (forced-colors: active) {
611
+ .plinth-badge-dot {
612
+ background-color: currentcolor;
613
+ }
614
+ }
615
+ }
616
+
617
+ /* button.css */
618
+ /*
619
+ * Button: the main action control.
620
+ *
621
+ * <button type="button" class="plinth-button" data-variant="primary" data-size="md">
622
+ * <span class="plinth-icon" data-size="md" aria-hidden="true"><svg>…</svg></span>
623
+ * <span class="plinth-button-label">Save</span>
624
+ * <span class="plinth-icon" data-size="md" aria-hidden="true"><svg>…</svg></span>
625
+ * </button>
626
+ *
627
+ * With an href it is <a class="plinth-button" href="…"> instead; the icons are optional.
628
+ *
629
+ * data-mirror on an icon flips it in right-to-left text, for arrows (mirror-icon and
630
+ * mirror-trailing in Blade, Vue and React; mirror on the icon button).
631
+ *
632
+ * Variants (data-variant): primary (default, also when missing; inverted neutral unless a brand
633
+ * colours it), accent (the brand's colour, for marketing and brand moments), secondary, ghost, danger, and
634
+ * danger-ghost for a low-emphasis destructive action (Remove next to an item).
635
+ * Sizes (data-size): sm 28px, md 32px (default), lg 40px. On a touch screen every size keeps a
636
+ * 40px touch target through an invisible hit area. Icons inside are 16px on sm and 20px otherwise.
637
+ *
638
+ * States:
639
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
640
+ * - busy: aria-busy="true" and aria-disabled="true", with
641
+ * <span class="plinth-button-spinner" aria-hidden="true"></span> in the leading icon's place.
642
+ * It keeps focus; the app ignores activation while busy.
643
+ * - hover only where the device can hover; the focus ring on :focus-visible.
644
+ *
645
+ * Icon button: a button with only an icon, square at the button's height.
646
+ *
647
+ * <button type="button" class="plinth-button plinth-icon-button" data-variant="ghost"
648
+ * data-size="md" aria-label="More actions">
649
+ * <span class="plinth-icon" data-size="md" aria-hidden="true"><svg>…</svg></span>
650
+ * </button>
651
+ *
652
+ * With an href it is a link instead, with the same disabled state as the button's link:
653
+ *
654
+ * <a class="plinth-button plinth-icon-button" href="…" data-variant="secondary"
655
+ * data-size="md" aria-label="Next page">
656
+ * <span class="plinth-icon" data-size="md" data-mirror aria-hidden="true"><svg>…</svg></span>
657
+ * </a>
658
+ *
659
+ * aria-label is required: it is the only name the button has. A toggle has aria-pressed
660
+ * ("true" or "false"), and pressed shows the selection tint (accent-subtle, accent-text) inside
661
+ * a 2px accent edge (border-width.thick), so colour is not its only cue; it still answers hover
662
+ * and press with a step on the tint. In forced colours it takes the Highlight system colour. A
663
+ * link is never a toggle: aria-pressed is never printed on one.
664
+ */
665
+
666
+ @layer plinth {
667
+ .plinth-button {
668
+ --plinth-button-surface: var(--plinth-color-primary);
669
+ --plinth-button-surface-hover: var(--plinth-color-primary-hover);
670
+ --plinth-button-surface-active: var(--plinth-color-primary-active);
671
+ --plinth-button-text: var(--plinth-color-on-primary);
672
+ --plinth-button-edge: transparent;
673
+ --plinth-button-inner-edge: inset 0 0 0 1px var(--plinth-color-primary-edge);
674
+ --plinth-button-height: var(--plinth-control-md);
675
+ --plinth-button-padding: var(--plinth-space-4);
676
+ --plinth-button-icon-size: var(--plinth-size-icon-md);
677
+
678
+ position: relative;
679
+ display: inline-flex;
680
+ gap: var(--plinth-space-2);
681
+ align-items: center;
682
+ justify-content: center;
683
+ max-inline-size: 100%;
684
+ min-block-size: var(--plinth-button-height);
685
+ padding-block: 0;
686
+ padding-inline: var(--plinth-button-padding);
687
+ font-family: var(--plinth-font-family-body);
688
+ font-size: var(--plinth-font-size-md);
689
+ font-weight: var(--plinth-font-weight-medium);
690
+ line-height: var(--plinth-font-leading-tight);
691
+ vertical-align: middle;
692
+ color: var(--plinth-button-text);
693
+ white-space: nowrap;
694
+ text-decoration: none;
695
+ cursor: pointer;
696
+ user-select: none;
697
+ background-color: var(--plinth-button-surface);
698
+ border: var(--plinth-border-width-thin) solid var(--plinth-button-edge);
699
+ border-radius: var(--plinth-radius-control);
700
+ box-shadow: var(--plinth-button-inner-edge);
701
+ transition:
702
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard),
703
+ border-color var(--plinth-duration-fast) var(--plinth-easing-standard),
704
+ box-shadow var(--plinth-duration-fast) var(--plinth-easing-standard);
705
+ }
706
+
707
+ /* The brand's colour, for marketing and brand moments (Upgrade plan): at most one per screen. */
708
+ .plinth-button[data-variant='accent'] {
709
+ --plinth-button-surface: var(--plinth-color-accent);
710
+ --plinth-button-surface-hover: var(--plinth-color-accent-hover);
711
+ --plinth-button-surface-active: var(--plinth-color-accent-active);
712
+ --plinth-button-text: var(--plinth-color-on-accent);
713
+ }
714
+
715
+ .plinth-button[data-variant='secondary'] {
716
+ --plinth-button-inner-edge: none;
717
+ --plinth-button-surface: var(--plinth-color-surface-raised);
718
+ --plinth-button-surface-hover: var(--plinth-color-surface-hover);
719
+ --plinth-button-surface-active: var(--plinth-color-surface-sunken);
720
+ --plinth-button-text: var(--plinth-color-text);
721
+ --plinth-button-edge: var(--plinth-color-border-strong);
722
+ }
723
+
724
+ .plinth-button[data-variant='ghost'] {
725
+ --plinth-button-inner-edge: none;
726
+ --plinth-button-surface: transparent;
727
+ --plinth-button-surface-hover: var(--plinth-color-surface-hover);
728
+ --plinth-button-surface-active: var(--plinth-color-surface-sunken);
729
+ --plinth-button-text: var(--plinth-color-text);
730
+ }
731
+
732
+ .plinth-button[data-variant='danger'] {
733
+ --plinth-button-inner-edge: none;
734
+ --plinth-button-surface: var(--plinth-color-danger-solid);
735
+ --plinth-button-surface-hover: var(--plinth-color-danger-solid-hover);
736
+ --plinth-button-surface-active: var(--plinth-color-danger-solid-active);
737
+ --plinth-button-text: var(--plinth-color-on-danger);
738
+ }
739
+
740
+ .plinth-button[data-variant='danger-ghost'] {
741
+ --plinth-button-inner-edge: none;
742
+ --plinth-button-surface: transparent;
743
+ --plinth-button-surface-hover: var(--plinth-color-danger-surface);
744
+ --plinth-button-surface-active: var(--plinth-color-danger-surface);
745
+ --plinth-button-text: var(--plinth-color-danger-text);
746
+ }
747
+
748
+ .plinth-button[data-size='sm'] {
749
+ --plinth-button-height: var(--plinth-control-sm);
750
+ --plinth-button-padding: var(--plinth-space-3);
751
+ --plinth-button-icon-size: var(--plinth-size-icon-sm);
752
+
753
+ font-size: var(--plinth-font-size-sm);
754
+ }
755
+
756
+ .plinth-button[data-size='lg'] {
757
+ --plinth-button-height: var(--plinth-control-lg);
758
+ --plinth-button-padding: var(--plinth-space-5);
759
+ }
760
+
761
+ /*
762
+ * On a touch screen, every button keeps a 40px touch target through an invisible hit area,
763
+ * centred on it, without changing its look (ADR 3: controls look small, touch stays at 40px).
764
+ */
765
+ @media (pointer: coarse) {
766
+ .plinth-button::before {
767
+ position: absolute;
768
+ inset-block-start: 50%;
769
+ inset-inline: 0;
770
+ block-size: max(100%, var(--plinth-touch-target));
771
+ content: '';
772
+ translate: 0 -50%;
773
+ }
774
+
775
+ /* An icon button is narrower than 40px, so its hit area grows sideways too. */
776
+ .plinth-icon-button::before {
777
+ inset-inline: min(0%, 50% - var(--plinth-touch-target) / 2);
778
+ }
779
+ }
780
+
781
+ @media (hover: hover) {
782
+ .plinth-button:hover {
783
+ background-color: var(--plinth-button-surface-hover);
784
+ }
785
+ }
786
+
787
+ .plinth-button:active {
788
+ background-color: var(--plinth-button-surface-active);
789
+ }
790
+
791
+ .plinth-button:disabled,
792
+ .plinth-button[aria-disabled='true'] {
793
+ cursor: not-allowed;
794
+ background-color: var(--plinth-button-surface);
795
+ }
796
+
797
+ .plinth-button:disabled,
798
+ .plinth-button[aria-disabled='true']:not([aria-busy='true']) {
799
+ opacity: var(--plinth-opacity-disabled);
800
+ }
801
+
802
+ .plinth-button[aria-busy='true'] {
803
+ cursor: progress;
804
+ }
805
+
806
+ .plinth-button:focus-visible {
807
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
808
+ outline-offset: var(--plinth-focus-ring-offset-filled);
809
+ }
810
+
811
+ .plinth-button:is(
812
+ [data-variant='secondary'],
813
+ [data-variant='ghost'],
814
+ [data-variant='danger-ghost']
815
+ ):focus-visible {
816
+ outline-offset: var(--plinth-focus-ring-offset);
817
+ }
818
+
819
+ .plinth-icon-button {
820
+ flex: none;
821
+ inline-size: var(--plinth-button-height);
822
+ padding-inline: 0;
823
+ }
824
+
825
+ /* The border and an inset ring make the 2px edge, so pressed doesn't move the icon. */
826
+ .plinth-icon-button[aria-pressed='true'] {
827
+ --plinth-button-surface: var(--plinth-color-accent-subtle);
828
+ --plinth-button-surface-hover: var(--plinth-color-accent-subtle-hover);
829
+ --plinth-button-surface-active: var(--plinth-color-accent-subtle-active);
830
+ --plinth-button-text: var(--plinth-color-accent-text);
831
+ --plinth-button-edge: var(--plinth-color-accent);
832
+ --plinth-button-inner-edge: inset 0 0 0
833
+ calc(var(--plinth-border-width-thick) - var(--plinth-border-width-thin))
834
+ var(--plinth-color-accent);
835
+ }
836
+
837
+ /* A gap between the ring and the pressed accent edge, so focus and pressed read apart. */
838
+ .plinth-icon-button[aria-pressed='true']:focus-visible {
839
+ outline-offset: var(--plinth-focus-ring-offset-filled);
840
+ }
841
+
842
+ .plinth-button-label {
843
+ overflow: hidden;
844
+ text-overflow: ellipsis;
845
+ }
846
+
847
+ /* The same size as the icon it replaces, so the button keeps its width when it goes busy. */
848
+ .plinth-button-spinner {
849
+ flex: none;
850
+ inline-size: var(--plinth-button-icon-size);
851
+ block-size: var(--plinth-button-icon-size);
852
+ border: var(--plinth-border-width-thick) solid currentcolor;
853
+ border-inline-end-color: transparent;
854
+ border-radius: 50%;
855
+ animation: plinth-button-spin calc(var(--plinth-duration-slow) * 3) linear infinite;
856
+ }
857
+
858
+ @keyframes plinth-button-spin {
859
+ to {
860
+ transform: rotate(1turn);
861
+ }
862
+ }
863
+
864
+ @media (forced-colors: active) {
865
+ .plinth-button {
866
+ border-color: ButtonText;
867
+ }
868
+
869
+ .plinth-button:disabled,
870
+ .plinth-button[aria-disabled='true']:not([aria-busy='true']) {
871
+ color: GrayText;
872
+ border-color: GrayText;
873
+ }
874
+
875
+ /* The pressed tint is dropped in forced colours, so pressed takes the selection colours,
876
+ unless it is disabled, which keeps GrayText. */
877
+ .plinth-icon-button[aria-pressed='true']:not(
878
+ :disabled,
879
+ [aria-disabled='true']:not([aria-busy='true'])
880
+ ) {
881
+ color: HighlightText;
882
+ background-color: Highlight;
883
+ border-color: Highlight;
884
+ }
885
+
886
+ .plinth-button-spinner {
887
+ forced-color-adjust: none;
888
+ }
889
+ }
890
+ }
891
+
892
+ /* callout.css */
893
+ /*
894
+ * Callout: a note inside a page, with a tone, an icon, an optional title and actions.
895
+ *
896
+ * <div class="plinth-callout" data-tone="warning" role="note">
897
+ * <span class="plinth-icon plinth-callout-icon" data-size="md" aria-hidden="true">
898
+ * <svg viewBox="0 0 24 24">…</svg>
899
+ * </span>
900
+ * <div class="plinth-callout-body">
901
+ * <p class="plinth-callout-title">Invoices are sent at 9:00</p>
902
+ * <div class="plinth-callout-text">Drafts saved after that go out the next morning.</div>
903
+ * <div class="plinth-callout-actions">…buttons or links…</div>
904
+ * </div>
905
+ * </div>
906
+ *
907
+ * Tones (data-tone): neutral (default, also when the attribute is missing), info, success,
908
+ * warning, danger. Each tints the surface, the hairline border and the icon, and has its own icon
909
+ * shape; the title or the text still says what kind of note it is, so the tone is never the only
910
+ * signal. The tints are the component tokens --plinth-callout-<tone>-surface and -border: faint
911
+ * and translucent, quieter than the status surfaces a badge uses.
912
+ *
913
+ * role: note (default) for a note that is part of the page; status for a message the app adds
914
+ * after something happens (announced politely); alert for an urgent one (announced at once). A
915
+ * live region inserted with its content is often not announced, so for a status the app keeps an
916
+ * empty container with role="status" in the page and puts the callout in it.
917
+ *
918
+ * The text may hold paragraphs, links (underlined, in the text's colour) and inline code.
919
+ *
920
+ * The icon is decorative (aria-hidden). The tone's icon comes with the component; a project puts
921
+ * its own SVG in its place. Title, text and actions are each printed only when they have content.
922
+ */
923
+
924
+ @layer plinth {
925
+ .plinth-callout {
926
+ --plinth-callout-surface: var(--plinth-callout-neutral-surface);
927
+ --plinth-callout-border: var(--plinth-callout-neutral-border);
928
+ --plinth-callout-icon-color: var(--plinth-color-text-muted);
929
+
930
+ display: flex;
931
+ gap: var(--plinth-space-3);
932
+ align-items: flex-start;
933
+ min-inline-size: 0;
934
+ padding-block: var(--plinth-space-3);
935
+ padding-inline: var(--plinth-space-4);
936
+ font-family: var(--plinth-font-family-body);
937
+ font-size: var(--plinth-font-size-md);
938
+ line-height: var(--plinth-font-leading-normal);
939
+ color: var(--plinth-color-text);
940
+ overflow-wrap: anywhere;
941
+ background-color: var(--plinth-callout-surface);
942
+ border: 1px solid var(--plinth-callout-border);
943
+ border-radius: var(--plinth-radius-container);
944
+ }
945
+
946
+ .plinth-callout[data-tone='info'] {
947
+ --plinth-callout-surface: var(--plinth-callout-info-surface);
948
+ --plinth-callout-border: var(--plinth-callout-info-border);
949
+ --plinth-callout-icon-color: var(--plinth-color-info-text);
950
+ }
951
+
952
+ .plinth-callout[data-tone='success'] {
953
+ --plinth-callout-surface: var(--plinth-callout-success-surface);
954
+ --plinth-callout-border: var(--plinth-callout-success-border);
955
+ --plinth-callout-icon-color: var(--plinth-color-success-text);
956
+ }
957
+
958
+ .plinth-callout[data-tone='warning'] {
959
+ --plinth-callout-surface: var(--plinth-callout-warning-surface);
960
+ --plinth-callout-border: var(--plinth-callout-warning-border);
961
+ --plinth-callout-icon-color: var(--plinth-color-warning-text);
962
+ }
963
+
964
+ .plinth-callout[data-tone='danger'] {
965
+ --plinth-callout-surface: var(--plinth-callout-danger-surface);
966
+ --plinth-callout-border: var(--plinth-callout-danger-border);
967
+ --plinth-callout-icon-color: var(--plinth-color-danger-text);
968
+ }
969
+
970
+ /* Centred on the first line of text, however tall the line is. */
971
+ .plinth-callout-icon {
972
+ margin-block: calc((1lh - var(--plinth-size-icon-md)) / 2);
973
+ color: var(--plinth-callout-icon-color);
974
+ }
975
+
976
+ .plinth-callout-body {
977
+ display: flex;
978
+ flex: 1;
979
+ flex-direction: column;
980
+ gap: var(--plinth-space-1);
981
+ min-inline-size: 0;
982
+ }
983
+
984
+ .plinth-callout-title {
985
+ margin: 0;
986
+ font-weight: var(--plinth-font-weight-semibold);
987
+ }
988
+
989
+ .plinth-callout-text > * {
990
+ margin-block: 0;
991
+ }
992
+
993
+ .plinth-callout-text > * + * {
994
+ margin-block-start: var(--plinth-space-2);
995
+ }
996
+
997
+ .plinth-callout-text a {
998
+ color: inherit;
999
+ text-decoration-line: underline;
1000
+ text-decoration-color: var(--plinth-color-text-muted);
1001
+ text-underline-offset: 0.2em;
1002
+ }
1003
+
1004
+ @media (hover: hover) {
1005
+ .plinth-callout-text a:hover {
1006
+ text-decoration-color: currentcolor;
1007
+ }
1008
+ }
1009
+
1010
+ .plinth-callout-text a:focus-visible {
1011
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
1012
+ outline-offset: var(--plinth-focus-ring-offset);
1013
+ border-radius: var(--plinth-radius-control);
1014
+ }
1015
+
1016
+ .plinth-callout-text code {
1017
+ font-family: var(--plinth-font-family-mono);
1018
+ font-size: var(--plinth-font-size-sm);
1019
+ }
1020
+
1021
+ .plinth-callout-actions {
1022
+ display: flex;
1023
+ flex-wrap: wrap;
1024
+ gap: var(--plinth-space-2);
1025
+ align-items: center;
1026
+ margin-block-start: var(--plinth-space-2);
1027
+ }
1028
+
1029
+ /* Small buttons grow a 40px hit area on a touch screen; wrapped rows keep theirs apart. */
1030
+ @media (pointer: coarse) {
1031
+ .plinth-callout-actions {
1032
+ row-gap: var(--plinth-space-3);
1033
+ }
1034
+ }
1035
+ }
1036
+
1037
+ /* code-block.css */
1038
+ /*
1039
+ * Code block: a block of code with an optional file name or label, a copy button and optional
1040
+ * language tabs.
1041
+ *
1042
+ * <div class="plinth-code-block">
1043
+ * <div class="plinth-code-block-header">
1044
+ * <span class="plinth-code-block-label">resources/css/brand.css</span>
1045
+ * <button type="button" class="plinth-button plinth-code-block-copy" data-variant="ghost"
1046
+ * data-size="sm" hidden>
1047
+ * <span class="plinth-button-label">Copy</span>
1048
+ * </button>
1049
+ * <span class="plinth-code-block-status" role="status"></span>
1050
+ * </div>
1051
+ * <pre class="plinth-code-block-code" dir="ltr" tabindex="0"><code>…</code></pre>
1052
+ * </div>
1053
+ *
1054
+ * With language tabs, the header holds a radio group after the label, and there is one <pre> per
1055
+ * tab, in the same order:
1056
+ *
1057
+ * <div class="plinth-code-block-tabs" role="radiogroup" aria-label="Language">
1058
+ * <label class="plinth-code-block-tab">
1059
+ * <input type="radio" class="plinth-code-block-tab-input" name="install-language" checked>
1060
+ * <span class="plinth-code-block-tab-label">Blade</span>
1061
+ * </label>
1062
+ * <label class="plinth-code-block-tab">…Vue…</label>
1063
+ * </div>
1064
+ * …
1065
+ * <pre class="plinth-code-block-code" dir="ltr" tabindex="0"><code>…Blade…</code></pre>
1066
+ * <pre class="plinth-code-block-code" dir="ltr" tabindex="0"><code>…Vue…</code></pre>
1067
+ *
1068
+ * The root is the <div>, and takes the app's id. The label is optional, and is text or a little
1069
+ * markup (a badge). The tabs are native radio buttons (ADR 2): the browser moves between them with
1070
+ * the arrow keys and remembers the checked one, and CSS shows the <pre> in the checked tab's
1071
+ * place, with no script. Their name is the root's id with -language (one is generated when the
1072
+ * root has no id), and the group's name is tabs-label in Blade, Vue and React ("Language" by
1073
+ * default). In plain HTML, give each block on a page its own name, or their tabs become one group.
1074
+ * Up to six tabs (the layers drop the rest); the first is checked unless the app picks another
1075
+ * (selected), and after that the browser keeps the reader's choice. Inside a <form> the radios
1076
+ * are part of it: they are sent with it (name=on), and a reset checks the starting tab again.
1077
+ *
1078
+ * The code is text, or the markup a highlighter prints (Phiki, Shiki: the spans inside its
1079
+ * <code>). Plinth styles the block, not the tokens: their colours are the highlighter's. The
1080
+ * <pre> keeps the code's lines and scrolls sideways inside its own box, so it takes tabindex="0"
1081
+ * (a scrolling region must be reachable by keyboard), and dir="ltr", as code reads left to right
1082
+ * on any page.
1083
+ *
1084
+ * Copy: the button (button.css) is printed hidden, and the Blade (Alpine), Vue and React layers
1085
+ * show it when the clipboard is there. It copies the text of the <pre> that shows, says Copied
1086
+ * (copied-label) for two seconds and puts the same word in the status, a polite live region,
1087
+ * hidden from sight. The plain HTML works without it, and the header keeps its place for the
1088
+ * button either way. copy: false in Blade, Vue and React leaves
1089
+ * the button and the status out.
1090
+ *
1091
+ * States: the checked tab (input:checked) is a raised surface with a strong edge; hover only
1092
+ * where the device can hover; the focus ring on :focus-visible, on the tab's input (it covers the
1093
+ * tab) and inset on the <pre>. On a touch screen each tab keeps a 40px touch target, as the
1094
+ * button does. The header hides itself when it has nothing to show.
1095
+ */
1096
+
1097
+ @layer plinth {
1098
+ .plinth-code-block {
1099
+ box-sizing: border-box;
1100
+ display: flex;
1101
+ flex-direction: column;
1102
+ min-inline-size: 0;
1103
+ max-inline-size: 100%;
1104
+ color: var(--plinth-color-text);
1105
+ background-color: var(--plinth-color-surface-sunken);
1106
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
1107
+ border-radius: var(--plinth-radius-container);
1108
+ }
1109
+
1110
+ .plinth-code-block-header {
1111
+ box-sizing: border-box;
1112
+ display: flex;
1113
+ flex-wrap: wrap;
1114
+ gap: var(--plinth-space-2);
1115
+ align-items: center;
1116
+ min-block-size: var(--plinth-touch-target);
1117
+ padding-block: calc((var(--plinth-touch-target) - var(--plinth-control-sm)) / 2);
1118
+ padding-inline: var(--plinth-space-4)
1119
+ calc((var(--plinth-touch-target) - var(--plinth-control-sm)) / 2);
1120
+ border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
1121
+ }
1122
+
1123
+ /* Tabs first, with no label: the header's start matches its end. */
1124
+ .plinth-code-block-header:not(:has(.plinth-code-block-label)) {
1125
+ padding-inline-start: calc((var(--plinth-touch-target) - var(--plinth-control-sm)) / 2);
1126
+ }
1127
+
1128
+ /*
1129
+ * Nothing to show: no label, no tabs and no copy button (copy off). A hidden copy button keeps
1130
+ * the header, so the code doesn't move down when the layer shows it.
1131
+ */
1132
+ .plinth-code-block-header:not(
1133
+ :has(.plinth-code-block-label, .plinth-code-block-tabs, .plinth-code-block-copy)
1134
+ ) {
1135
+ display: none;
1136
+ }
1137
+
1138
+ .plinth-code-block-label {
1139
+ display: inline-flex;
1140
+ flex: 0 1 auto;
1141
+ gap: var(--plinth-space-2);
1142
+ align-items: center;
1143
+ min-inline-size: 0;
1144
+ overflow: hidden;
1145
+ text-overflow: ellipsis;
1146
+ font-family: var(--plinth-font-family-mono);
1147
+ font-size: var(--plinth-font-size-xs);
1148
+ line-height: var(--plinth-font-leading-tight);
1149
+ color: var(--plinth-color-text-muted);
1150
+ white-space: nowrap;
1151
+ }
1152
+
1153
+ .plinth-code-block-label:has(+ .plinth-code-block-tabs) {
1154
+ margin-inline-end: var(--plinth-space-2);
1155
+ }
1156
+
1157
+ /* The tabs follow the label; the copy button sits at the end. */
1158
+ .plinth-code-block-tabs {
1159
+ display: flex;
1160
+ flex: 0 1 auto;
1161
+ flex-wrap: wrap;
1162
+ gap: var(--plinth-space-1);
1163
+ align-items: center;
1164
+ min-inline-size: 0;
1165
+ }
1166
+
1167
+ .plinth-code-block-tab {
1168
+ position: relative;
1169
+ box-sizing: border-box;
1170
+ display: inline-flex;
1171
+ align-items: center;
1172
+ justify-content: center;
1173
+ min-block-size: var(--plinth-control-sm);
1174
+ padding-block: 0;
1175
+ padding-inline: var(--plinth-space-3);
1176
+ font-family: var(--plinth-font-family-body);
1177
+ font-size: var(--plinth-font-size-sm);
1178
+ font-weight: var(--plinth-font-weight-medium);
1179
+ line-height: var(--plinth-font-leading-tight);
1180
+ color: var(--plinth-color-text-muted);
1181
+ white-space: nowrap;
1182
+ cursor: pointer;
1183
+ user-select: none;
1184
+ background-color: transparent;
1185
+ border: var(--plinth-border-width-thin) solid transparent;
1186
+ border-radius: var(--plinth-radius-control);
1187
+ transition:
1188
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard),
1189
+ border-color var(--plinth-duration-fast) var(--plinth-easing-standard),
1190
+ color var(--plinth-duration-fast) var(--plinth-easing-standard);
1191
+ }
1192
+
1193
+ /* The radio covers its tab, so the tab is what is focused, clicked and ringed. */
1194
+ .plinth-code-block-tab-input {
1195
+ position: absolute;
1196
+ inset: calc(-1 * var(--plinth-border-width-thin));
1197
+ margin: 0;
1198
+ appearance: none;
1199
+ cursor: pointer;
1200
+ background: none;
1201
+ border: 0;
1202
+ border-radius: var(--plinth-radius-control);
1203
+ }
1204
+
1205
+ .plinth-code-block-tab-input:focus-visible {
1206
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
1207
+ outline-offset: var(--plinth-focus-ring-offset);
1208
+ }
1209
+
1210
+ .plinth-code-block-tab:has(> .plinth-code-block-tab-input:checked) {
1211
+ color: var(--plinth-color-text);
1212
+ background-color: var(--plinth-color-surface-raised);
1213
+ border-color: var(--plinth-color-border-strong);
1214
+ }
1215
+
1216
+ @media (hover: hover) {
1217
+ .plinth-code-block-tab:not(:has(> .plinth-code-block-tab-input:checked)):hover {
1218
+ color: var(--plinth-color-text);
1219
+ background-color: var(--plinth-color-surface-hover);
1220
+ }
1221
+ }
1222
+
1223
+ .plinth-code-block-tab:not(:has(> .plinth-code-block-tab-input:checked)):active {
1224
+ background-color: var(--plinth-color-surface-sunken);
1225
+ }
1226
+
1227
+ /* A 40px touch target through an invisible hit area, as on the button (ADR 3). */
1228
+ @media (pointer: coarse) {
1229
+ .plinth-code-block-tab::before {
1230
+ position: absolute;
1231
+ inset-block-start: 50%;
1232
+ inset-inline: min(0%, 50% - var(--plinth-touch-target) / 2);
1233
+ block-size: max(100%, var(--plinth-touch-target));
1234
+ content: '';
1235
+ translate: 0 -50%;
1236
+ }
1237
+ }
1238
+
1239
+ .plinth-code-block-copy {
1240
+ margin-inline-start: auto;
1241
+ }
1242
+
1243
+ /* The button's own display would beat the hidden attribute. */
1244
+ .plinth-code-block-copy[hidden] {
1245
+ display: none;
1246
+ }
1247
+
1248
+ /* Read out, not shown. */
1249
+ .plinth-code-block-status {
1250
+ position: absolute;
1251
+ inline-size: 1px;
1252
+ block-size: 1px;
1253
+ padding: 0;
1254
+ margin: calc(-1 * var(--plinth-border-width-thin));
1255
+ overflow: hidden;
1256
+ white-space: nowrap;
1257
+ border: 0;
1258
+ clip-path: inset(50%);
1259
+ }
1260
+
1261
+ .plinth-code-block-code {
1262
+ box-sizing: border-box;
1263
+ max-inline-size: 100%;
1264
+ padding: var(--plinth-space-4);
1265
+ margin: 0;
1266
+ overflow-x: auto;
1267
+ overscroll-behavior-inline: contain;
1268
+ font-family: var(--plinth-font-family-mono);
1269
+ font-size: var(--plinth-font-size-sm);
1270
+ line-height: var(--plinth-font-leading-normal);
1271
+ color: var(--plinth-color-text);
1272
+ tab-size: 2;
1273
+ white-space: pre;
1274
+ border-radius: var(--plinth-radius-container);
1275
+ }
1276
+
1277
+ .plinth-code-block-code:focus-visible {
1278
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
1279
+ outline-offset: var(--plinth-focus-ring-offset-inset);
1280
+ }
1281
+
1282
+ .plinth-code-block-code > code {
1283
+ font: inherit;
1284
+ color: inherit;
1285
+ background: none;
1286
+ }
1287
+
1288
+ /*
1289
+ * With tabs, only the checked tab's <pre> shows: the nth tab shows the nth <pre>. Nothing
1290
+ * checked (a reset form) shows the first.
1291
+ */
1292
+ .plinth-code-block:has(.plinth-code-block-tabs) > .plinth-code-block-code {
1293
+ display: none;
1294
+ }
1295
+
1296
+ .plinth-code-block:not(:has(.plinth-code-block-tab-input:checked))
1297
+ > .plinth-code-block-code:first-of-type,
1298
+ .plinth-code-block:has(.plinth-code-block-tab:nth-child(1) > :checked)
1299
+ > .plinth-code-block-code:nth-of-type(1),
1300
+ .plinth-code-block:has(.plinth-code-block-tab:nth-child(2) > :checked)
1301
+ > .plinth-code-block-code:nth-of-type(2),
1302
+ .plinth-code-block:has(.plinth-code-block-tab:nth-child(3) > :checked)
1303
+ > .plinth-code-block-code:nth-of-type(3),
1304
+ .plinth-code-block:has(.plinth-code-block-tab:nth-child(4) > :checked)
1305
+ > .plinth-code-block-code:nth-of-type(4),
1306
+ .plinth-code-block:has(.plinth-code-block-tab:nth-child(5) > :checked)
1307
+ > .plinth-code-block-code:nth-of-type(5),
1308
+ .plinth-code-block:has(.plinth-code-block-tab:nth-child(6) > :checked)
1309
+ > .plinth-code-block-code:nth-of-type(6) {
1310
+ display: block;
1311
+ }
1312
+
1313
+ @media (forced-colors: active) {
1314
+ .plinth-code-block-tab:has(> .plinth-code-block-tab-input:checked) {
1315
+ color: HighlightText;
1316
+ background-color: Highlight;
1317
+ border-color: Highlight;
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ /* confirm-dialog.css */
1323
+ /*
1324
+ * Confirmation dialog: a dialog that asks a person to confirm an action before it happens, in
1325
+ * three kinds: simple ("Archive 3 tickets?"), type to confirm ("Type harbour to delete this
1326
+ * project") and password ("Enter your password to change the billing email").
1327
+ *
1328
+ * <dialog id="delete" class="plinth-dialog plinth-confirm-dialog" role="alertdialog"
1329
+ * closedby="closerequest" aria-labelledby="delete-title" aria-describedby="delete-message">
1330
+ * <form method="dialog" class="plinth-confirm-dialog-form">
1331
+ * <div class="plinth-dialog-header">
1332
+ * <h2 id="delete-title" class="plinth-dialog-title">Delete this project?</h2>
1333
+ * </div>
1334
+ * <div class="plinth-dialog-body">
1335
+ * <p id="delete-message" class="plinth-confirm-dialog-message">Its tickets go too.</p>
1336
+ * <div class="plinth-confirm-dialog-field">
1337
+ * <label for="delete-field" class="plinth-confirm-dialog-label">
1338
+ * Type <strong>harbour</strong> to confirm</label>
1339
+ * <input id="delete-field" class="plinth-confirm-dialog-input" type="text"
1340
+ * name="confirmation" required pattern="harbour" autocomplete="off"
1341
+ * autocapitalize="none" spellcheck="false">
1342
+ * </div>
1343
+ * <p id="delete-error" class="plinth-confirm-dialog-error" role="alert">…</p>
1344
+ * </div>
1345
+ * <div class="plinth-dialog-footer">
1346
+ * <button type="button" class="plinth-button" data-variant="secondary" data-size="md"
1347
+ * commandfor="delete" command="close" value="cancel">
1348
+ * <span class="plinth-button-label">Cancel</span></button>
1349
+ * <button type="submit" class="plinth-button plinth-confirm-dialog-confirm"
1350
+ * data-variant="danger" data-size="md" value="confirm">
1351
+ * <span class="plinth-button-label">Delete</span></button>
1352
+ * </div>
1353
+ * </form>
1354
+ * </dialog>
1355
+ *
1356
+ * It is a dialog (dialog.css, on modal.css, with button.css for its actions) that is never
1357
+ * dismissible: role="alertdialog", closedby="closerequest" and no close button, so a tap on the
1358
+ * backdrop leaves it open, and it closes through its buttons, Escape acting as Cancel. The root
1359
+ * needs an id; the title's, message's, field's and error's ids are the root's id with -title,
1360
+ * -message, -field and -error. The title and the message are required: they are the alert
1361
+ * dialog's name and description. data-side-phone="bottom" makes it a bottom sheet below 768px, as
1362
+ * on the dialog. A project may set its own role and closedby.
1363
+ *
1364
+ * Its content is a <form method="dialog">. Cancel is a button with command="close" and
1365
+ * value="cancel", which closes the dialog with returnValue "cancel" and never submits, so it works
1366
+ * whatever is in the field; Escape closes it with an empty returnValue.
1367
+ * Confirm is the form's only submit button, so Enter in the field confirms too: once the browser
1368
+ * has checked the field, the form fires submit (it bubbles to the dialog, where the app listens)
1369
+ * and the dialog closes with returnValue "confirm". The browser focuses the first control when it
1370
+ * opens: the field, or Cancel when there is none. The browser doesn't empty the field when the
1371
+ * dialog closes, so the app calls reset() on the form on close: the phrase is typed afresh each
1372
+ * time, and a password doesn't stay in the page.
1373
+ *
1374
+ * Kinds (no attribute: the markup is what differs):
1375
+ * - simple: the message only.
1376
+ * - type to confirm: a text field with required and pattern, the phrase with its pattern
1377
+ * characters escaped. The browser blocks the submit until the text matches, and while the form
1378
+ * is :invalid the confirm button looks disabled, through CSS alone (it isn't disabled: a press
1379
+ * shows the browser's message and focuses the field). The label shows the phrase in <strong>;
1380
+ * a label of the app's own must keep it (:phrase), or nobody can tell what to type.
1381
+ * - password: a field with type="password", name="password", autocomplete="current-password" and
1382
+ * required, so the confirm button looks disabled until something is typed. Plinth never checks
1383
+ * a password: the app calls preventDefault() on the submit, sets
1384
+ * the confirm button busy while it checks, then closes the dialog (close("confirm")) or shows
1385
+ * the error.
1386
+ *
1387
+ * States:
1388
+ * - error: a message under the field, in danger-text with role="alert" so it is announced when it
1389
+ * appears; the field has aria-invalid="true", aria-describedby pointing at it, and a thick
1390
+ * danger edge (thick, so it isn't only a colour).
1391
+ * - busy: the confirm button's busy state (button.css: aria-busy, aria-disabled and a spinner).
1392
+ * The app ignores another submit while it is busy.
1393
+ * - tone: neutral (default) makes the confirm button primary; danger makes it the danger
1394
+ * variant, for an action that destroys something.
1395
+ *
1396
+ * Fields are 16px text on a phone, so iOS doesn't zoom the page when one takes focus, and md from
1397
+ * 768px up; they are control.md tall, and 40px on a touch screen.
1398
+ */
1399
+
1400
+ @layer plinth {
1401
+ /* The form takes the dialog's place as the column of parts, so the body still scrolls. */
1402
+ .plinth-confirm-dialog-form {
1403
+ display: flex;
1404
+ flex: 1 1 auto;
1405
+ flex-direction: column;
1406
+ min-block-size: 0;
1407
+ margin: 0;
1408
+ }
1409
+
1410
+ .plinth-confirm-dialog-message {
1411
+ margin: 0;
1412
+ overflow-wrap: anywhere;
1413
+ }
1414
+
1415
+ .plinth-confirm-dialog-field {
1416
+ display: grid;
1417
+ gap: var(--plinth-space-2);
1418
+ margin-block-start: var(--plinth-space-4);
1419
+ }
1420
+
1421
+ .plinth-confirm-dialog-label {
1422
+ font-size: var(--plinth-font-size-sm);
1423
+ font-weight: var(--plinth-font-weight-medium);
1424
+ line-height: var(--plinth-font-leading-normal);
1425
+ color: var(--plinth-color-text-muted);
1426
+ overflow-wrap: anywhere;
1427
+ }
1428
+
1429
+ .plinth-confirm-dialog-label strong {
1430
+ font-family: var(--plinth-font-family-mono);
1431
+ font-weight: var(--plinth-font-weight-semibold);
1432
+ color: var(--plinth-color-text);
1433
+ }
1434
+
1435
+ .plinth-confirm-dialog-input {
1436
+ box-sizing: border-box;
1437
+ inline-size: 100%;
1438
+ min-block-size: var(--plinth-control-md);
1439
+ padding-block: 0;
1440
+ padding-inline: var(--plinth-space-3);
1441
+ margin: 0;
1442
+ font-family: var(--plinth-font-family-body);
1443
+ font-size: var(--plinth-font-size-field);
1444
+ line-height: var(--plinth-font-leading-tight);
1445
+ color: var(--plinth-color-text);
1446
+ background-color: var(--plinth-color-surface-sunken);
1447
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
1448
+ border-radius: var(--plinth-radius-control);
1449
+ transition: border-color var(--plinth-duration-fast) var(--plinth-easing-standard);
1450
+ }
1451
+
1452
+ /* The phrase is typed as it shows in the label. */
1453
+ .plinth-confirm-dialog-input[type='text'] {
1454
+ font-family: var(--plinth-font-family-mono);
1455
+ }
1456
+
1457
+ .plinth-confirm-dialog-input:focus-visible {
1458
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
1459
+ outline-offset: var(--plinth-focus-ring-offset);
1460
+ }
1461
+
1462
+ .plinth-confirm-dialog-input[aria-invalid='true'] {
1463
+ border-color: var(--plinth-color-danger);
1464
+ border-width: var(--plinth-border-width-thick);
1465
+ }
1466
+
1467
+ .plinth-confirm-dialog-error {
1468
+ margin-block: var(--plinth-space-2) 0;
1469
+ font-size: var(--plinth-font-size-sm);
1470
+ line-height: var(--plinth-font-leading-normal);
1471
+ color: var(--plinth-color-danger-text);
1472
+ overflow-wrap: anywhere;
1473
+ }
1474
+
1475
+ /* Until the field matches, the confirm button looks disabled; it keeps its contrast. */
1476
+ .plinth-confirm-dialog-form:invalid .plinth-confirm-dialog-confirm:not([aria-busy='true']) {
1477
+ --plinth-button-surface: var(--plinth-color-surface-sunken);
1478
+ --plinth-button-surface-hover: var(--plinth-color-surface-sunken);
1479
+ --plinth-button-surface-active: var(--plinth-color-surface-sunken);
1480
+ --plinth-button-text: var(--plinth-color-text-muted);
1481
+ --plinth-button-edge: var(--plinth-color-border);
1482
+ --plinth-button-inner-edge: none;
1483
+
1484
+ cursor: not-allowed;
1485
+ }
1486
+
1487
+ @media (pointer: coarse) {
1488
+ .plinth-confirm-dialog-input {
1489
+ min-block-size: var(--plinth-touch-target);
1490
+ }
1491
+ }
1492
+
1493
+ @media (min-width: 768px) {
1494
+ .plinth-confirm-dialog-input {
1495
+ font-size: var(--plinth-font-size-md);
1496
+ }
1497
+ }
1498
+
1499
+ @media (forced-colors: active) {
1500
+ .plinth-confirm-dialog-form:invalid .plinth-confirm-dialog-confirm:not([aria-busy='true']) {
1501
+ color: GrayText;
1502
+ border-color: GrayText;
1503
+ }
1504
+ }
1505
+ }
1506
+
1507
+ /* data-table.css */
1508
+ /*
1509
+ * Data table: a collection of records (orders, users, invoices) from one markup, a stack of cards
1510
+ * on a phone and a table from 768px up. No script: the CSS alone changes the layout.
1511
+ *
1512
+ * <table class="plinth-data-table" role="table">
1513
+ * <caption class="plinth-data-table-caption">Orders</caption>
1514
+ * <thead class="plinth-data-table-head" role="rowgroup">
1515
+ * <tr class="plinth-data-table-head-row" role="row">
1516
+ * <th class="plinth-data-table-column" role="columnheader" scope="col">Order</th>
1517
+ * <th class="plinth-data-table-column" role="columnheader" scope="col" aria-sort="descending">
1518
+ * <button type="button" class="plinth-data-table-sort">
1519
+ * <span class="plinth-data-table-sort-label">Date</span>
1520
+ * <span class="plinth-icon plinth-data-table-sort-icon" data-size="sm" aria-hidden="true">
1521
+ * <svg>…<path class="plinth-data-table-sort-up"/><path class="plinth-data-table-sort-down"/></svg>
1522
+ * </span>
1523
+ * </button>
1524
+ * </th>
1525
+ * <th class="plinth-data-table-column" role="columnheader" scope="col" data-numeric>Total</th>
1526
+ * <th class="plinth-data-table-column" role="columnheader" scope="col" data-phone="hidden">…</th>
1527
+ * </tr>
1528
+ * </thead>
1529
+ * <tbody class="plinth-data-table-body" role="rowgroup">
1530
+ * <tr class="plinth-data-table-row" role="row">
1531
+ * <td class="plinth-data-table-cell" role="cell" data-kind="title">
1532
+ * <span class="plinth-data-table-label" aria-hidden="true">Order</span>
1533
+ * <span class="plinth-data-table-value">#1042</span>
1534
+ * </td>
1535
+ * <td class="plinth-data-table-cell" role="cell" data-kind="field">…</td>
1536
+ * <td class="plinth-data-table-cell" role="cell" data-kind="field" data-numeric>…</td>
1537
+ * <td class="plinth-data-table-cell" role="cell" data-kind="actions">
1538
+ * <span class="plinth-data-table-value">buttons</span>
1539
+ * </td>
1540
+ * </tr>
1541
+ * <tr class="plinth-data-table-row" role="row" aria-selected="true">…</tr>
1542
+ * </tbody>
1543
+ * </table>
1544
+ *
1545
+ * With no rows, the body holds the empty state:
1546
+ *
1547
+ * <tbody class="plinth-data-table-body" role="rowgroup">
1548
+ * <tr class="plinth-data-table-empty" role="row">
1549
+ * <td class="plinth-data-table-empty-cell" role="cell" colspan="4">No orders yet.</td>
1550
+ * </tr>
1551
+ * </tbody>
1552
+ *
1553
+ * The explicit roles (table, rowgroup, row, columnheader, cell) are part of the contract: some
1554
+ * browsers, Safari in particular, drop a table's semantics once its rows stop displaying as table
1555
+ * rows. The caption is optional; without it, name the table with aria-label or aria-labelledby.
1556
+ *
1557
+ * Each cell repeats its column's name in plinth-data-table-label, shown only on a phone's card
1558
+ * and aria-hidden, since the column header already names the cell. The label is optional (an
1559
+ * actions cell has none). The header row stays in the accessibility tree on a phone, visually
1560
+ * hidden, so every cell keeps its header.
1561
+ *
1562
+ * Choices, on the cells and the column headers:
1563
+ * - data-kind on a cell: field (the default; the label, then the value), title (the card's title,
1564
+ * first on the card, with no label) or actions (row actions, last on the card, with no label).
1565
+ * - data-numeric: numbers, aligned to the end, with figures of one width.
1566
+ * - data-phone="hidden": a less important column, left out of a phone's card and shown from 768px
1567
+ * up. Put it on the column header and on every cell of the column, so the two always match.
1568
+ * The title cell is the row's first cell and the actions cell its last: the card moves them to
1569
+ * its top and bottom with `order`, which must not change the reading and focus order.
1570
+ *
1571
+ * The app owns sorting, paging and selection (ADR 2); the table only shows them:
1572
+ * - aria-sort on a sortable column header: none, ascending or descending (one column at a time).
1573
+ * Its label is a plinth-data-table-sort button, which the app listens to; its icon shows both
1574
+ * arrows (none) or the one of the order. On a phone the sortable headers stay visible above the
1575
+ * cards as a row of sort buttons; the other headers are visually hidden.
1576
+ * - aria-selected on a row of a table whose rows can be selected: true for a selected row, false
1577
+ * for the others. A selected row takes the selection tint (accent-subtle) and an accent edge at
1578
+ * its start. aria-selected drives the styling only: screen readers don't announce it in a
1579
+ * table, so the control that selects a row (a checkbox, a pressed button) is the app's, and it
1580
+ * carries the state.
1581
+ * - the sort button's states: hover where the device can hover, pressed (:active) a surface step
1582
+ * down, disabled (while a sort loads, say) dimmed with opacity.disabled.
1583
+ * - the empty state: one plinth-data-table-empty row in the body, whose plinth-data-table-empty-cell
1584
+ * spans every column (colspan).
1585
+ * Paging controls sit outside the table.
1586
+ *
1587
+ * From 768px up the actions sit at the start of their cell, under their column header.
1588
+ *
1589
+ * Text wraps in its cell. A table wider than its container, from 768px up, belongs in a
1590
+ * scrolling container the app adds. --plinth-data-table-row-height is a body row's height (44px).
1591
+ */
1592
+
1593
+ @layer plinth {
1594
+ /* The phone: a stack of cards. */
1595
+ .plinth-data-table {
1596
+ position: relative;
1597
+ display: block;
1598
+ inline-size: 100%;
1599
+ font-family: var(--plinth-font-family-body);
1600
+ font-size: var(--plinth-font-size-md);
1601
+ line-height: var(--plinth-font-leading-tight);
1602
+ color: var(--plinth-color-text);
1603
+ border-spacing: 0;
1604
+ border-collapse: separate;
1605
+ }
1606
+
1607
+ .plinth-data-table-caption {
1608
+ display: block;
1609
+ padding-inline: var(--plinth-space-4);
1610
+ margin-block-end: var(--plinth-space-2);
1611
+ font-size: var(--plinth-font-size-sm);
1612
+ font-weight: var(--plinth-font-weight-medium);
1613
+ line-height: var(--plinth-font-leading-normal);
1614
+ color: var(--plinth-color-text-muted);
1615
+ text-align: start;
1616
+ overflow-wrap: anywhere;
1617
+ }
1618
+
1619
+ .plinth-data-table-head {
1620
+ display: block;
1621
+ }
1622
+
1623
+ .plinth-data-table-head-row {
1624
+ display: flex;
1625
+ flex-wrap: wrap;
1626
+ gap: var(--plinth-space-2);
1627
+ }
1628
+
1629
+ .plinth-data-table-head-row:has(> [aria-sort]:not([data-phone='hidden'])) {
1630
+ margin-block-end: var(--plinth-space-3);
1631
+ }
1632
+
1633
+ .plinth-data-table-column {
1634
+ display: block;
1635
+ padding: 0;
1636
+ font-size: var(--plinth-font-size-sm);
1637
+ font-weight: var(--plinth-font-weight-medium);
1638
+ color: var(--plinth-color-text-muted);
1639
+ text-align: start;
1640
+ }
1641
+
1642
+ /* Visually hidden, still a column header to assistive technology. */
1643
+ .plinth-data-table-column:not([aria-sort]) {
1644
+ position: absolute;
1645
+ inline-size: 1px;
1646
+ block-size: 1px;
1647
+ overflow: hidden;
1648
+ white-space: nowrap;
1649
+ clip-path: inset(50%);
1650
+ }
1651
+
1652
+ .plinth-data-table-sort {
1653
+ display: inline-flex;
1654
+ gap: var(--plinth-space-1);
1655
+ align-items: center;
1656
+ min-inline-size: var(--plinth-touch-target);
1657
+ min-block-size: var(--plinth-touch-target);
1658
+ padding-inline: var(--plinth-space-3);
1659
+ margin: 0;
1660
+ font: inherit;
1661
+ color: inherit;
1662
+ text-align: inherit;
1663
+ cursor: pointer;
1664
+ background-color: var(--plinth-color-surface-raised);
1665
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
1666
+ border-radius: var(--plinth-radius-pill);
1667
+ transition:
1668
+ color var(--plinth-duration-fast) var(--plinth-easing-standard),
1669
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard);
1670
+ }
1671
+
1672
+ .plinth-data-table-sort:focus-visible {
1673
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
1674
+ outline-offset: var(--plinth-focus-ring-offset);
1675
+ }
1676
+
1677
+ :is([aria-sort='ascending'], [aria-sort='descending']) > .plinth-data-table-sort {
1678
+ color: var(--plinth-color-text);
1679
+ border-color: var(--plinth-color-border-strong);
1680
+ }
1681
+
1682
+ @media (hover: hover) {
1683
+ .plinth-data-table-sort:hover {
1684
+ color: var(--plinth-color-text);
1685
+ background-color: var(--plinth-color-surface-hover);
1686
+ }
1687
+ }
1688
+
1689
+ .plinth-data-table-sort:disabled {
1690
+ cursor: not-allowed;
1691
+ opacity: var(--plinth-opacity-disabled);
1692
+ }
1693
+
1694
+ .plinth-data-table-sort:enabled:active {
1695
+ background-color: var(--plinth-color-surface-sunken);
1696
+ }
1697
+
1698
+ .plinth-data-table-sort-label {
1699
+ overflow-wrap: anywhere;
1700
+ }
1701
+
1702
+ [aria-sort='none'] .plinth-data-table-sort-icon {
1703
+ color: var(--plinth-color-text-subtle);
1704
+ }
1705
+
1706
+ [aria-sort='ascending'] .plinth-data-table-sort-down,
1707
+ [aria-sort='descending'] .plinth-data-table-sort-up {
1708
+ display: none;
1709
+ }
1710
+
1711
+ .plinth-data-table-body {
1712
+ display: flex;
1713
+ flex-direction: column;
1714
+ gap: var(--plinth-space-3);
1715
+ }
1716
+
1717
+ .plinth-data-table-row {
1718
+ position: relative;
1719
+ display: flex;
1720
+ flex-direction: column;
1721
+ padding-block: var(--plinth-space-1);
1722
+ background-color: var(--plinth-color-surface-raised);
1723
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
1724
+ border-radius: var(--plinth-radius-container);
1725
+ }
1726
+
1727
+ .plinth-data-table-cell {
1728
+ display: flex;
1729
+ gap: var(--plinth-space-3);
1730
+ align-items: baseline;
1731
+ justify-content: space-between;
1732
+ padding-block: var(--plinth-space-2);
1733
+ padding-inline: var(--plinth-space-4);
1734
+ overflow-wrap: anywhere;
1735
+ }
1736
+
1737
+ .plinth-data-table-label {
1738
+ flex: none;
1739
+ max-inline-size: 50%;
1740
+ font-size: var(--plinth-font-size-sm);
1741
+ color: var(--plinth-color-text-muted);
1742
+ }
1743
+
1744
+ .plinth-data-table-value {
1745
+ min-inline-size: 0;
1746
+ text-align: end;
1747
+ }
1748
+
1749
+ .plinth-data-table-cell[data-kind='title'] {
1750
+ order: -1;
1751
+ font-weight: var(--plinth-font-weight-medium);
1752
+ }
1753
+
1754
+ .plinth-data-table-cell:is([data-kind='title'], [data-kind='actions']) .plinth-data-table-label {
1755
+ display: none;
1756
+ }
1757
+
1758
+ .plinth-data-table-cell[data-kind='title'] .plinth-data-table-value {
1759
+ text-align: start;
1760
+ }
1761
+
1762
+ .plinth-data-table-cell[data-kind='actions'] {
1763
+ justify-content: flex-end;
1764
+ order: 1;
1765
+ padding-block-start: var(--plinth-space-3);
1766
+ margin-block-start: var(--plinth-space-1);
1767
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
1768
+ }
1769
+
1770
+ .plinth-data-table-cell[data-kind='actions'] .plinth-data-table-value {
1771
+ display: flex;
1772
+ flex-wrap: wrap;
1773
+ gap: var(--plinth-space-2);
1774
+ align-items: center;
1775
+ justify-content: flex-end;
1776
+ }
1777
+
1778
+ .plinth-data-table [data-numeric] {
1779
+ font-variant-numeric: tabular-nums;
1780
+ }
1781
+
1782
+ .plinth-data-table [data-phone='hidden'] {
1783
+ display: none;
1784
+ }
1785
+
1786
+ .plinth-data-table-row[aria-selected='true'] {
1787
+ background-color: var(--plinth-color-accent-subtle);
1788
+ border-color: var(--plinth-color-accent);
1789
+ }
1790
+
1791
+ /* The selected row's edge, so the tint is not the only sign of it. */
1792
+ .plinth-data-table-row[aria-selected='true']::before {
1793
+ position: absolute;
1794
+ inset-block: var(--plinth-space-3);
1795
+ inset-inline-start: 0;
1796
+ inline-size: var(--plinth-border-width-thick);
1797
+ content: '';
1798
+ background-color: var(--plinth-color-accent);
1799
+ border-radius: var(--plinth-radius-pill);
1800
+ }
1801
+
1802
+ .plinth-data-table-empty {
1803
+ display: block;
1804
+ }
1805
+
1806
+ .plinth-data-table-empty-cell {
1807
+ display: block;
1808
+ padding-block: var(--plinth-space-8);
1809
+ padding-inline: var(--plinth-space-4);
1810
+ color: var(--plinth-color-text-muted);
1811
+ text-align: center;
1812
+ overflow-wrap: anywhere;
1813
+ background-color: var(--plinth-color-surface-raised);
1814
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
1815
+ border-radius: var(--plinth-radius-container);
1816
+ }
1817
+
1818
+ /* From 768px: a table. */
1819
+ @media (min-width: 768px) {
1820
+ .plinth-data-table {
1821
+ display: table;
1822
+ }
1823
+
1824
+ .plinth-data-table-caption {
1825
+ display: table-caption;
1826
+ padding-inline: 0;
1827
+ caption-side: top;
1828
+ }
1829
+
1830
+ .plinth-data-table-head {
1831
+ display: table-header-group;
1832
+ }
1833
+
1834
+ .plinth-data-table-head-row,
1835
+ .plinth-data-table-head-row:has(> [aria-sort]:not([data-phone='hidden'])) {
1836
+ display: table-row;
1837
+ margin: 0;
1838
+ }
1839
+
1840
+ .plinth-data-table-column,
1841
+ .plinth-data-table-column:not([aria-sort]),
1842
+ .plinth-data-table [data-phone='hidden'].plinth-data-table-column {
1843
+ position: static;
1844
+ box-sizing: border-box;
1845
+ display: table-cell;
1846
+ inline-size: auto;
1847
+ block-size: var(--plinth-touch-target);
1848
+ padding-block: 0;
1849
+ padding-inline: var(--plinth-space-3);
1850
+ overflow: visible;
1851
+ vertical-align: middle;
1852
+ white-space: normal;
1853
+ border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-border);
1854
+ clip-path: none;
1855
+ }
1856
+
1857
+ .plinth-data-table-sort {
1858
+ padding-inline: var(--plinth-space-1);
1859
+ margin-inline: calc(var(--plinth-space-1) * -1);
1860
+ background-color: transparent;
1861
+ border: 0;
1862
+ border-radius: var(--plinth-radius-control);
1863
+ }
1864
+
1865
+ @media (hover: hover) {
1866
+ .plinth-data-table-sort:hover {
1867
+ background-color: transparent;
1868
+ }
1869
+ }
1870
+
1871
+ .plinth-data-table-sort:enabled:active {
1872
+ background-color: var(--plinth-color-surface-sunken);
1873
+ }
1874
+
1875
+ .plinth-data-table-body {
1876
+ display: table-row-group;
1877
+ }
1878
+
1879
+ .plinth-data-table-row {
1880
+ position: static;
1881
+ display: table-row;
1882
+ padding: 0;
1883
+ background-color: transparent;
1884
+ border: 0;
1885
+ border-radius: 0;
1886
+ }
1887
+
1888
+ .plinth-data-table-cell,
1889
+ .plinth-data-table-cell[data-kind='actions'],
1890
+ .plinth-data-table [data-phone='hidden'].plinth-data-table-cell {
1891
+ position: relative;
1892
+ box-sizing: border-box;
1893
+ display: table-cell;
1894
+ block-size: var(--plinth-data-table-row-height);
1895
+ padding-block: var(--plinth-space-2);
1896
+ padding-inline: var(--plinth-space-3);
1897
+ margin: 0;
1898
+ vertical-align: middle;
1899
+ text-align: start;
1900
+ overflow-wrap: break-word;
1901
+ border: 0;
1902
+ }
1903
+
1904
+ .plinth-data-table-row + .plinth-data-table-row > .plinth-data-table-cell {
1905
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
1906
+ }
1907
+
1908
+ .plinth-data-table-label {
1909
+ display: none;
1910
+ }
1911
+
1912
+ .plinth-data-table-value {
1913
+ text-align: inherit;
1914
+ }
1915
+
1916
+ .plinth-data-table-cell[data-kind='actions'] .plinth-data-table-value {
1917
+ display: inline-flex;
1918
+ flex-wrap: nowrap;
1919
+ }
1920
+
1921
+ .plinth-data-table-column[data-numeric],
1922
+ .plinth-data-table-cell[data-numeric] {
1923
+ text-align: end;
1924
+ }
1925
+
1926
+ .plinth-data-table-row[aria-selected='true'] {
1927
+ border-color: transparent;
1928
+ }
1929
+
1930
+ .plinth-data-table-row[aria-selected='true'] > .plinth-data-table-cell {
1931
+ background-color: var(--plinth-color-accent-subtle);
1932
+ }
1933
+
1934
+ .plinth-data-table-row[aria-selected='true']::before {
1935
+ content: none;
1936
+ }
1937
+
1938
+ .plinth-data-table-row[aria-selected='true'] > .plinth-data-table-cell:first-child::before {
1939
+ position: absolute;
1940
+ inset-block: 0;
1941
+ inset-inline-start: 0;
1942
+ inline-size: var(--plinth-border-width-thick);
1943
+ content: '';
1944
+ background-color: var(--plinth-color-accent);
1945
+ }
1946
+
1947
+ .plinth-data-table-empty {
1948
+ display: table-row;
1949
+ }
1950
+
1951
+ .plinth-data-table-empty-cell {
1952
+ display: table-cell;
1953
+ background-color: transparent;
1954
+ border: 0;
1955
+ border-radius: 0;
1956
+ }
1957
+ }
1958
+
1959
+ @media (min-width: 1280px) {
1960
+ .plinth-data-table-column,
1961
+ .plinth-data-table-column:not([aria-sort]),
1962
+ .plinth-data-table [data-phone='hidden'].plinth-data-table-column,
1963
+ .plinth-data-table-cell,
1964
+ .plinth-data-table-cell[data-kind='actions'],
1965
+ .plinth-data-table [data-phone='hidden'].plinth-data-table-cell {
1966
+ padding-inline: var(--plinth-space-4);
1967
+ }
1968
+ }
1969
+
1970
+ @media (forced-colors: active) {
1971
+ .plinth-data-table-row[aria-selected='true'],
1972
+ .plinth-data-table-row[aria-selected='true'] > .plinth-data-table-cell {
1973
+ color: HighlightText;
1974
+ background-color: Highlight;
1975
+ }
1976
+ }
1977
+ }
1978
+
1979
+ /* dialog.css */
1980
+ /*
1981
+ * Dialog: a modal for a form, the details of something or its settings, centred on the screen at
1982
+ * every width; with data-side-phone="bottom" it is a bottom sheet below 768px.
1983
+ *
1984
+ * <dialog id="settings" class="plinth-dialog" closedby="any"
1985
+ * aria-labelledby="settings-title" aria-describedby="settings-description">
1986
+ * <div class="plinth-dialog-panel">
1987
+ * <div class="plinth-dialog-handle" aria-hidden="true"></div>
1988
+ * <div class="plinth-dialog-header">
1989
+ * <h2 id="settings-title" class="plinth-dialog-title">Settings</h2>
1990
+ * <p id="settings-description" class="plinth-dialog-description">Changes apply at once.</p>
1991
+ * <button type="button" class="plinth-button plinth-icon-button plinth-dialog-close"
1992
+ * data-variant="ghost" data-size="md" aria-label="Close" commandfor="settings"
1993
+ * command="close">
1994
+ * <span class="plinth-icon" data-size="md" aria-hidden="true"><svg>…</svg></span>
1995
+ * </button>
1996
+ * </div>
1997
+ * <div class="plinth-dialog-body">…</div>
1998
+ * <div class="plinth-dialog-footer">…</div>
1999
+ * </div>
2000
+ * </dialog>
2001
+ *
2002
+ * The root is the <dialog>, and it needs an id: commandfor and aria-labelledby point at it, and
2003
+ * the title's and description's ids are the root's id with -title and -description. The panel is
2004
+ * the root's only child and holds the parts. The handle is there only on a dismissible dialog
2005
+ * with data-side-phone="bottom": decorative (aria-hidden), shown below 768px, where the dialog is
2006
+ * dragged down to close (modal.css). The title is
2007
+ * required; the description (and its aria-describedby) and the footer are optional. The close
2008
+ * button is an icon button (button.css and icon.css). It needs modal.css, the base it shares with
2009
+ * the sheet: the surface, the backdrop, the scroll lock, the motion, the safe areas and the parts
2010
+ * are there, and this file sets only where the dialog sits and how big it is.
2011
+ *
2012
+ * Open it with showModal() or a <button commandfor="settings" command="show-modal">. Open is the
2013
+ * native state, [open]; the browser puts the dialog in the top layer, makes the page inert, traps
2014
+ * focus, and returns focus to what opened it. The dialog fires close when it closes.
2015
+ *
2016
+ * Dismissible (the default): closedby="any" and the close button; the close button, Escape and a
2017
+ * tap on the backdrop close it (closedby="any" is an enhancement Safari doesn't have yet, so the
2018
+ * close button is always there). Not dismissible, for a choice the person must make:
2019
+ * role="alertdialog", closedby="closerequest" and no close button, so a stray tap on the backdrop
2020
+ * does nothing and it closes through its own buttons, with Escape acting as Cancel (the browser
2021
+ * fires cancel, then close, with an empty returnValue). It needs an action that closes it,
2022
+ * usually in the footer, the least destructive first: a touch screen has no Escape, and the
2023
+ * browser focuses the first control. Give it a description: an alert dialog's message is its
2024
+ * accessible description. A project may set its own closedby.
2025
+ *
2026
+ * Presentation: centred at every width, dialog.width wide (less the screen's margin on a phone),
2027
+ * its height capped by the screen's, fading and rising into place. data-side-phone="bottom" (the
2028
+ * only value; leave it out for centred) makes it a bottom sheet below 768px: the screen's width,
2029
+ * on the bottom edge, sliding up, its last part clearing the home indicator; from 768px up it is
2030
+ * centred like any dialog.
2031
+ *
2032
+ * On a phone the dialog's height is capped by 100dvh, which doesn't, on its own, clear the
2033
+ * on-screen keyboard: a page with a form in a dialog sets interactive-widget=resizes-content in
2034
+ * its viewport meta where the browser supports it, as for the sheet.
2035
+ *
2036
+ * In Livewire, a re-render would morph the open dialog back to the server's markup and drop its
2037
+ * open attribute: put wire:ignore.self on the dialog (or keep it outside the component that
2038
+ * re-renders), and always pass an id, since a generated one changes on every render.
2039
+ */
2040
+
2041
+ @layer plinth {
2042
+ .plinth-dialog {
2043
+ --plinth-modal-offset: 0 var(--plinth-space-4);
2044
+ --plinth-modal-closed-opacity: 0;
2045
+
2046
+ inset: 0;
2047
+ inline-size: min(100% - 2 * var(--plinth-space-4), var(--plinth-dialog-width));
2048
+ max-block-size: calc(100dvh - 2 * var(--plinth-space-4));
2049
+ margin: auto;
2050
+ }
2051
+
2052
+ .plinth-dialog[data-side-phone='bottom'] {
2053
+ --plinth-modal-offset: 0 100%;
2054
+ --plinth-modal-closed-opacity: 1;
2055
+ --plinth-modal-safe-area-end: env(safe-area-inset-bottom);
2056
+
2057
+ inset-block: auto 0;
2058
+ inline-size: 100%;
2059
+ max-block-size: calc(100dvh - var(--plinth-space-12));
2060
+ margin: 0;
2061
+ border-block-end-width: 0;
2062
+ border-end-start-radius: 0;
2063
+ border-end-end-radius: 0;
2064
+ }
2065
+
2066
+ /* One that can be dismissed is dragged down to close (modal.css): the root fills the screen. */
2067
+ .plinth-dialog[data-side-phone='bottom']:has(> * > .plinth-dialog-handle) {
2068
+ inset: 0;
2069
+ block-size: auto;
2070
+ max-block-size: none;
2071
+ border-width: 0;
2072
+ border-radius: 0;
2073
+ }
2074
+
2075
+ @media (min-width: 768px) {
2076
+ .plinth-dialog,
2077
+ .plinth-dialog[data-side-phone='bottom'],
2078
+ .plinth-dialog[data-side-phone='bottom']:has(> * > .plinth-dialog-handle) {
2079
+ --plinth-modal-offset: 0 var(--plinth-space-4);
2080
+ --plinth-modal-closed-opacity: 0;
2081
+ --plinth-modal-safe-area-end: initial;
2082
+
2083
+ inset: 0;
2084
+ inline-size: min(100% - 2 * var(--plinth-space-8), var(--plinth-dialog-width));
2085
+ block-size: fit-content;
2086
+ max-block-size: calc(100dvh - 2 * var(--plinth-space-8));
2087
+ margin: auto;
2088
+ border-width: var(--plinth-border-width-thin);
2089
+ border-radius: var(--plinth-radius-container);
2090
+ }
2091
+ }
2092
+ }
2093
+
2094
+ /* drawer.css */
2095
+ /*
2096
+ * Drawer: a phone-first panel on the bottom edge that the person drags, like a maps app's panel,
2097
+ * with optional snap points (a peek, half and full height). From 768px up it is a dialog or a
2098
+ * sheet.
2099
+ *
2100
+ * <dialog id="route" class="plinth-drawer" data-desktop="dialog"
2101
+ * data-snap-points="peek half full" data-snap="peek" closedby="any"
2102
+ * aria-labelledby="route-title" aria-describedby="route-description">
2103
+ * <div class="plinth-drawer-panel">
2104
+ * <div class="plinth-drawer-handle" aria-hidden="true"></div>
2105
+ * <div class="plinth-drawer-header">
2106
+ * <h2 id="route-title" class="plinth-drawer-title">Route</h2>
2107
+ * <p id="route-description" class="plinth-drawer-description">12 min by bike.</p>
2108
+ * <button type="button" class="plinth-button plinth-icon-button plinth-drawer-close"
2109
+ * data-variant="ghost" data-size="md" aria-label="Close" commandfor="route"
2110
+ * command="close">
2111
+ * <span class="plinth-icon" data-size="md" aria-hidden="true"><svg>…</svg></span>
2112
+ * </button>
2113
+ * </div>
2114
+ * <div class="plinth-drawer-body">…</div>
2115
+ * <div class="plinth-drawer-footer">…</div>
2116
+ * </div>
2117
+ * </dialog>
2118
+ *
2119
+ * The root is the <dialog>, and it needs an id: commandfor and aria-labelledby point at it, and
2120
+ * the title's and description's ids are the root's id with -title and -description. The panel is
2121
+ * the root's only child and holds the parts; the handle is always there, first, and decorative
2122
+ * (aria-hidden): Escape and the close button, which is always there too, are the ways to close it
2123
+ * without dragging. The title is required; the description (and its aria-describedby) and the
2124
+ * footer are optional. The close button is an icon button (button.css and icon.css). It needs
2125
+ * modal.css, the base it shares with the dialog and the sheet, where the dragging is described:
2126
+ * this file sets where the drawer sits, how big it is and its snap points.
2127
+ *
2128
+ * Open it with showModal() or a <button commandfor="route" command="show-modal">. Open is the
2129
+ * native state, [open]; the browser puts the drawer in the top layer, makes the page inert, traps
2130
+ * focus, closes on Escape and returns focus. The dialog fires close when it closes, whichever way
2131
+ * it closed.
2132
+ *
2133
+ * On a phone (below 768px) it is the screen's width on the bottom edge and slides up. Dragged
2134
+ * down past its lowest snap point it closes; the layers do that part (modal.css). With no snap
2135
+ * points it is as tall as its content, up to the screen's height less a gap.
2136
+ *
2137
+ * Snap points (data-snap-points, optional): any of peek, half and full, separated by spaces.
2138
+ * With them the panel is the screen's height less a gap, and it rests where it is let go at the
2139
+ * nearest one: peek shows drawer.peek-height of it (the handle, the header and the top of the
2140
+ * body), half the screen's height, full all of it. The body scrolls inside the panel at any of
2141
+ * them, so a drawer with a long body is dragged by its handle and header. data-snap is the snap
2142
+ * point it opens at, one of those listed (the layers print the largest listed when it is missing
2143
+ * or unknown); the browser opens it at full, or the largest listed, and the layers then scroll it
2144
+ * to data-snap. The drawer remembers nothing: where it rests is the person's.
2145
+ *
2146
+ * Desktop (data-desktop): its presentation from 768px up. dialog (the default, also when missing
2147
+ * or unknown) centres it like the dialog, at dialog.width; sheet makes it a full-height panel on
2148
+ * the inline-end edge, sheet.side-width wide, like the sheet. From 768px up there is no handle
2149
+ * and no dragging, the snap points don't apply, and a tap on the backdrop closes it
2150
+ * (closedby="any"; Safari doesn't have it yet, so the close button is always there).
2151
+ *
2152
+ * The motion runs on the motion tokens, which drop to 0ms under prefers-reduced-motion; the
2153
+ * snapping is the browser's scrolling.
2154
+ *
2155
+ * In Livewire, a re-render would morph the open dialog back to the server's markup and drop its
2156
+ * open attribute: put wire:ignore.self on the drawer (or keep it outside the component that
2157
+ * re-renders), and always pass an id, since a generated one changes on every render.
2158
+ */
2159
+
2160
+ @layer plinth {
2161
+ .plinth-drawer {
2162
+ --plinth-modal-offset: 0 100%;
2163
+ --plinth-modal-safe-area-end: env(safe-area-inset-bottom);
2164
+
2165
+ /* Translate isn't logical: -1 (under :dir(rtl), below) turns the sheet's slide around. */
2166
+ --plinth-drawer-direction: 1;
2167
+
2168
+ inset: 0;
2169
+ inline-size: 100%;
2170
+ block-size: auto;
2171
+ max-block-size: none;
2172
+ margin: 0;
2173
+ border-width: 0;
2174
+ border-radius: 0;
2175
+ }
2176
+
2177
+ /* With snap points the panel is its full height, and the snap points are where it rests. */
2178
+ .plinth-drawer[data-snap-points] > .plinth-drawer-panel {
2179
+ block-size: calc(
2180
+ 100% - max(var(--plinth-space-12), env(safe-area-inset-top) + var(--plinth-space-4))
2181
+ );
2182
+ }
2183
+
2184
+ /* Full is the panel's end on the screen's bottom edge, the base's open position. */
2185
+ .plinth-drawer[data-snap-points]:not([data-snap-points~='full']) > .plinth-drawer-panel {
2186
+ scroll-snap-align: none;
2187
+ }
2188
+
2189
+ /*
2190
+ * Peek and half are the panel's top that far from the screen's bottom edge. The snap areas are
2191
+ * the handle (peek) and the header (half), the first parts of the panel, so their margins add
2192
+ * what lies above them: the handle's margin, then the handle (modal.css; change them together).
2193
+ */
2194
+ .plinth-drawer[data-snap-points~='peek'] .plinth-drawer-handle {
2195
+ scroll-snap-align: start;
2196
+ scroll-margin-block-start: calc(
2197
+ 100dvh - var(--plinth-drawer-peek-height) + var(--plinth-space-2)
2198
+ );
2199
+ }
2200
+
2201
+ .plinth-drawer[data-snap-points~='half'] .plinth-drawer-header {
2202
+ scroll-snap-align: start;
2203
+ scroll-margin-block-start: calc(50dvh + var(--plinth-space-2) + var(--plinth-space-1));
2204
+ }
2205
+
2206
+ .plinth-drawer:dir(rtl) {
2207
+ --plinth-drawer-direction: -1;
2208
+ }
2209
+
2210
+ @media (min-width: 768px) {
2211
+ .plinth-drawer {
2212
+ --plinth-modal-offset: 0 var(--plinth-space-4);
2213
+ --plinth-modal-closed-opacity: 0;
2214
+ --plinth-modal-safe-area-end: initial;
2215
+
2216
+ inset: 0;
2217
+ inline-size: min(100% - 2 * var(--plinth-space-8), var(--plinth-dialog-width));
2218
+ block-size: fit-content;
2219
+ max-block-size: calc(100dvh - 2 * var(--plinth-space-8));
2220
+ margin: auto;
2221
+ border-width: var(--plinth-border-width-thin);
2222
+ border-radius: var(--plinth-radius-container);
2223
+ }
2224
+
2225
+ .plinth-drawer[data-desktop='sheet'] {
2226
+ --plinth-modal-offset: calc(100% * var(--plinth-drawer-direction)) 0;
2227
+ --plinth-modal-closed-opacity: 1;
2228
+ --plinth-modal-safe-area-end: env(safe-area-inset-bottom);
2229
+
2230
+ inset-block: 0;
2231
+ inset-inline: auto 0;
2232
+ inline-size: min(100% - var(--plinth-space-12), var(--plinth-sheet-side-width));
2233
+ block-size: auto;
2234
+ max-block-size: none;
2235
+ padding-block: env(safe-area-inset-top) 0;
2236
+ margin: 0;
2237
+ border-width: 0;
2238
+ border-inline-start-width: var(--plinth-border-width-thin);
2239
+ border-radius: 0;
2240
+ }
2241
+
2242
+ .plinth-drawer[data-snap-points] > .plinth-drawer-panel {
2243
+ block-size: auto;
2244
+ }
2245
+
2246
+ .plinth-drawer[data-snap-points] :is(.plinth-drawer-handle, .plinth-drawer-header) {
2247
+ scroll-snap-align: none;
2248
+ }
2249
+ }
2250
+ }
2251
+
2252
+ /* icon.css */
2253
+ /*
2254
+ * Icon: a project's own SVG, sized by Plinth.
2255
+ *
2256
+ * Decorative (the default), hidden from assistive technology:
2257
+ * <span class="plinth-icon" data-size="md" aria-hidden="true">
2258
+ * <svg viewBox="0 0 24 24">…</svg>
2259
+ * </span>
2260
+ *
2261
+ * Meaningful, with a label (an img role makes the SVG inside presentational). Blade, Vue and
2262
+ * React take it as `label`, or as an `aria-label` attribute; a blank label leaves the icon
2263
+ * decorative:
2264
+ * <span class="plinth-icon" data-size="md" role="img" aria-label="Warning">
2265
+ * <svg viewBox="0 0 24 24">…</svg>
2266
+ * </span>
2267
+ *
2268
+ * Sizes (data-size): sm 16px, md 20px (default, also when the attribute is missing or unknown),
2269
+ * lg 24px. data-mirror flips the icon in right-to-left text, for icons that point a direction.
2270
+ *
2271
+ * The SVG comes from the project, inline or as <use href="sprite.svg#name">. It should draw with
2272
+ * currentColor; Plinth sets no fill or stroke, so outline and filled icon sets both keep theirs.
2273
+ * The icon takes its colour from the text around it.
2274
+ */
2275
+
2276
+ @layer plinth {
2277
+ .plinth-icon {
2278
+ --plinth-icon-current-size: var(--plinth-size-icon-md);
2279
+
2280
+ display: inline-flex;
2281
+ flex: none;
2282
+ inline-size: var(--plinth-icon-current-size);
2283
+ block-size: var(--plinth-icon-current-size);
2284
+ vertical-align: middle;
2285
+ }
2286
+
2287
+ .plinth-icon[data-size='sm'] {
2288
+ --plinth-icon-current-size: var(--plinth-size-icon-sm);
2289
+ }
2290
+
2291
+ .plinth-icon[data-size='lg'] {
2292
+ --plinth-icon-current-size: var(--plinth-size-icon-lg);
2293
+ }
2294
+
2295
+ .plinth-icon > svg {
2296
+ inline-size: 100%;
2297
+ block-size: 100%;
2298
+ }
2299
+
2300
+ .plinth-icon[data-mirror]:dir(rtl) > svg {
2301
+ transform: scaleX(-1);
2302
+ }
2303
+ }
2304
+
2305
+ /* list-card.css */
2306
+ /*
2307
+ * List card: a group of list rows on a raised surface, with an optional heading and footer note.
2308
+ *
2309
+ * <div class="plinth-list-card">
2310
+ * <h2 class="plinth-list-card-heading">Account</h2>
2311
+ * <ul class="plinth-list-card-list" role="list">
2312
+ * <li><a class="plinth-list-row" href="…">…</a></li>
2313
+ * <li><div class="plinth-list-row">…</div></li>
2314
+ * </ul>
2315
+ * <p class="plinth-list-card-footer">Changes apply to every device.</p>
2316
+ * </div>
2317
+ *
2318
+ * The heading and the footer are optional, and sit outside the raised surface: the heading above
2319
+ * it, the footer note below. The heading is an <h2> by default; pick the level that fits the page
2320
+ * (heading-level h2 to h6 in Blade, headingLevel in Vue).
2321
+ *
2322
+ * The list is the raised surface (color.surface-raised, radius.container, a hairline in
2323
+ * color.border; no shadow, per ADR 3: the card does not float). The card resets the list's
2324
+ * margin, padding and bullets, and prints role="list" so Safari keeps announcing it. Each row
2325
+ * goes in its own <li> and keeps its own root, states and divider; the surface clips the rows'
2326
+ * hover and selection tints to its corners, and anything a row positions outside itself (a
2327
+ * popover or a dialog is in the top layer, so it is not). The first and last rows take the
2328
+ * surface's inner radius, so their focus ring, drawn inside the row, follows the corners.
2329
+ */
2330
+
2331
+ @layer plinth {
2332
+ .plinth-list-card {
2333
+ display: flex;
2334
+ flex-direction: column;
2335
+ gap: var(--plinth-space-2);
2336
+ min-inline-size: 0;
2337
+ font-family: var(--plinth-font-family-body);
2338
+ color: var(--plinth-color-text);
2339
+ }
2340
+
2341
+ .plinth-list-card-heading {
2342
+ padding-inline: var(--plinth-space-4);
2343
+ margin: 0;
2344
+ font-size: var(--plinth-font-size-sm);
2345
+ font-weight: var(--plinth-font-weight-medium);
2346
+ line-height: var(--plinth-font-leading-normal);
2347
+ color: var(--plinth-color-text-muted);
2348
+ overflow-wrap: anywhere;
2349
+ }
2350
+
2351
+ .plinth-list-card-list {
2352
+ padding: 0;
2353
+ margin: 0;
2354
+ overflow: hidden;
2355
+ list-style: none;
2356
+ background-color: var(--plinth-color-surface-raised);
2357
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
2358
+ border-radius: var(--plinth-radius-container);
2359
+ }
2360
+
2361
+ .plinth-list-card-footer {
2362
+ padding-inline: var(--plinth-space-4);
2363
+ margin: 0;
2364
+ font-size: var(--plinth-font-size-sm);
2365
+ line-height: var(--plinth-font-leading-normal);
2366
+ color: var(--plinth-color-text-muted);
2367
+ overflow-wrap: anywhere;
2368
+ }
2369
+
2370
+ .plinth-list-card-list > li:first-child > .plinth-list-row {
2371
+ border-start-start-radius: calc(
2372
+ var(--plinth-radius-container) - var(--plinth-border-width-thin)
2373
+ );
2374
+ border-start-end-radius: calc(var(--plinth-radius-container) - var(--plinth-border-width-thin));
2375
+ }
2376
+
2377
+ .plinth-list-card-list > li:last-child > .plinth-list-row {
2378
+ border-end-start-radius: calc(var(--plinth-radius-container) - var(--plinth-border-width-thin));
2379
+ border-end-end-radius: calc(var(--plinth-radius-container) - var(--plinth-border-width-thin));
2380
+ }
2381
+ }
2382
+
2383
+ /* list-row.css */
2384
+ /*
2385
+ * List row: one row of a list or a sheet, 44px tall at least.
2386
+ *
2387
+ * <div class="plinth-list-row">
2388
+ * <span class="plinth-list-row-leading">an icon or an avatar</span>
2389
+ * <span class="plinth-list-row-content">
2390
+ * <span class="plinth-list-row-title">Notifications</span>
2391
+ * <span class="plinth-list-row-description">Email and push</span>
2392
+ * </span>
2393
+ * <span class="plinth-list-row-trailing">
2394
+ * a value, a badge or a control
2395
+ * <span class="plinth-icon plinth-list-row-chevron" data-size="sm" data-mirror
2396
+ * aria-hidden="true"><svg>…</svg></span>
2397
+ * </span>
2398
+ * </div>
2399
+ *
2400
+ * The leading slot, the description and the trailing slot are optional; the chevron is the
2401
+ * row's own (chevron in Blade and Vue), and points the other way in right-to-left text.
2402
+ *
2403
+ * The root is the element the app talks to (ADR 2):
2404
+ * - a static row is a <div>;
2405
+ * - a row that navigates is <a class="plinth-list-row" href="…">;
2406
+ * - a row that acts is <button type="button" class="plinth-list-row">.
2407
+ * A link or button row holds no other control: a switch or an icon button in the trailing slot
2408
+ * needs a static row, and its own accessible name.
2409
+ *
2410
+ * States:
2411
+ * - current: aria-current="page" on a link row (or a static row) for the page being shown, set
2412
+ * by the app; the selection tint (accent-subtle, accent-text) and a heavier title. A button
2413
+ * row never prints it: a button is not a page.
2414
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
2415
+ * - hover only where the device can hover; the focus ring on :focus-visible, drawn inside the
2416
+ * row (focus-ring.offset-inset).
2417
+ *
2418
+ * A hairline divider (color.divider) sits between two rows that follow each other, as siblings
2419
+ * or each as the direct child of its own <li>; a wrapper element in between drops it. The app
2420
+ * resets the list (margin, padding, list-style) and adds role="list", which Safari needs to keep
2421
+ * announcing a list without bullets. The height is --plinth-list-row-height (44px); a description makes the
2422
+ * row taller. Long titles and descriptions wrap; the trailing slot keeps its width up to half the
2423
+ * row, and only a value longer than that wraps.
2424
+ */
2425
+
2426
+ @layer plinth {
2427
+ .plinth-list-row {
2428
+ box-sizing: border-box;
2429
+ display: flex;
2430
+ gap: var(--plinth-space-3);
2431
+ align-items: center;
2432
+ inline-size: 100%;
2433
+ min-block-size: var(--plinth-list-row-height);
2434
+ padding-block: var(--plinth-space-2);
2435
+ padding-inline: var(--plinth-space-4);
2436
+ margin: 0;
2437
+ font-family: var(--plinth-font-family-body);
2438
+ font-size: var(--plinth-font-size-md);
2439
+ font-weight: var(--plinth-font-weight-regular);
2440
+ line-height: var(--plinth-font-leading-tight);
2441
+ color: var(--plinth-color-text);
2442
+ text-align: start;
2443
+ text-decoration: none;
2444
+ background-color: transparent;
2445
+ border: 0;
2446
+ transition: background-color var(--plinth-duration-fast) var(--plinth-easing-standard);
2447
+ }
2448
+
2449
+ :is(a, button).plinth-list-row {
2450
+ cursor: pointer;
2451
+ }
2452
+
2453
+ li + li > .plinth-list-row {
2454
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
2455
+ }
2456
+
2457
+ .plinth-list-row + .plinth-list-row {
2458
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
2459
+ }
2460
+
2461
+ @media (hover: hover) {
2462
+ a.plinth-list-row[href]:not([aria-current='page']):hover,
2463
+ button.plinth-list-row:enabled:not([aria-current='page']):hover {
2464
+ background-color: var(--plinth-color-surface-hover);
2465
+ }
2466
+ }
2467
+
2468
+ a.plinth-list-row[href]:not([aria-current='page']):active,
2469
+ button.plinth-list-row:enabled:not([aria-current='page']):active {
2470
+ background-color: var(--plinth-color-surface-sunken);
2471
+ }
2472
+
2473
+ .plinth-list-row[aria-current='page'] {
2474
+ color: var(--plinth-color-accent-text);
2475
+ background-color: var(--plinth-color-accent-subtle);
2476
+ }
2477
+
2478
+ .plinth-list-row:disabled,
2479
+ .plinth-list-row[aria-disabled='true'] {
2480
+ cursor: not-allowed;
2481
+ opacity: var(--plinth-opacity-disabled);
2482
+ }
2483
+
2484
+ /* Inside the row, so a scrolling sheet or a card doesn't cut the ring off at the sides. */
2485
+ .plinth-list-row:focus-visible {
2486
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
2487
+ outline-offset: var(--plinth-focus-ring-offset-inset);
2488
+ }
2489
+
2490
+ .plinth-list-row-leading {
2491
+ display: inline-flex;
2492
+ flex: none;
2493
+ align-items: center;
2494
+ color: var(--plinth-color-text-muted);
2495
+ }
2496
+
2497
+ .plinth-list-row-content {
2498
+ display: flex;
2499
+ flex: 1 1 auto;
2500
+ flex-direction: column;
2501
+ gap: var(--plinth-space-0-5);
2502
+ min-inline-size: 0;
2503
+ }
2504
+
2505
+ .plinth-list-row-title {
2506
+ overflow-wrap: anywhere;
2507
+ }
2508
+
2509
+ .plinth-list-row-description {
2510
+ font-size: var(--plinth-font-size-sm);
2511
+ line-height: var(--plinth-font-leading-normal);
2512
+ color: var(--plinth-color-text-muted);
2513
+ overflow-wrap: anywhere;
2514
+ }
2515
+
2516
+ /* Keeps its width up to half the row, so a long title wraps before a short value does. */
2517
+ .plinth-list-row-trailing {
2518
+ display: inline-flex;
2519
+ flex: none;
2520
+ gap: var(--plinth-space-2);
2521
+ align-items: center;
2522
+ max-inline-size: 50%;
2523
+ color: var(--plinth-color-text-muted);
2524
+ text-align: end;
2525
+ overflow-wrap: anywhere;
2526
+ }
2527
+
2528
+ .plinth-list-row-chevron {
2529
+ color: var(--plinth-color-text-subtle);
2530
+ }
2531
+
2532
+ .plinth-list-row[aria-current='page'] .plinth-list-row-title {
2533
+ font-weight: var(--plinth-font-weight-medium);
2534
+ }
2535
+
2536
+ @media (forced-colors: active) {
2537
+ .plinth-list-row[aria-current='page'] {
2538
+ color: HighlightText;
2539
+ background-color: Highlight;
2540
+ }
2541
+
2542
+ .plinth-list-row:disabled,
2543
+ .plinth-list-row[aria-disabled='true'] {
2544
+ color: GrayText;
2545
+ }
2546
+ }
2547
+ }
2548
+
2549
+ /* menu-sheet.css */
2550
+ /*
2551
+ * Menu sheet: the app shell's menu, a sheet with the signed-in user, the navigation and the
2552
+ * account actions. A side panel from 768px up, a bottom sheet on a phone.
2553
+ *
2554
+ * <dialog id="menu" class="plinth-sheet plinth-menu-sheet" data-side="end"
2555
+ * data-side-phone="bottom" closedby="any" aria-labelledby="menu-title">
2556
+ * <div class="plinth-sheet-panel">
2557
+ * <div class="plinth-sheet-handle" aria-hidden="true"></div>
2558
+ * <div class="plinth-sheet-header">
2559
+ * <h2 id="menu-title" class="plinth-sheet-title">Menu</h2>
2560
+ * <button … class="plinth-button plinth-icon-button plinth-sheet-close" commandfor="menu"
2561
+ * command="close">…</button>
2562
+ * </div>
2563
+ * <div class="plinth-sheet-body">
2564
+ * <a class="plinth-list-row plinth-menu-sheet-user" href="/profile">
2565
+ * <span class="plinth-list-row-leading">
2566
+ * <span class="plinth-avatar" data-size="lg" aria-hidden="true">…</span>
2567
+ * </span>
2568
+ * <span class="plinth-list-row-content">
2569
+ * <span class="plinth-list-row-title">Sam Lee</span>
2570
+ * <span class="plinth-list-row-description">sam@example.com</span>
2571
+ * </span>
2572
+ * <span class="plinth-list-row-trailing">…chevron…</span>
2573
+ * </a>
2574
+ * <nav class="plinth-menu-sheet-nav" aria-labelledby="menu-title">
2575
+ * <div class="plinth-list-card">…link rows, one with aria-current="page"…</div>
2576
+ * </nav>
2577
+ * <div class="plinth-menu-sheet-account">
2578
+ * <div class="plinth-list-card">…Settings, Sign out…</div>
2579
+ * </div>
2580
+ * </div>
2581
+ * </div>
2582
+ * </dialog>
2583
+ *
2584
+ * It is a sheet (sheet.css), always data-side="end" data-side-phone="bottom", with no footer;
2585
+ * everything the sheet's contract says holds, and it needs modal.css, sheet.css, list-row.css, list-card.css, avatar.css,
2586
+ * button.css and icon.css. The root's id is required in practice: the header's menu button opens
2587
+ * it with <button commandfor="menu" command="show-modal">, the close button points at it too, and
2588
+ * the dialog and the nav are named by the title, whose id is the root's id with -title.
2589
+ *
2590
+ * The parts, in the body:
2591
+ * - the user row (optional): a list row with a decorative avatar (lg), the name and an optional
2592
+ * detail (an email or a role); a static <div>, or a link to the profile with a chevron when it
2593
+ * has an href. The avatar is decorative because the name is printed next to it.
2594
+ * - the nav (always there): a <nav> named by the sheet's title (aria-labelledby), holding list
2595
+ * cards of link rows, their headings <h3> under the sheet's <h2> title. The app marks the row
2596
+ * of the page being shown with aria-current="page" (current on the list row) and keeps it up
2597
+ * to date; the menu remembers nothing.
2598
+ * - the account (optional): a list card of account actions, such as Settings and Sign out (a
2599
+ * button row; for a form post, a button row with type="submit" and form="…").
2600
+ *
2601
+ * The parts stack with a gap between them. In the full-height side panel the account sits on the
2602
+ * bottom edge (margin-block-start: auto) and rises with the content when the nav is long; the
2603
+ * body scrolls as one.
2604
+ */
2605
+
2606
+ @layer plinth {
2607
+ .plinth-menu-sheet .plinth-sheet-body {
2608
+ display: flex;
2609
+ flex-direction: column;
2610
+ gap: var(--plinth-space-5);
2611
+ }
2612
+
2613
+ .plinth-menu-sheet-nav,
2614
+ .plinth-menu-sheet-account {
2615
+ display: flex;
2616
+ flex-direction: column;
2617
+ gap: var(--plinth-space-5);
2618
+ }
2619
+
2620
+ .plinth-menu-sheet-account {
2621
+ margin-block-start: auto;
2622
+ }
2623
+
2624
+ /* The name reads as the row's heading. */
2625
+ .plinth-menu-sheet-user .plinth-list-row-title {
2626
+ font-weight: var(--plinth-font-weight-medium);
2627
+ }
2628
+
2629
+ /* A link to the profile rounds its hover tint like the list cards' corners below it. */
2630
+ a.plinth-menu-sheet-user {
2631
+ border-radius: var(--plinth-radius-container);
2632
+ }
2633
+ }
2634
+
2635
+ /* menu.css */
2636
+ /*
2637
+ * Menu: a short list of actions that opens from a button, in a popover anchored to it.
2638
+ *
2639
+ * <button type="button" class="plinth-button plinth-icon-button" popovertarget="ticket-actions"
2640
+ * aria-label="More actions">…</button>
2641
+ * <div class="plinth-menu" id="ticket-actions" popover="auto">
2642
+ * <button type="button" class="plinth-menu-item" commandfor="ticket-actions"
2643
+ * command="hide-popover">
2644
+ * <span class="plinth-menu-icon" aria-hidden="true"><svg>…</svg></span>
2645
+ * <span class="plinth-menu-label">Assign to me</span>
2646
+ * <kbd class="plinth-menu-shortcut" aria-hidden="true">A</kbd>
2647
+ * </button>
2648
+ * <a class="plinth-menu-item" href="/tickets/42/print">
2649
+ * <span class="plinth-menu-label">Print</span>
2650
+ * </a>
2651
+ * <hr class="plinth-menu-separator">
2652
+ * <div class="plinth-menu-group" role="group" aria-label="Move to">
2653
+ * <div class="plinth-menu-heading" aria-hidden="true">Move to</div>
2654
+ * <button type="button" class="plinth-menu-item" commandfor="ticket-actions"
2655
+ * command="hide-popover"><span class="plinth-menu-label">Archive</span></button>
2656
+ * </div>
2657
+ * <hr class="plinth-menu-separator">
2658
+ * <button type="button" class="plinth-menu-item" commandfor="ticket-actions"
2659
+ * command="hide-popover" data-tone="danger">
2660
+ * <span class="plinth-menu-label">Delete</span>
2661
+ * </button>
2662
+ * </div>
2663
+ *
2664
+ * The root is a <div popover="auto"> with an id (required in Blade, Vue and React), which the
2665
+ * app's button names in popovertarget (or commandfor with command="toggle-popover"). The browser
2666
+ * opens and closes it (ADR 2): the top layer, a click outside or Escape closes it, focus goes back
2667
+ * to the button, and the browser tells assistive technology whether the button's menu is
2668
+ * expanded. The button is the app's own (an icon button or a button); it is not part of the menu.
2669
+ *
2670
+ * It is not role="menu". The ARIA menu pattern needs arrow keys and a roving focus, which would be
2671
+ * script in every layer; this is a list of buttons and links, reached with Tab, as ADR 2 decides
2672
+ * for menus of actions. So the button has no aria-haspopup, and the items are plain buttons and
2673
+ * links that read as such.
2674
+ *
2675
+ * Parts, each its own component in Blade, Vue and React:
2676
+ * - item: a <button type="button">, or an <a href> when it navigates. A button item closes its
2677
+ * menu when pressed: commandfor="<the menu's id>" command="hide-popover", which the layers take
2678
+ * from the menu around the item. A link closes the menu by leaving the page; an app that routes
2679
+ * without loading a page closes it with hidePopover(). The icon is the project's SVG, decorative;
2680
+ * the label is the item's name. The shortcut is a hint for the eye, aria-hidden; the app that
2681
+ * has the shortcut adds aria-keyshortcuts to the item.
2682
+ * - separator: an <hr> between items.
2683
+ * - group: a <div role="group"> named by its heading (aria-label), with the heading shown above
2684
+ * its items and hidden from assistive technology, which reads the group's name instead.
2685
+ *
2686
+ * Variants and states:
2687
+ * - data-tone="danger" on an item that deletes or removes something: the danger text colour and a
2688
+ * danger tint on hover. The label still says what it does; the colour is not the only signal.
2689
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
2690
+ * - hover only where the device can hover; pressed takes the sunken surface; the focus ring on
2691
+ * :focus-visible, drawn inside the item (focus-ring.offset-inset).
2692
+ * - open is the native one (:popover-open), never a class.
2693
+ *
2694
+ * Placement: below the button, starting at its start edge, through CSS anchor positioning (the
2695
+ * popover's button is its implicit anchor). It takes the side of the button with more room, above
2696
+ * or below, and is never taller than that room: a long menu scrolls. When it would leave the
2697
+ * screen sideways it flips to the button's end edge. It is the same at every width; a phone gets no bottom sheet,
2698
+ * since a menu is a few actions next to the button that opened them (a long list belongs in a
2699
+ * sheet). A browser without anchor positioning shows the menu in the middle of the screen, where
2700
+ * it still works. Script opens it with showPopover({ source: button }), so it has its anchor;
2701
+ * without a source it has none, and sits in the screen's start corner.
2702
+ *
2703
+ * Size: --plinth-menu-min-width (192px) to --plinth-menu-max-width (320px), never wider than the
2704
+ * screen less a margin, and never taller than the room it has. Items are --plinth-touch-target
2705
+ * (40px) tall; with a mouse from 768px up, --plinth-control-md (32px). A long label wraps.
2706
+ * It fades in and out on the motion tokens, which drop to 0ms under prefers-reduced-motion.
2707
+ */
2708
+
2709
+ @layer plinth {
2710
+ .plinth-menu {
2711
+ /* The browser's popover rules centre it on the screen: the fallback without anchoring. */
2712
+ box-sizing: border-box;
2713
+ min-inline-size: min(var(--plinth-menu-min-width), 100vi - 2 * var(--plinth-space-2));
2714
+ max-inline-size: min(var(--plinth-menu-max-width), 100vi - 2 * var(--plinth-space-2));
2715
+ max-block-size: calc(100dvb - 2 * var(--plinth-space-2));
2716
+ padding: var(--plinth-space-1);
2717
+ overflow: hidden auto;
2718
+ overscroll-behavior: contain;
2719
+ font-family: var(--plinth-font-family-body);
2720
+ font-size: var(--plinth-font-size-md);
2721
+ line-height: var(--plinth-font-leading-tight);
2722
+ color: var(--plinth-color-text);
2723
+ background-color: var(--plinth-color-surface-raised);
2724
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
2725
+ border-radius: var(--plinth-radius-container);
2726
+ box-shadow: var(--plinth-elevation-2);
2727
+ opacity: 0;
2728
+ transition:
2729
+ opacity var(--plinth-duration-fast) var(--plinth-easing-exit),
2730
+ overlay var(--plinth-duration-fast) allow-discrete,
2731
+ display var(--plinth-duration-fast) allow-discrete;
2732
+ }
2733
+
2734
+ .plinth-menu:popover-open {
2735
+ opacity: 1;
2736
+ transition-timing-function: var(--plinth-easing-enter);
2737
+ }
2738
+
2739
+ @starting-style {
2740
+ .plinth-menu:popover-open {
2741
+ opacity: 0;
2742
+ }
2743
+ }
2744
+
2745
+ @supports (position-area: block-end) {
2746
+ .plinth-menu {
2747
+ inset: auto;
2748
+ max-block-size: calc(100% - 2 * var(--plinth-space-1));
2749
+ margin-block: var(--plinth-space-1);
2750
+ margin-inline: 0;
2751
+ position-area: block-end span-inline-end;
2752
+ position-try-order: most-block-size;
2753
+ position-try-fallbacks:
2754
+ flip-block,
2755
+ flip-inline,
2756
+ flip-block flip-inline;
2757
+ }
2758
+ }
2759
+
2760
+ .plinth-menu-item {
2761
+ box-sizing: border-box;
2762
+ display: flex;
2763
+ gap: var(--plinth-space-2);
2764
+ align-items: center;
2765
+ inline-size: 100%;
2766
+ min-block-size: var(--plinth-touch-target);
2767
+ padding-block: var(--plinth-space-1);
2768
+ padding-inline: var(--plinth-space-2);
2769
+ margin: 0;
2770
+ font: inherit;
2771
+ color: var(--plinth-color-text);
2772
+ text-align: start;
2773
+ text-decoration: none;
2774
+ cursor: pointer;
2775
+ user-select: none;
2776
+ background-color: transparent;
2777
+ border: 0;
2778
+ border-radius: var(--plinth-radius-control);
2779
+ transition:
2780
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard),
2781
+ color var(--plinth-duration-fast) var(--plinth-easing-standard);
2782
+ }
2783
+
2784
+ .plinth-menu-item[data-tone='danger'] {
2785
+ color: var(--plinth-color-danger-text);
2786
+ }
2787
+
2788
+ @media (hover: hover) {
2789
+ a.plinth-menu-item[href]:hover,
2790
+ button.plinth-menu-item:enabled:hover {
2791
+ background-color: var(--plinth-color-surface-hover);
2792
+ }
2793
+
2794
+ a.plinth-menu-item[href][data-tone='danger']:hover,
2795
+ button.plinth-menu-item:enabled[data-tone='danger']:hover {
2796
+ background-color: var(--plinth-color-danger-surface);
2797
+ }
2798
+ }
2799
+
2800
+ a.plinth-menu-item[href]:active,
2801
+ button.plinth-menu-item:enabled:active {
2802
+ background-color: var(--plinth-color-surface-sunken);
2803
+ }
2804
+
2805
+ .plinth-menu-item:focus-visible {
2806
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
2807
+ outline-offset: var(--plinth-focus-ring-offset-inset);
2808
+ }
2809
+
2810
+ .plinth-menu-item:disabled,
2811
+ .plinth-menu-item[aria-disabled='true'] {
2812
+ cursor: not-allowed;
2813
+ opacity: var(--plinth-opacity-disabled);
2814
+ }
2815
+
2816
+ .plinth-menu-icon {
2817
+ display: inline-flex;
2818
+ flex: none;
2819
+ inline-size: var(--plinth-size-icon-sm);
2820
+ block-size: var(--plinth-size-icon-sm);
2821
+ color: var(--plinth-color-text-muted);
2822
+ }
2823
+
2824
+ .plinth-menu-item[data-tone='danger'] .plinth-menu-icon {
2825
+ color: inherit;
2826
+ }
2827
+
2828
+ .plinth-menu-icon > svg {
2829
+ inline-size: 100%;
2830
+ block-size: 100%;
2831
+ }
2832
+
2833
+ .plinth-menu-label {
2834
+ flex: 1 1 auto;
2835
+ min-inline-size: 0;
2836
+ overflow-wrap: anywhere;
2837
+ }
2838
+
2839
+ .plinth-menu-shortcut {
2840
+ flex: none;
2841
+ padding-inline-start: var(--plinth-space-4);
2842
+ margin-inline-start: auto;
2843
+ font-family: var(--plinth-font-family-mono);
2844
+ font-size: var(--plinth-font-size-xs);
2845
+ color: var(--plinth-color-text-subtle);
2846
+ }
2847
+
2848
+ .plinth-menu-separator {
2849
+ block-size: 0;
2850
+ margin-block: var(--plinth-space-1);
2851
+ margin-inline: calc(-1 * var(--plinth-space-1));
2852
+ border: 0;
2853
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
2854
+ }
2855
+
2856
+ .plinth-menu-group {
2857
+ margin: 0;
2858
+ }
2859
+
2860
+ .plinth-menu-heading {
2861
+ padding-block: var(--plinth-space-2) var(--plinth-space-1);
2862
+ padding-inline: var(--plinth-space-2);
2863
+ font-size: var(--plinth-font-size-xs);
2864
+ font-weight: var(--plinth-font-weight-medium);
2865
+ color: var(--plinth-color-text-subtle);
2866
+ }
2867
+
2868
+ @media (min-width: 768px) and (pointer: fine) {
2869
+ .plinth-menu-item {
2870
+ min-block-size: var(--plinth-control-md);
2871
+ }
2872
+ }
2873
+
2874
+ @media (forced-colors: active) {
2875
+ .plinth-menu-item:disabled,
2876
+ .plinth-menu-item[aria-disabled='true'] {
2877
+ color: GrayText;
2878
+ }
2879
+ }
2880
+ }
2881
+
2882
+ /* modal.css */
2883
+ /*
2884
+ * Modal base: what the dialog (dialog.css), the sheet (sheet.css) and the drawer (drawer.css)
2885
+ * share, written once so they can't drift apart (docs/adr/0005): the root's surface and motion,
2886
+ * the backdrop, the scroll lock, the safe areas, the panel and the grab handle, and the header,
2887
+ * title, description, close button, body and footer. It has no markup of its own and no class:
2888
+ * each component keeps its own root and part classes (plinth-dialog, plinth-dialog-panel, …;
2889
+ * plinth-sheet, plinth-sheet-panel, …), listed here in :is(). A page that loads the components
2890
+ * one by one loads modal.css with them.
2891
+ *
2892
+ * The parts sit in a panel, the root's only child:
2893
+ * <dialog class="plinth-sheet" …>
2894
+ * <div class="plinth-sheet-panel">
2895
+ * <div class="plinth-sheet-handle" aria-hidden="true"></div> (a bottom panel on a phone)
2896
+ * <div class="plinth-sheet-header">…</div>
2897
+ * <div class="plinth-sheet-body">…</div>
2898
+ * <div class="plinth-sheet-footer">…</div> (optional)
2899
+ * </div>
2900
+ * </dialog>
2901
+ *
2902
+ * The rules sit in a layer inside plinth (plinth.modal), so a component's own rules win over them
2903
+ * whatever the order the files load in, and a project's utilities win over both.
2904
+ *
2905
+ * Each component sets where its root sits and how big it is, and tunes the motion and the safe
2906
+ * area through three custom properties on the root; it never sets translate or opacity itself:
2907
+ * - --plinth-modal-offset: where the root slides in from and back out to (a translate value;
2908
+ * 0 0, the default, is no slide);
2909
+ * - --plinth-modal-closed-opacity: its opacity while it opens and closes (0 fades it, 1, the
2910
+ * default, doesn't);
2911
+ * - --plinth-modal-safe-area-end: env(safe-area-inset-bottom) when the root reaches the bottom
2912
+ * edge of the screen, so its last part clears the home indicator; initial (the default) when
2913
+ * it doesn't.
2914
+ *
2915
+ * The motion runs on the motion tokens, which drop to 0ms under prefers-reduced-motion. While a
2916
+ * modal is open the page under it doesn't scroll (html:has(…:modal)), and it has no scrollbar
2917
+ * gutter (scrollbar-gutter: auto, over a project's stable in an earlier layer): the top layer is
2918
+ * never drawn in the gutter, so a gutter would leave a strip of the page beside a sheet on the end
2919
+ * edge or the full width. Where the scrollbar takes space the page shifts by its width under the
2920
+ * backdrop; overlay scrollbars take no space and nothing shifts. The body scrolls, with
2921
+ * overscroll-behavior: contain; the header and footer stay put; the margins of the body's first and
2922
+ * last child are reset, so its padding is its edge. On a phone the footer's actions share its
2923
+ * width; from 768px up they sit at its end.
2924
+ *
2925
+ * A bottom panel on a phone (a sheet whose side below 768px is the bottom, a dialog with
2926
+ * data-side-phone="bottom" that can be dismissed, and the drawer) has a grab handle, and the
2927
+ * person drags it down to close it. The layers print the handle only there; its presence is what
2928
+ * turns the dragging on, below 768px. Dragging is native scrolling: the root fills the screen and
2929
+ * is a scroll-snap container, see-through, with a spacer as tall as the screen above the panel,
2930
+ * so the browser gives the momentum and the snapping. Without script the handle shows and the
2931
+ * close button and Escape close the panel. What the platform can't do, the layers add (a few
2932
+ * lines, docs/adr/0002 section 6: x-init in Blade, useSwipeToClose in Vue and React):
2933
+ * - on scrollend (scroll where it is missing), when the panel has gone off the bottom edge, they
2934
+ * call requestClose() (close() where it is missing), so cancel fires first, as for Escape; when
2935
+ * the app prevents it, the panel goes back to where it opened;
2936
+ * - a tap on the root itself, above the panel, closes it when closedby="any", since the browser
2937
+ * reads that tap as one on the dialog, not its backdrop;
2938
+ * - on toggle to open, a drawer with data-snap scrolls to that snap point (a browser that fires no
2939
+ * toggle on a dialog opens it at its largest).
2940
+ * The snapping is the browser's own scrolling, and the layers' scroll is instant.
2941
+ */
2942
+
2943
+ @layer plinth {
2944
+ @layer modal {
2945
+ html:has(:is(.plinth-dialog, .plinth-sheet, .plinth-drawer):modal) {
2946
+ overflow: hidden;
2947
+ scrollbar-gutter: auto;
2948
+ }
2949
+
2950
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer) {
2951
+ --plinth-modal-offset: 0 0;
2952
+ --plinth-modal-closed-opacity: 1;
2953
+ --plinth-modal-safe-area-end: initial;
2954
+
2955
+ position: fixed;
2956
+ box-sizing: border-box;
2957
+ flex-direction: column;
2958
+ max-inline-size: 100%;
2959
+ padding: 0;
2960
+ overflow: hidden;
2961
+ font-family: var(--plinth-font-family-body);
2962
+ color: var(--plinth-color-text);
2963
+ background-color: var(--plinth-color-surface-raised);
2964
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
2965
+ border-radius: var(--plinth-radius-container);
2966
+ box-shadow: var(--plinth-elevation-3);
2967
+ opacity: var(--plinth-modal-closed-opacity);
2968
+ translate: var(--plinth-modal-offset);
2969
+ transition:
2970
+ translate var(--plinth-duration-normal) var(--plinth-easing-exit),
2971
+ opacity var(--plinth-duration-normal) var(--plinth-easing-exit),
2972
+ overlay var(--plinth-duration-normal) allow-discrete,
2973
+ display var(--plinth-duration-normal) allow-discrete;
2974
+ }
2975
+
2976
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer)[open] {
2977
+ display: flex;
2978
+ opacity: 1;
2979
+ translate: 0 0;
2980
+ transition-timing-function: var(--plinth-easing-enter);
2981
+ }
2982
+
2983
+ @starting-style {
2984
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer)[open] {
2985
+ opacity: var(--plinth-modal-closed-opacity);
2986
+ translate: var(--plinth-modal-offset);
2987
+ }
2988
+ }
2989
+
2990
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer)::backdrop {
2991
+ background-color: var(--plinth-color-overlay);
2992
+ opacity: 0;
2993
+ transition:
2994
+ opacity var(--plinth-duration-normal) var(--plinth-easing-standard),
2995
+ overlay var(--plinth-duration-normal) allow-discrete,
2996
+ display var(--plinth-duration-normal) allow-discrete;
2997
+ }
2998
+
2999
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer)[open]::backdrop {
3000
+ opacity: 1;
3001
+ }
3002
+
3003
+ @starting-style {
3004
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer)[open]::backdrop {
3005
+ opacity: 0;
3006
+ }
3007
+ }
3008
+
3009
+ /*
3010
+ * The panel holds the parts, the root's only child. It is the column the body scrolls in;
3011
+ * the root is the surface, except for a bottom panel on a phone (below).
3012
+ */
3013
+ :is(.plinth-dialog-panel, .plinth-sheet-panel, .plinth-drawer-panel) {
3014
+ display: flex;
3015
+ flex: 1 1 auto;
3016
+ flex-direction: column;
3017
+ min-block-size: 0;
3018
+ margin: 0;
3019
+ }
3020
+
3021
+ :is(.plinth-dialog-header, .plinth-sheet-header, .plinth-drawer-header) {
3022
+ display: grid;
3023
+ grid-template-columns: minmax(0, 1fr) auto;
3024
+ gap: var(--plinth-space-1) var(--plinth-space-3);
3025
+ align-items: center;
3026
+ padding-block: var(--plinth-space-3);
3027
+ padding-inline: var(--plinth-space-4) var(--plinth-space-3);
3028
+ border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
3029
+ }
3030
+
3031
+ /* With no close button, the header's padding is even. */
3032
+ :is(.plinth-dialog-header, .plinth-sheet-header, .plinth-drawer-header):not(
3033
+ :has(> :is(.plinth-dialog-close, .plinth-sheet-close, .plinth-drawer-close))
3034
+ ) {
3035
+ padding-inline-end: var(--plinth-space-4);
3036
+ }
3037
+
3038
+ :is(.plinth-dialog-title, .plinth-sheet-title, .plinth-drawer-title) {
3039
+ grid-column: 1;
3040
+ margin: 0;
3041
+ font-size: var(--plinth-font-size-lg);
3042
+ font-weight: var(--plinth-font-weight-semibold);
3043
+ line-height: var(--plinth-font-leading-tight);
3044
+ letter-spacing: var(--plinth-font-tracking-tight);
3045
+ overflow-wrap: anywhere;
3046
+ }
3047
+
3048
+ :is(.plinth-dialog-description, .plinth-sheet-description, .plinth-drawer-description) {
3049
+ grid-column: 1;
3050
+ margin: 0;
3051
+ font-size: var(--plinth-font-size-sm);
3052
+ line-height: var(--plinth-font-leading-normal);
3053
+ color: var(--plinth-color-text-muted);
3054
+ overflow-wrap: anywhere;
3055
+ }
3056
+
3057
+ :is(.plinth-dialog-close, .plinth-sheet-close, .plinth-drawer-close) {
3058
+ grid-row: 1;
3059
+ grid-column: 2;
3060
+ }
3061
+
3062
+ :is(.plinth-dialog-body, .plinth-sheet-body, .plinth-drawer-body) {
3063
+ flex: 1 1 auto;
3064
+ min-block-size: 0;
3065
+ padding: var(--plinth-space-4);
3066
+ overflow-y: auto;
3067
+ overscroll-behavior: contain;
3068
+ font-size: var(--plinth-font-size-md);
3069
+ line-height: var(--plinth-font-leading-normal);
3070
+ }
3071
+
3072
+ /* The body's padding is its edge: the first and last child's margins don't add to it. */
3073
+ :is(.plinth-dialog-body, .plinth-sheet-body, .plinth-drawer-body) > :first-child {
3074
+ margin-block-start: 0;
3075
+ }
3076
+
3077
+ :is(.plinth-dialog-body, .plinth-sheet-body, .plinth-drawer-body) > :last-child {
3078
+ margin-block-end: 0;
3079
+ }
3080
+
3081
+ /*
3082
+ * A browser can focus a scrolling area that has nothing focusable in it (Chrome and Firefox
3083
+ * do, Safari doesn't yet), so a long body of text only is better with a link or a control in
3084
+ * it.
3085
+ */
3086
+ :is(.plinth-dialog-body, .plinth-sheet-body, .plinth-drawer-body):focus-visible {
3087
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
3088
+ outline-offset: var(--plinth-focus-ring-offset-inset);
3089
+ }
3090
+
3091
+ /* The last part clears the home indicator when the root reaches the bottom edge. */
3092
+ :is(.plinth-dialog-body, .plinth-sheet-body, .plinth-drawer-body):last-child {
3093
+ padding-block-end: max(
3094
+ var(--plinth-space-4),
3095
+ var(--plinth-modal-safe-area-end, var(--plinth-space-4))
3096
+ );
3097
+ }
3098
+
3099
+ :is(.plinth-dialog-footer, .plinth-sheet-footer, .plinth-drawer-footer) {
3100
+ display: flex;
3101
+ flex-wrap: wrap;
3102
+ gap: var(--plinth-space-2);
3103
+ justify-content: flex-end;
3104
+ padding-block: var(--plinth-space-3)
3105
+ max(var(--plinth-space-3), var(--plinth-modal-safe-area-end, var(--plinth-space-3)));
3106
+ padding-inline: var(--plinth-space-4);
3107
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
3108
+ }
3109
+
3110
+ /* On a phone the footer's actions share its width. */
3111
+ :is(.plinth-dialog-footer, .plinth-sheet-footer, .plinth-drawer-footer) > * {
3112
+ flex: 1 1 0;
3113
+ }
3114
+
3115
+ /*
3116
+ * A bottom panel on a phone, dragged down to close: a root whose panel holds a handle (the
3117
+ * layers print one only where the panel is on the bottom edge below 768px, and can be
3118
+ * dismissed). The root fills the screen and is a scroll-snap container, see-through; its
3119
+ * ::before is a spacer as tall as the screen above the panel, so scrolling it down is the
3120
+ * panel going off the bottom edge. The flex column runs bottom up (column-reverse), so the
3121
+ * root opens scrolled to its end, with the panel showing. Two snap positions: the panel's
3122
+ * end on the screen's bottom edge (open), and the spacer filling the screen (closed); the
3123
+ * layers close the dialog when the scrolling settles there. The panel is the surface, and the
3124
+ * body hands a swipe on to the root once it is scrolled to its top.
3125
+ */
3126
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer):has(
3127
+ > * > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3128
+ ) {
3129
+ flex-direction: column-reverse;
3130
+ overflow-y: auto;
3131
+ overscroll-behavior: contain;
3132
+ scroll-snap-type: y mandatory;
3133
+ scrollbar-width: none;
3134
+ background-color: transparent;
3135
+ box-shadow: none;
3136
+ }
3137
+
3138
+ /* As a scrolling area the root can take focus (Chrome and Firefox), so it shows the ring. */
3139
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer):has(
3140
+ > * > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3141
+ ):focus-visible {
3142
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
3143
+ outline-offset: var(--plinth-focus-ring-offset-inset);
3144
+ }
3145
+
3146
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer):has(
3147
+ > * > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3148
+ )::before {
3149
+ flex: none;
3150
+ order: 1;
3151
+ block-size: 100%;
3152
+ content: '';
3153
+ scroll-snap-align: start;
3154
+ }
3155
+
3156
+ :is(.plinth-dialog-panel, .plinth-sheet-panel, .plinth-drawer-panel):has(
3157
+ > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3158
+ ) {
3159
+ flex: none;
3160
+ max-block-size: calc(
3161
+ 100% - max(var(--plinth-space-12), env(safe-area-inset-top) + var(--plinth-space-4))
3162
+ );
3163
+ padding-inline: max(env(safe-area-inset-left), env(safe-area-inset-right));
3164
+ scroll-snap-align: end;
3165
+ background-color: var(--plinth-color-surface-raised);
3166
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-border);
3167
+ border-start-start-radius: var(--plinth-radius-container);
3168
+ border-start-end-radius: var(--plinth-radius-container);
3169
+ box-shadow: var(--plinth-elevation-3);
3170
+ }
3171
+
3172
+ /*
3173
+ * The grab handle, a short pill centred at the top; decorative, since Escape and the close
3174
+ * button close the panel too. From 768px up it is hidden.
3175
+ */
3176
+ :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle) {
3177
+ display: block;
3178
+ flex: none;
3179
+ align-self: center;
3180
+ inline-size: var(--plinth-space-10);
3181
+ block-size: var(--plinth-space-1);
3182
+ margin-block-start: var(--plinth-space-2);
3183
+ background-color: var(--plinth-color-border-strong);
3184
+ border-radius: var(--plinth-radius-pill);
3185
+ }
3186
+
3187
+ :is(.plinth-dialog-panel, .plinth-sheet-panel, .plinth-drawer-panel):has(
3188
+ > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3189
+ )
3190
+ > :is(.plinth-dialog-body, .plinth-sheet-body, .plinth-drawer-body) {
3191
+ overscroll-behavior: auto;
3192
+ }
3193
+
3194
+ @media (forced-colors: active) {
3195
+ :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle) {
3196
+ background-color: CanvasText;
3197
+ }
3198
+ }
3199
+
3200
+ @media (min-width: 768px) {
3201
+ :is(.plinth-dialog-footer, .plinth-sheet-footer, .plinth-drawer-footer) > * {
3202
+ flex: none;
3203
+ }
3204
+
3205
+ /* From 768px up no panel is dragged: the root is the surface again. */
3206
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer):has(
3207
+ > * > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3208
+ ) {
3209
+ flex-direction: column;
3210
+ overflow: hidden;
3211
+ overscroll-behavior: auto;
3212
+ scroll-snap-type: none;
3213
+ scrollbar-width: auto;
3214
+ background-color: var(--plinth-color-surface-raised);
3215
+ box-shadow: var(--plinth-elevation-3);
3216
+ }
3217
+
3218
+ :is(.plinth-dialog, .plinth-sheet, .plinth-drawer):has(
3219
+ > * > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3220
+ )::before {
3221
+ content: none;
3222
+ }
3223
+
3224
+ :is(.plinth-dialog-panel, .plinth-sheet-panel, .plinth-drawer-panel):has(
3225
+ > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3226
+ ) {
3227
+ flex: 1 1 auto;
3228
+ max-block-size: none;
3229
+ padding-inline: 0;
3230
+ scroll-snap-align: none;
3231
+ background-color: transparent;
3232
+ border-block-start: 0;
3233
+ border-radius: 0;
3234
+ box-shadow: none;
3235
+ }
3236
+
3237
+ :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle) {
3238
+ display: none;
3239
+ }
3240
+
3241
+ :is(.plinth-dialog-panel, .plinth-sheet-panel, .plinth-drawer-panel):has(
3242
+ > :is(.plinth-dialog-handle, .plinth-sheet-handle, .plinth-drawer-handle)
3243
+ )
3244
+ > :is(.plinth-dialog-body, .plinth-sheet-body, .plinth-drawer-body) {
3245
+ overscroll-behavior: contain;
3246
+ }
3247
+ }
3248
+ }
3249
+ }
3250
+
3251
+ /* page-header.css */
3252
+ /*
3253
+ * Page header: the bar at the top of a page, with a leading action, the page's heading and
3254
+ * trailing actions. On a phone it is the screen's top bar; in an app shell it heads the page beside
3255
+ * the sidebar, under the app bar. It sticks to the top as the content scrolls under it.
3256
+ *
3257
+ * <header class="plinth-page-header" data-top>
3258
+ * <div class="plinth-page-header-leading">
3259
+ * <a class="plinth-button plinth-icon-button" href="/inbox" data-variant="ghost"
3260
+ * data-size="md" aria-label="Back">
3261
+ * <span class="plinth-icon" data-size="md" data-mirror aria-hidden="true"><svg>…</svg></span>
3262
+ * </a>
3263
+ * </div>
3264
+ * <h1 class="plinth-page-header-heading">Inbox</h1>
3265
+ * <div class="plinth-page-header-actions">
3266
+ * <button type="button" class="plinth-button plinth-icon-button" data-variant="ghost"
3267
+ * data-size="md" aria-label="Search">…</button>
3268
+ * </div>
3269
+ * </header>
3270
+ *
3271
+ * The root is a <header>. At the top of the page (not inside <main>, <article>, <section>, <aside>
3272
+ * or <nav>) it is the page's banner landmark, so a page has one there; inside <main>, as in an app
3273
+ * shell, it is no landmark, only the page's header. The heading is required: an <h1> by default, or
3274
+ * <h2> to <h6> (level) when the page sits beside another page's <h1>, such as an open ticket beside
3275
+ * the list on a wide screen. The level doesn't change the look. The heading stays on one line and
3276
+ * ends in an ellipsis when it doesn't fit.
3277
+ *
3278
+ * The leading part holds one action, usually an icon button: a menu button that opens a sheet
3279
+ * (commandfor and command="show-modal") on a top-level page, or a back link (with data-mirror on
3280
+ * its icon so it points the other way in right-to-left text) on a page under it. The actions part
3281
+ * holds the trailing actions: at most three icon buttons (put the rest in a menu), or a primary
3282
+ * button such as New ticket. Both are optional, and printed only when they have content. Every
3283
+ * icon button needs its own name (label). The page header needs button.css and icon.css for them.
3284
+ *
3285
+ * Variant (the app sets it, the page header doesn't guess):
3286
+ * - data-top: the page header is the screen's top bar, at the very top of a phone's screen. It
3287
+ * adds env(safe-area-inset-top) above its height, which pads it below the status bar and the
3288
+ * notch on a phone that draws under them (viewport-fit=cover). Leave it off under an app bar.
3289
+ *
3290
+ * Its height is the page-header.height component token (56px). The inline padding clears the
3291
+ * notch on a phone held sideways. It is sticky on the layer.sticky z-index, so it sits above the
3292
+ * page and below sheets and menus (the top layer), and it sticks --plinth-page-header-offset from
3293
+ * the top: 0 by default, or the app bar's height in a shell that has one. Content scrolls under
3294
+ * it, so the app sets scroll-padding-block-start on its scrolling element (the offset plus the
3295
+ * height, plus env(safe-area-inset-top) with data-top) to keep a focused element or an anchor's
3296
+ * target from landing under the page header.
3297
+ *
3298
+ * The actions sit var(--plinth-space-2) apart, so on a touch screen the 40px hit areas of 32px
3299
+ * icon buttons don't overlap.
3300
+ *
3301
+ * The app owns the state: the page header has no states of its own. What the actions do is the
3302
+ * app's (a link, a commandfor, wire:click, @click on the icon button).
3303
+ */
3304
+
3305
+ @layer plinth {
3306
+ .plinth-page-header {
3307
+ position: sticky;
3308
+ inset-block-start: var(--plinth-page-header-offset);
3309
+ z-index: var(--plinth-layer-sticky);
3310
+ box-sizing: border-box;
3311
+ display: flex;
3312
+ gap: var(--plinth-space-2);
3313
+ align-items: center;
3314
+ min-block-size: var(--plinth-page-header-height);
3315
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-left))
3316
+ max(var(--plinth-space-2), env(safe-area-inset-right));
3317
+ font-family: var(--plinth-font-family-body);
3318
+ color: var(--plinth-color-text);
3319
+ background-color: var(--plinth-color-surface);
3320
+ border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
3321
+ }
3322
+
3323
+ .plinth-page-header:dir(rtl) {
3324
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-right))
3325
+ max(var(--plinth-space-2), env(safe-area-inset-left));
3326
+ }
3327
+
3328
+ /* The screen's top bar clears the status bar and the notch. */
3329
+ .plinth-page-header[data-top] {
3330
+ min-block-size: calc(var(--plinth-page-header-height) + env(safe-area-inset-top));
3331
+ padding-block-start: env(safe-area-inset-top);
3332
+ }
3333
+
3334
+ .plinth-page-header-leading,
3335
+ .plinth-page-header-actions {
3336
+ display: flex;
3337
+ flex: none;
3338
+ gap: var(--plinth-space-2);
3339
+ align-items: center;
3340
+ }
3341
+
3342
+ .plinth-page-header-heading {
3343
+ flex: 1 1 auto;
3344
+ min-inline-size: 0;
3345
+ margin: 0;
3346
+ overflow: hidden;
3347
+ text-overflow: ellipsis;
3348
+ font-size: var(--plinth-font-size-lg);
3349
+ font-weight: var(--plinth-font-weight-semibold);
3350
+ line-height: var(--plinth-font-leading-tight);
3351
+ letter-spacing: var(--plinth-font-tracking-tight);
3352
+ white-space: nowrap;
3353
+ }
3354
+
3355
+ /* Without a leading action or trailing actions, the heading keeps the text's distance from the edge. */
3356
+ .plinth-page-header-heading:first-child {
3357
+ margin-inline-start: var(--plinth-space-2);
3358
+ }
3359
+
3360
+ .plinth-page-header-heading:last-child {
3361
+ margin-inline-end: var(--plinth-space-2);
3362
+ }
3363
+
3364
+ @media (min-width: 768px) {
3365
+ .plinth-page-header {
3366
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-left))
3367
+ max(var(--plinth-space-4), env(safe-area-inset-right));
3368
+ }
3369
+
3370
+ .plinth-page-header:dir(rtl) {
3371
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-right))
3372
+ max(var(--plinth-space-4), env(safe-area-inset-left));
3373
+ }
3374
+ }
3375
+ }
3376
+
3377
+ /* pill-row.css */
3378
+ /*
3379
+ * Pill row: a row of pills for filters, segments or sub-pages.
3380
+ *
3381
+ * <div class="plinth-pill-row" role="group" aria-label="Status">
3382
+ * <button type="button" class="plinth-pill" aria-pressed="true">
3383
+ * <span class="plinth-pill-label">Open</span>
3384
+ * <span class="plinth-pill-count">12</span>
3385
+ * </button>
3386
+ * <button type="button" class="plinth-pill" aria-pressed="false">
3387
+ * <span class="plinth-pill-label">Closed</span>
3388
+ * </button>
3389
+ * </div>
3390
+ *
3391
+ * The root is a <div role="group">, named by aria-label (label in Blade and Vue) or by the app's
3392
+ * own aria-labelledby. A row of links that navigates is <nav class="plinth-pill-row"> instead,
3393
+ * with no role (nav in Blade and Vue), and its pills are links:
3394
+ *
3395
+ * <a class="plinth-pill" href="…" aria-current="page"><span class="plinth-pill-label">…</span></a>
3396
+ *
3397
+ * Each pill is its own component (<x-plinth::pill>, PlinthPill), so the app's attributes and
3398
+ * listeners land on the pill (ADR 2). The count is optional. plinth-pill-label has no rule of its
3399
+ * own: it is a hook for a project's styles.
3400
+ *
3401
+ * States, set by the app:
3402
+ * - selected: a button pill always prints aria-pressed, "true" or "false"; a link pill prints
3403
+ * aria-current="page" for the page being shown. Selected takes the selection tint
3404
+ * (accent-subtle, accent-text) inside a 2px accent edge (border-width.thick), where the others
3405
+ * have a 1px neutral one, so colour is not its only cue. A pressed button still answers hover
3406
+ * and press with a step on the tint (accent-subtle-hover, accent-subtle-active).
3407
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
3408
+ * - hover only where the device can hover; the focus ring on :focus-visible.
3409
+ *
3410
+ * Layout: on a phone the row scrolls sideways, with no visible scrollbar, and snaps to the pills;
3411
+ * from 768px it wraps. The row's padding keeps the focus ring and the 40px touch target of the
3412
+ * pills inside the scrolling area, whatever --plinth-pill-height is (32px by default). A pill is
3413
+ * at least 40px wide.
3414
+ */
3415
+
3416
+ @layer plinth {
3417
+ .plinth-pill-row {
3418
+ box-sizing: border-box;
3419
+ display: flex;
3420
+ gap: var(--plinth-space-2);
3421
+ align-items: center;
3422
+ max-inline-size: 100%;
3423
+ padding-block: max(
3424
+ var(--plinth-space-1),
3425
+ (var(--plinth-touch-target) - var(--plinth-pill-height)) / 2
3426
+ );
3427
+ padding-inline: var(--plinth-space-1);
3428
+ margin: 0;
3429
+ overflow-x: auto;
3430
+ overscroll-behavior-inline: contain;
3431
+ scroll-snap-type: inline proximity;
3432
+ scroll-padding-inline: var(--plinth-space-1);
3433
+ scrollbar-width: none;
3434
+ }
3435
+
3436
+ .plinth-pill {
3437
+ position: relative;
3438
+ box-sizing: border-box;
3439
+ display: inline-flex;
3440
+ flex: none;
3441
+ gap: var(--plinth-space-2);
3442
+ align-items: center;
3443
+ justify-content: center;
3444
+ min-inline-size: var(--plinth-touch-target);
3445
+ min-block-size: var(--plinth-pill-height);
3446
+ padding-block: 0;
3447
+ padding-inline: var(--plinth-space-3);
3448
+ margin: 0;
3449
+ font-family: var(--plinth-font-family-body);
3450
+ font-size: var(--plinth-font-size-sm);
3451
+ font-weight: var(--plinth-font-weight-medium);
3452
+ line-height: var(--plinth-font-leading-tight);
3453
+ color: var(--plinth-color-text);
3454
+ white-space: nowrap;
3455
+ text-decoration: none;
3456
+ cursor: pointer;
3457
+ user-select: none;
3458
+ scroll-snap-align: start;
3459
+ background-color: transparent;
3460
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
3461
+ border-radius: var(--plinth-radius-pill);
3462
+ transition:
3463
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard),
3464
+ border-color var(--plinth-duration-fast) var(--plinth-easing-standard),
3465
+ box-shadow var(--plinth-duration-fast) var(--plinth-easing-standard),
3466
+ color var(--plinth-duration-fast) var(--plinth-easing-standard);
3467
+ }
3468
+
3469
+ /* A 40px touch target through an invisible hit area, as on the button (ADR 3). */
3470
+ @media (pointer: coarse) {
3471
+ .plinth-pill::before {
3472
+ position: absolute;
3473
+ inset-block-start: 50%;
3474
+ inset-inline: 0;
3475
+ block-size: max(100%, var(--plinth-touch-target));
3476
+ content: '';
3477
+ translate: 0 -50%;
3478
+ }
3479
+ }
3480
+
3481
+ @media (hover: hover) {
3482
+ a.plinth-pill[href]:not([aria-current='page']):hover,
3483
+ button.plinth-pill:enabled:not([aria-pressed='true']):hover {
3484
+ background-color: var(--plinth-color-surface-hover);
3485
+ }
3486
+ }
3487
+
3488
+ a.plinth-pill[href]:not([aria-current='page']):active,
3489
+ button.plinth-pill:enabled:not([aria-pressed='true']):active {
3490
+ background-color: var(--plinth-color-surface-sunken);
3491
+ }
3492
+
3493
+ /* The border and an inset ring make the 2px edge, so selecting doesn't move the label. */
3494
+ .plinth-pill:is([aria-pressed='true'], [aria-current='page']) {
3495
+ color: var(--plinth-color-accent-text);
3496
+ background-color: var(--plinth-color-accent-subtle);
3497
+ border-color: var(--plinth-color-accent);
3498
+ box-shadow: inset 0 0 0 calc(var(--plinth-border-width-thick) - var(--plinth-border-width-thin))
3499
+ var(--plinth-color-accent);
3500
+ }
3501
+
3502
+ /*
3503
+ * A pressed button can be pressed again, so it answers hover and press with a step on the tint.
3504
+ * Not in forced colours, where the tint would replace the system highlight under the label.
3505
+ */
3506
+ @media (hover: hover) and (forced-colors: none) {
3507
+ button.plinth-pill:enabled[aria-pressed='true']:hover {
3508
+ background-color: var(--plinth-color-accent-subtle-hover);
3509
+ }
3510
+ }
3511
+
3512
+ @media (forced-colors: none) {
3513
+ button.plinth-pill:enabled[aria-pressed='true']:active {
3514
+ background-color: var(--plinth-color-accent-subtle-active);
3515
+ }
3516
+ }
3517
+
3518
+ .plinth-pill:disabled,
3519
+ .plinth-pill[aria-disabled='true'] {
3520
+ cursor: not-allowed;
3521
+ opacity: var(--plinth-opacity-disabled);
3522
+ }
3523
+
3524
+ .plinth-pill:focus-visible {
3525
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
3526
+ outline-offset: var(--plinth-focus-ring-offset);
3527
+ }
3528
+
3529
+ /* A gap between the ring and the accent edge, so focus and selection read apart. */
3530
+ .plinth-pill:is([aria-pressed='true'], [aria-current='page']):focus-visible {
3531
+ outline-offset: var(--plinth-focus-ring-offset-filled);
3532
+ }
3533
+
3534
+ .plinth-pill-count {
3535
+ font-weight: var(--plinth-font-weight-regular);
3536
+ font-variant-numeric: tabular-nums;
3537
+ color: var(--plinth-color-text-muted);
3538
+ }
3539
+
3540
+ .plinth-pill:is([aria-pressed='true'], [aria-current='page']) .plinth-pill-count {
3541
+ color: inherit;
3542
+ }
3543
+
3544
+ /* Wider screens have room: the pills wrap onto more lines instead of scrolling. */
3545
+ @media (min-width: 768px) {
3546
+ .plinth-pill-row {
3547
+ flex-wrap: wrap;
3548
+ overflow-x: visible;
3549
+ }
3550
+ }
3551
+
3552
+ @media (forced-colors: active) {
3553
+ .plinth-pill {
3554
+ border-color: ButtonText;
3555
+ }
3556
+
3557
+ .plinth-pill:is([aria-pressed='true'], [aria-current='page']) {
3558
+ color: HighlightText;
3559
+ background-color: Highlight;
3560
+ border-color: Highlight;
3561
+ }
3562
+
3563
+ .plinth-pill:is([aria-pressed='true'], [aria-current='page']) .plinth-pill-count {
3564
+ color: HighlightText;
3565
+ }
3566
+
3567
+ .plinth-pill:disabled,
3568
+ .plinth-pill[aria-disabled='true'] {
3569
+ color: GrayText;
3570
+ background-color: Canvas;
3571
+ border-color: GrayText;
3572
+ }
3573
+ }
3574
+ }
3575
+
3576
+ /* section-menu.css */
3577
+ /*
3578
+ * Section menu: the row under the page header that switches between the sections of a page.
3579
+ *
3580
+ * <nav class="plinth-section-menu" aria-label="Project">
3581
+ * <a class="plinth-section-menu-item" href="/overview" aria-current="page">
3582
+ * <span class="plinth-section-menu-label">Overview</span>
3583
+ * </a>
3584
+ * <a class="plinth-section-menu-item" href="/tasks">
3585
+ * <span class="plinth-section-menu-label">Tasks</span>
3586
+ * <span class="plinth-section-menu-count">8</span>
3587
+ * </a>
3588
+ * </nav>
3589
+ *
3590
+ * The root is a <nav>, named by aria-label (label in Blade and Vue) or by the app's own
3591
+ * aria-labelledby, so it reads apart from the page's other navigation. Its items are links. A
3592
+ * menu that switches views in place, without navigating, has buttons instead, and its root is
3593
+ * <div class="plinth-section-menu" role="group"> (nav set to false in Blade and Vue):
3594
+ *
3595
+ * <button type="button" class="plinth-section-menu-item" aria-pressed="true">…</button>
3596
+ *
3597
+ * Each item is its own component (<x-plinth::section-menu-item>, PlinthSectionMenuItem), so the
3598
+ * app's attributes and listeners land on the item (ADR 2). The count is optional.
3599
+ * plinth-section-menu-label has no rule of its own: it is a hook for a project's styles.
3600
+ *
3601
+ * States, set by the app:
3602
+ * - current: a link prints aria-current="page" for the section being shown; a button always
3603
+ * prints aria-pressed, "true" or "false". The current item takes the selection tint
3604
+ * (accent-subtle, accent-text) inside a 2px accent edge (border-width.thick), so colour is not
3605
+ * its only cue; the others are muted text on no surface, like a ghost button. A pressed button
3606
+ * still answers hover and press with a step on the tint (accent-subtle-hover,
3607
+ * accent-subtle-active).
3608
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
3609
+ * - hover only where the device can hover; the focus ring on :focus-visible.
3610
+ *
3611
+ * Layout: a row --plinth-section-menu-height (56px) tall, on the surface with a divider below,
3612
+ * the same inline padding as the page header and clear of the notch on a phone held sideways. Its
3613
+ * items are --plinth-section-menu-item-height (40px) tall, never less than the 40px touch
3614
+ * target, and at least 40px wide. The row never wraps: when the items don't fit it scrolls
3615
+ * sideways and snaps to them, with a thin scrollbar for a mouse and none on a touch screen; the
3616
+ * row's block padding keeps the focus ring inside it. The app scrolls a current item that starts
3617
+ * out of view into view.
3618
+ * Placement is the app's: put it under the page header, or make it sticky below it with
3619
+ * position: sticky, inset-block-start: calc(var(--plinth-page-header-offset) +
3620
+ * var(--plinth-page-header-height)), plus env(safe-area-inset-top) under the screen's top bar,
3621
+ * and z-index: var(--plinth-layer-sticky).
3622
+ */
3623
+
3624
+ @layer plinth {
3625
+ .plinth-section-menu {
3626
+ box-sizing: border-box;
3627
+ display: flex;
3628
+ gap: var(--plinth-space-1);
3629
+ align-items: center;
3630
+ min-block-size: var(--plinth-section-menu-height);
3631
+
3632
+ /* What the item and the divider leave of the row's height, so the row is exactly its token. */
3633
+ padding-block: calc(
3634
+ (
3635
+ var(--plinth-section-menu-height) - var(--plinth-section-menu-item-height) -
3636
+ var(--plinth-border-width-thin)
3637
+ ) /
3638
+ 2
3639
+ );
3640
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-left))
3641
+ max(var(--plinth-space-2), env(safe-area-inset-right));
3642
+ margin: 0;
3643
+ overflow-x: auto;
3644
+ overscroll-behavior-inline: contain;
3645
+ font-family: var(--plinth-font-family-body);
3646
+ scroll-snap-type: inline proximity;
3647
+ scroll-padding-inline: max(var(--plinth-space-2), env(safe-area-inset-left))
3648
+ max(var(--plinth-space-2), env(safe-area-inset-right));
3649
+ scrollbar-width: none;
3650
+ background-color: var(--plinth-color-surface);
3651
+ border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
3652
+ }
3653
+
3654
+ .plinth-section-menu:dir(rtl) {
3655
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-right))
3656
+ max(var(--plinth-space-2), env(safe-area-inset-left));
3657
+ scroll-padding-inline: max(var(--plinth-space-2), env(safe-area-inset-right))
3658
+ max(var(--plinth-space-2), env(safe-area-inset-left));
3659
+ }
3660
+
3661
+ /* A mouse without a sideways wheel needs a scrollbar to reach the items that don't fit. */
3662
+ @media (hover: hover) and (pointer: fine) {
3663
+ .plinth-section-menu {
3664
+ scrollbar-width: thin;
3665
+ }
3666
+ }
3667
+
3668
+ .plinth-section-menu-item {
3669
+ box-sizing: border-box;
3670
+ display: inline-flex;
3671
+ flex: none;
3672
+ gap: var(--plinth-space-2);
3673
+ align-items: center;
3674
+ justify-content: center;
3675
+ min-inline-size: var(--plinth-touch-target);
3676
+ min-block-size: max(var(--plinth-section-menu-item-height), var(--plinth-touch-target));
3677
+ padding-block: 0;
3678
+ padding-inline: var(--plinth-space-3);
3679
+ margin: 0;
3680
+ font-family: inherit;
3681
+ font-size: var(--plinth-font-size-md);
3682
+ font-weight: var(--plinth-font-weight-medium);
3683
+ line-height: var(--plinth-font-leading-tight);
3684
+ color: var(--plinth-color-text-muted);
3685
+ white-space: nowrap;
3686
+ text-decoration: none;
3687
+ cursor: pointer;
3688
+ user-select: none;
3689
+ scroll-snap-align: start;
3690
+ background-color: transparent;
3691
+ border: var(--plinth-border-width-thin) solid transparent;
3692
+ border-radius: var(--plinth-radius-control);
3693
+ transition:
3694
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard),
3695
+ border-color var(--plinth-duration-fast) var(--plinth-easing-standard),
3696
+ box-shadow var(--plinth-duration-fast) var(--plinth-easing-standard),
3697
+ color var(--plinth-duration-fast) var(--plinth-easing-standard);
3698
+ }
3699
+
3700
+ @media (hover: hover) {
3701
+ a.plinth-section-menu-item[href]:not([aria-current='page']):hover,
3702
+ button.plinth-section-menu-item:enabled:not([aria-pressed='true']):hover {
3703
+ color: var(--plinth-color-text);
3704
+ background-color: var(--plinth-color-surface-hover);
3705
+ }
3706
+ }
3707
+
3708
+ a.plinth-section-menu-item[href]:not([aria-current='page']):active,
3709
+ button.plinth-section-menu-item:enabled:not([aria-pressed='true']):active {
3710
+ color: var(--plinth-color-text);
3711
+ background-color: var(--plinth-color-surface-sunken);
3712
+ }
3713
+
3714
+ /* The border and an inset ring make the 2px edge, so selecting doesn't move the label. */
3715
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page']) {
3716
+ color: var(--plinth-color-accent-text);
3717
+ background-color: var(--plinth-color-accent-subtle);
3718
+ border-color: var(--plinth-color-accent);
3719
+ box-shadow: inset 0 0 0 calc(var(--plinth-border-width-thick) - var(--plinth-border-width-thin))
3720
+ var(--plinth-color-accent);
3721
+ }
3722
+
3723
+ /*
3724
+ * A pressed button can be pressed again, so it answers hover and press with a step on the tint.
3725
+ * Not in forced colours, where the tint would replace the system highlight under the label.
3726
+ */
3727
+ @media (hover: hover) and (forced-colors: none) {
3728
+ button.plinth-section-menu-item:enabled[aria-pressed='true']:hover {
3729
+ background-color: var(--plinth-color-accent-subtle-hover);
3730
+ }
3731
+ }
3732
+
3733
+ @media (forced-colors: none) {
3734
+ button.plinth-section-menu-item:enabled[aria-pressed='true']:active {
3735
+ background-color: var(--plinth-color-accent-subtle-active);
3736
+ }
3737
+ }
3738
+
3739
+ .plinth-section-menu-item:disabled,
3740
+ .plinth-section-menu-item[aria-disabled='true'] {
3741
+ cursor: not-allowed;
3742
+ opacity: var(--plinth-opacity-disabled);
3743
+ }
3744
+
3745
+ .plinth-section-menu-item:focus-visible {
3746
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
3747
+ outline-offset: var(--plinth-focus-ring-offset);
3748
+ }
3749
+
3750
+ /* A gap between the ring and the accent edge, so focus and selection read apart. */
3751
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page']):focus-visible {
3752
+ outline-offset: var(--plinth-focus-ring-offset-filled);
3753
+ }
3754
+
3755
+ .plinth-section-menu-count {
3756
+ font-family: var(--plinth-font-family-mono);
3757
+ font-size: var(--plinth-font-size-xs);
3758
+ font-weight: var(--plinth-font-weight-regular);
3759
+ font-variant-numeric: tabular-nums;
3760
+ color: var(--plinth-color-text-muted);
3761
+ }
3762
+
3763
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page'])
3764
+ .plinth-section-menu-count {
3765
+ color: inherit;
3766
+ }
3767
+
3768
+ @media (min-width: 768px) {
3769
+ .plinth-section-menu {
3770
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-left))
3771
+ max(var(--plinth-space-4), env(safe-area-inset-right));
3772
+ scroll-padding-inline: max(var(--plinth-space-4), env(safe-area-inset-left))
3773
+ max(var(--plinth-space-4), env(safe-area-inset-right));
3774
+ }
3775
+
3776
+ .plinth-section-menu:dir(rtl) {
3777
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-right))
3778
+ max(var(--plinth-space-4), env(safe-area-inset-left));
3779
+ scroll-padding-inline: max(var(--plinth-space-4), env(safe-area-inset-right))
3780
+ max(var(--plinth-space-4), env(safe-area-inset-left));
3781
+ }
3782
+ }
3783
+
3784
+ /* The current item is only a surface tint: in forced colours it takes the system highlight. */
3785
+ @media (forced-colors: active) {
3786
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page']) {
3787
+ color: HighlightText;
3788
+ background-color: Highlight;
3789
+ border-color: Highlight;
3790
+ }
3791
+
3792
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page'])
3793
+ .plinth-section-menu-count {
3794
+ color: HighlightText;
3795
+ }
3796
+
3797
+ .plinth-section-menu-item:disabled,
3798
+ .plinth-section-menu-item[aria-disabled='true'] {
3799
+ color: GrayText;
3800
+ }
3801
+ }
3802
+ }
3803
+
3804
+ /* sheet.css */
3805
+ /*
3806
+ * Sheet: a modal panel on the edge of the screen the developer picks, for filters, side
3807
+ * information or navigation. Optionally on another edge below 768px.
3808
+ *
3809
+ * <dialog id="filters" class="plinth-sheet" data-side="end" data-side-phone="bottom"
3810
+ * closedby="any" aria-labelledby="filters-title" aria-describedby="filters-description">
3811
+ * <div class="plinth-sheet-panel">
3812
+ * <div class="plinth-sheet-handle" aria-hidden="true"></div>
3813
+ * <div class="plinth-sheet-header">
3814
+ * <h2 id="filters-title" class="plinth-sheet-title">Filters</h2>
3815
+ * <p id="filters-description" class="plinth-sheet-description">Narrow the list down.</p>
3816
+ * <button type="button" class="plinth-button plinth-icon-button plinth-sheet-close"
3817
+ * data-variant="ghost" data-size="md" aria-label="Close" commandfor="filters"
3818
+ * command="close">
3819
+ * <span class="plinth-icon" data-size="md" aria-hidden="true"><svg>…</svg></span>
3820
+ * </button>
3821
+ * </div>
3822
+ * <div class="plinth-sheet-body">…</div>
3823
+ * <div class="plinth-sheet-footer">…</div>
3824
+ * </div>
3825
+ * </dialog>
3826
+ *
3827
+ * The root is the <dialog>, and it needs an id: commandfor and aria-labelledby point at it, and
3828
+ * the title's and description's ids are the root's id with -title and -description. The panel is
3829
+ * the root's only child and holds the parts. The handle is there only where the sheet's side on a
3830
+ * phone is the bottom (data-side="bottom" with no data-side-phone, or data-side-phone="bottom"):
3831
+ * decorative (aria-hidden), shown below 768px, where the sheet is dragged down to close
3832
+ * (modal.css). The title is
3833
+ * required; the description (and its aria-describedby) and the footer are optional. The close
3834
+ * button is an icon button (button.css and icon.css) and is always there: closedby="any" (tap the
3835
+ * backdrop) is an enhancement Safari doesn't have yet. A project may set closedby="closerequest"
3836
+ * on a sheet that must not close on a stray tap.
3837
+ *
3838
+ * Open it with showModal() or a <button commandfor="filters" command="show-modal">. Open is the
3839
+ * native state, [open]; the browser puts the sheet in the top layer, makes the page inert, traps
3840
+ * focus, closes on Escape and returns focus. The dialog fires close when it closes. For a modal
3841
+ * centred on the screen, use the dialog (dialog.css).
3842
+ *
3843
+ * Side (data-side), at every width: end (default, also when missing or unknown) and start are
3844
+ * full-height panels on the inline-end and inline-start edges, sheet.side-width wide and at
3845
+ * most the screen's width less a gap, so the page shows beside them; top and bottom are the
3846
+ * screen's width on the top and bottom edges, as tall as their content up to the screen's height
3847
+ * less a gap that also clears the safe area at the other edge. The sides are logical: in
3848
+ * right-to-left text end is the left edge and start the right.
3849
+ *
3850
+ * Side on a phone (data-side-phone, optional): the side below 768px, one of the same four; from
3851
+ * 768px up data-side holds. data-side="end" data-side-phone="bottom" is a side panel on a tablet
3852
+ * or desktop and a bottom sheet on a phone. Leave it out to keep data-side at every width. It
3853
+ * takes only those four values: any other makes the phone's side end (the layers never print
3854
+ * one, and leave the attribute out instead).
3855
+ *
3856
+ * The edge the sheet sits on has no border or radius; the edge facing the page has a hairline
3857
+ * (in forced colours too), and a top or bottom sheet rounds the corners facing the page. Each
3858
+ * side clears the safe areas it touches (the notch, the status bar and the home indicator, with
3859
+ * viewport-fit=cover).
3860
+ *
3861
+ * The description is a secondary line, at the size of a list row's description.
3862
+ *
3863
+ * The surface, the backdrop, the scroll lock, the motion, the safe areas and the parts come from
3864
+ * the modal base (modal.css), which the dialog shares; load it with this file. The body scrolls,
3865
+ * the header and footer stay put, and the page under an open sheet doesn't scroll. The sheet
3866
+ * slides in from its edge and back out on the motion tokens, which drop to 0ms under
3867
+ * prefers-reduced-motion. This file sets only where the sheet sits and how big it is.
3868
+ *
3869
+ * A sheet on the bottom edge has its height capped by 100dvh, which clears the home indicator
3870
+ * (env(safe-area-inset-bottom)) but not, on its own, the on-screen keyboard: iOS lays the
3871
+ * keyboard over the page. A page with a form in a sheet sets
3872
+ * <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
3873
+ * where the browser supports it, and keeps the sheet's fields near its top.
3874
+ *
3875
+ * In Livewire, a re-render would morph the open dialog back to the server's markup and drop its
3876
+ * open attribute: put wire:ignore.self on the sheet (or keep it outside the component that
3877
+ * re-renders), and always pass an id, since a generated one changes on every render.
3878
+ */
3879
+
3880
+ @layer plinth {
3881
+ /*
3882
+ * The end side, the default. The sides are written twice: as the phone's side (data-side-phone,
3883
+ * or data-side without it), and from 768px up as data-side again for a sheet with
3884
+ * data-side-phone. The phone's rules use :where() so the later ones win on order alone.
3885
+ */
3886
+ .plinth-sheet {
3887
+ /* Translate isn't logical: -1 (under :dir(rtl), below) turns the slide around. */
3888
+ --plinth-sheet-direction: 1;
3889
+ --plinth-sheet-safe-area-inline-start: env(safe-area-inset-left);
3890
+ --plinth-sheet-safe-area-inline-end: env(safe-area-inset-right);
3891
+ --plinth-modal-offset: calc(100% * var(--plinth-sheet-direction)) 0;
3892
+ --plinth-modal-safe-area-end: env(safe-area-inset-bottom);
3893
+
3894
+ inset-block: 0;
3895
+ inset-inline: auto 0;
3896
+ inline-size: min(100% - var(--plinth-space-12), var(--plinth-sheet-side-width));
3897
+ block-size: auto;
3898
+ max-block-size: none;
3899
+ padding-block: env(safe-area-inset-top) 0;
3900
+ padding-inline: 0 var(--plinth-sheet-safe-area-inline-end);
3901
+ margin: 0;
3902
+ border-width: 0;
3903
+ border-inline-start-width: var(--plinth-border-width-thin);
3904
+ border-radius: 0;
3905
+ }
3906
+
3907
+ .plinth-sheet:where([data-side='start']:not([data-side-phone]), [data-side-phone='start']) {
3908
+ --plinth-modal-offset: calc(-100% * var(--plinth-sheet-direction)) 0;
3909
+
3910
+ inset-block: 0;
3911
+ inset-inline: 0 auto;
3912
+ inline-size: min(100% - var(--plinth-space-12), var(--plinth-sheet-side-width));
3913
+ max-block-size: none;
3914
+ padding-block: env(safe-area-inset-top) 0;
3915
+ padding-inline: var(--plinth-sheet-safe-area-inline-start) 0;
3916
+ border-width: 0;
3917
+ border-inline-end-width: var(--plinth-border-width-thin);
3918
+ border-radius: 0;
3919
+ }
3920
+
3921
+ .plinth-sheet:where([data-side='top']:not([data-side-phone]), [data-side-phone='top']) {
3922
+ --plinth-modal-offset: 0 -100%;
3923
+ --plinth-modal-safe-area-end: initial;
3924
+
3925
+ inset-block: 0 auto;
3926
+ inset-inline: 0;
3927
+ inline-size: 100%;
3928
+ max-block-size: calc(
3929
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-bottom) + var(--plinth-space-4))
3930
+ );
3931
+ padding-block: env(safe-area-inset-top) 0;
3932
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
3933
+ var(--plinth-sheet-safe-area-inline-end);
3934
+ border-width: 0;
3935
+ border-block-end-width: var(--plinth-border-width-thin);
3936
+ border-radius: 0;
3937
+ border-end-start-radius: var(--plinth-radius-container);
3938
+ border-end-end-radius: var(--plinth-radius-container);
3939
+ }
3940
+
3941
+ .plinth-sheet:where([data-side='bottom']:not([data-side-phone]), [data-side-phone='bottom']) {
3942
+ --plinth-modal-offset: 0 100%;
3943
+
3944
+ inset-block: auto 0;
3945
+ inset-inline: 0;
3946
+ inline-size: 100%;
3947
+ max-block-size: calc(
3948
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-top) + var(--plinth-space-4))
3949
+ );
3950
+ padding-block: 0;
3951
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
3952
+ var(--plinth-sheet-safe-area-inline-end);
3953
+ border-width: 0;
3954
+ border-block-start-width: var(--plinth-border-width-thin);
3955
+ border-radius: 0;
3956
+ border-start-start-radius: var(--plinth-radius-container);
3957
+ border-start-end-radius: var(--plinth-radius-container);
3958
+ }
3959
+
3960
+ /*
3961
+ * With its grab handle, a bottom sheet on a phone is dragged down to close (modal.css): the root
3962
+ * fills the screen, see-through, and its panel is the surface, as tall as its content up to the
3963
+ * screen's height less a gap.
3964
+ */
3965
+ .plinth-sheet:where([data-side='bottom']:not([data-side-phone]), [data-side-phone='bottom']):has(
3966
+ > * > .plinth-sheet-handle
3967
+ ) {
3968
+ inset: 0;
3969
+ max-block-size: none;
3970
+ padding: 0;
3971
+ border-width: 0;
3972
+ border-radius: 0;
3973
+ }
3974
+
3975
+ /* Right-to-left text turns the slide around and swaps the safe areas. */
3976
+ .plinth-sheet:dir(rtl) {
3977
+ --plinth-sheet-direction: -1;
3978
+ --plinth-sheet-safe-area-inline-start: env(safe-area-inset-right);
3979
+ --plinth-sheet-safe-area-inline-end: env(safe-area-inset-left);
3980
+ }
3981
+
3982
+ @media (min-width: 768px) {
3983
+ .plinth-sheet[data-side-phone] {
3984
+ --plinth-modal-offset: calc(100% * var(--plinth-sheet-direction)) 0;
3985
+ --plinth-modal-safe-area-end: env(safe-area-inset-bottom);
3986
+
3987
+ inset-block: 0;
3988
+ inset-inline: auto 0;
3989
+ inline-size: min(100% - var(--plinth-space-12), var(--plinth-sheet-side-width));
3990
+ max-block-size: none;
3991
+ padding-block: env(safe-area-inset-top) 0;
3992
+ padding-inline: 0 var(--plinth-sheet-safe-area-inline-end);
3993
+ border-width: 0;
3994
+ border-inline-start-width: var(--plinth-border-width-thin);
3995
+ border-radius: 0;
3996
+ }
3997
+
3998
+ .plinth-sheet[data-side-phone][data-side='start'] {
3999
+ --plinth-modal-offset: calc(-100% * var(--plinth-sheet-direction)) 0;
4000
+
4001
+ inset-inline: 0 auto;
4002
+ padding-inline: var(--plinth-sheet-safe-area-inline-start) 0;
4003
+ border-inline-start-width: 0;
4004
+ border-inline-end-width: var(--plinth-border-width-thin);
4005
+ }
4006
+
4007
+ .plinth-sheet[data-side-phone][data-side='top'] {
4008
+ --plinth-modal-offset: 0 -100%;
4009
+ --plinth-modal-safe-area-end: initial;
4010
+
4011
+ inset-block: 0 auto;
4012
+ inset-inline: 0;
4013
+ inline-size: 100%;
4014
+ max-block-size: calc(
4015
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-bottom) + var(--plinth-space-4))
4016
+ );
4017
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
4018
+ var(--plinth-sheet-safe-area-inline-end);
4019
+ border-block-end-width: var(--plinth-border-width-thin);
4020
+ border-inline-start-width: 0;
4021
+ border-end-start-radius: var(--plinth-radius-container);
4022
+ border-end-end-radius: var(--plinth-radius-container);
4023
+ }
4024
+
4025
+ .plinth-sheet[data-side='bottom'] {
4026
+ --plinth-modal-offset: 0 100%;
4027
+
4028
+ inset-block: auto 0;
4029
+ inset-inline: 0;
4030
+ inline-size: 100%;
4031
+ max-block-size: calc(
4032
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-top) + var(--plinth-space-4))
4033
+ );
4034
+ padding-block: 0;
4035
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
4036
+ var(--plinth-sheet-safe-area-inline-end);
4037
+ border-block-start-width: var(--plinth-border-width-thin);
4038
+ border-inline-start-width: 0;
4039
+ border-start-start-radius: var(--plinth-radius-container);
4040
+ border-start-end-radius: var(--plinth-radius-container);
4041
+ }
4042
+ }
4043
+ }
4044
+
4045
+ /* text-field.css */
4046
+ /*
4047
+ * Text field: one line of text, with its label, an optional description and an error.
4048
+ *
4049
+ * <div class="plinth-text-field" data-size="md">
4050
+ * <label class="plinth-text-field-label" for="email">Email</label>
4051
+ * <div class="plinth-text-field-control">
4052
+ * <span class="plinth-icon" data-size="sm" aria-hidden="true"><svg>…</svg></span>
4053
+ * <input type="email" class="plinth-text-field-input" id="email"
4054
+ * aria-describedby="email-description email-error" aria-invalid="true">
4055
+ * <span class="plinth-text-field-trailing">…</span>
4056
+ * </div>
4057
+ * <p class="plinth-text-field-description" id="email-description">For receipts only.</p>
4058
+ * <p class="plinth-text-field-error" id="email-error">Enter an email address.</p>
4059
+ * </div>
4060
+ *
4061
+ * The input needs an id: the label points at it with for, and the description's and error's ids
4062
+ * are the input's id with -description and -error. The icon, the trailing slot, the description
4063
+ * and the error are optional; aria-describedby lists the description, then the error, and is left
4064
+ * out when there is neither.
4065
+ *
4066
+ * Attributes go to the input, not the root (name, value, placeholder, autocomplete, required,
4067
+ * wire:model, x-model, data-*, aria-*), so the app talks to the input; only a project's class is
4068
+ * added to the root's.
4069
+ *
4070
+ * - type (on the input): text by default; email, url, tel, password, number or search. A search
4071
+ * field takes a search icon and, in the trailing slot, a clear button (a small ghost icon
4072
+ * button) that the app shows when there is text and wires up. With a trailing slot, the
4073
+ * browser's own clear button is hidden, so there is only one.
4074
+ * - the leading icon is 16px and decorative (aria-hidden); the label names the field.
4075
+ * - the trailing slot holds one small control or hint, in the room the field keeps at its end
4076
+ * (--plinth-text-field-trailing-size, 32px): a small icon button, or a keyboard hint as <kbd>.
4077
+ * - data-label="hidden" on the root hides the label visually; screen readers still read it. Use it
4078
+ * only where the field's purpose is plain without it, such as a search box with its icon.
4079
+ *
4080
+ * Sizes (data-size): md 32px (default, also when missing) and sm 28px. On a touch screen both are
4081
+ * 40px tall. The text is 16px on a phone, so iOS doesn't zoom into the field, and from 768px
4082
+ * up it is 14px (md) or 13px (sm).
4083
+ *
4084
+ * States:
4085
+ * - error: aria-invalid="true" on the input and the error message under it; the edge turns
4086
+ * danger and thick, so it isn't only a colour, and the message says what to fix.
4087
+ * - disabled: `disabled` on the input; the field is faded and can't be focused.
4088
+ * - read-only: `readonly` on the input; it keeps its text and focus, without the field's fill
4089
+ * and with a dashed edge.
4090
+ * - the focus ring on the input's :focus-visible.
4091
+ */
4092
+
4093
+ @layer plinth {
4094
+ .plinth-text-field {
4095
+ --plinth-text-field-height: var(--plinth-control-md);
4096
+ --plinth-text-field-text: var(--plinth-font-size-md);
4097
+
4098
+ display: grid;
4099
+ gap: var(--plinth-space-2);
4100
+ min-inline-size: 0;
4101
+ font-family: var(--plinth-font-family-body);
4102
+ }
4103
+
4104
+ .plinth-text-field[data-size='sm'] {
4105
+ --plinth-text-field-height: var(--plinth-control-sm);
4106
+ --plinth-text-field-text: var(--plinth-font-size-sm);
4107
+ }
4108
+
4109
+ .plinth-text-field-label {
4110
+ font-size: var(--plinth-font-size-sm);
4111
+ font-weight: var(--plinth-font-weight-medium);
4112
+ line-height: var(--plinth-font-leading-normal);
4113
+ color: var(--plinth-color-text-muted);
4114
+ overflow-wrap: anywhere;
4115
+ }
4116
+
4117
+ /* Hidden from sight, still read by screen readers. */
4118
+ .plinth-text-field[data-label='hidden'] .plinth-text-field-label {
4119
+ position: absolute;
4120
+ inline-size: 1px;
4121
+ block-size: 1px;
4122
+ overflow: hidden;
4123
+ white-space: nowrap;
4124
+ clip-path: inset(50%);
4125
+ }
4126
+
4127
+ .plinth-text-field-control {
4128
+ position: relative;
4129
+ display: flex;
4130
+ align-items: center;
4131
+ min-inline-size: 0;
4132
+ }
4133
+
4134
+ .plinth-text-field-input {
4135
+ box-sizing: border-box;
4136
+ flex: 1;
4137
+ min-inline-size: 0;
4138
+ min-block-size: var(--plinth-text-field-height);
4139
+ padding-block: 0;
4140
+ padding-inline: var(--plinth-space-3);
4141
+ margin: 0;
4142
+ font-family: inherit;
4143
+ font-size: var(--plinth-font-size-field);
4144
+ line-height: var(--plinth-font-leading-tight);
4145
+ color: var(--plinth-color-text);
4146
+ appearance: none;
4147
+ background-color: var(--plinth-color-surface-sunken);
4148
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
4149
+ border-radius: var(--plinth-radius-control);
4150
+ transition: border-color var(--plinth-duration-fast) var(--plinth-easing-standard);
4151
+ }
4152
+
4153
+ .plinth-text-field-input::placeholder {
4154
+ color: var(--plinth-color-text-subtle);
4155
+ opacity: 1;
4156
+ }
4157
+
4158
+ @media (hover: hover) {
4159
+ .plinth-text-field-input:hover:not(:disabled, :read-only, [aria-invalid='true']) {
4160
+ border-color: var(--plinth-color-text-subtle);
4161
+ }
4162
+ }
4163
+
4164
+ .plinth-text-field-input:focus-visible {
4165
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
4166
+ outline-offset: var(--plinth-focus-ring-offset);
4167
+ }
4168
+
4169
+ .plinth-text-field-input[aria-invalid='true'] {
4170
+ border-color: var(--plinth-color-danger);
4171
+ border-width: var(--plinth-border-width-thick);
4172
+ }
4173
+
4174
+ .plinth-text-field-input:disabled {
4175
+ cursor: not-allowed;
4176
+ }
4177
+
4178
+ /* Without the fill, and with a dashed edge, so it doesn't read as a field to type in. */
4179
+ .plinth-text-field-input:read-only:not(:disabled) {
4180
+ background-color: transparent;
4181
+ border-style: dashed;
4182
+ }
4183
+
4184
+ .plinth-text-field-control:has(.plinth-text-field-input:disabled) {
4185
+ opacity: var(--plinth-opacity-disabled);
4186
+ }
4187
+
4188
+ /* The leading icon sits over the input's start, which keeps room for it. */
4189
+ .plinth-text-field-control > .plinth-icon {
4190
+ position: absolute;
4191
+ inset-inline-start: var(--plinth-space-3);
4192
+ color: var(--plinth-color-text-subtle);
4193
+ pointer-events: none;
4194
+ }
4195
+
4196
+ .plinth-text-field-control:has(> .plinth-icon) > .plinth-text-field-input {
4197
+ padding-inline-start: calc(
4198
+ var(--plinth-space-3) + var(--plinth-size-icon-sm) + var(--plinth-space-2)
4199
+ );
4200
+ }
4201
+
4202
+ .plinth-text-field-trailing {
4203
+ position: absolute;
4204
+ inset-inline-end: var(--plinth-space-1);
4205
+ display: flex;
4206
+ gap: var(--plinth-space-1);
4207
+ align-items: center;
4208
+ justify-content: center;
4209
+ min-inline-size: var(--plinth-text-field-trailing-size);
4210
+ color: var(--plinth-color-text-subtle);
4211
+ }
4212
+
4213
+ .plinth-text-field-control:has(> .plinth-text-field-trailing) > .plinth-text-field-input {
4214
+ padding-inline-end: calc(var(--plinth-space-1) + var(--plinth-text-field-trailing-size));
4215
+ }
4216
+
4217
+ /* With its own clear button in the trailing slot, the browser's is one too many. */
4218
+ .plinth-text-field-control:has(> .plinth-text-field-trailing)
4219
+ > .plinth-text-field-input::-webkit-search-cancel-button {
4220
+ display: none;
4221
+ }
4222
+
4223
+ .plinth-text-field-trailing kbd {
4224
+ box-sizing: border-box;
4225
+ min-inline-size: var(--plinth-size-icon-md);
4226
+ padding-block: 0;
4227
+ padding-inline: var(--plinth-space-1);
4228
+ font-family: var(--plinth-font-family-mono);
4229
+ font-size: var(--plinth-font-size-xs);
4230
+ line-height: var(--plinth-font-leading-normal);
4231
+ color: var(--plinth-color-text-muted);
4232
+ text-align: center;
4233
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
4234
+ border-radius: var(--plinth-radius-control);
4235
+ }
4236
+
4237
+ .plinth-text-field-description,
4238
+ .plinth-text-field-error {
4239
+ margin: 0;
4240
+ font-size: var(--plinth-font-size-sm);
4241
+ line-height: var(--plinth-font-leading-normal);
4242
+ overflow-wrap: anywhere;
4243
+ }
4244
+
4245
+ .plinth-text-field-description {
4246
+ color: var(--plinth-color-text-muted);
4247
+ }
4248
+
4249
+ .plinth-text-field-error {
4250
+ color: var(--plinth-color-danger-text);
4251
+ }
4252
+
4253
+ @media (pointer: coarse) {
4254
+ .plinth-text-field-input {
4255
+ min-block-size: var(--plinth-touch-target);
4256
+ }
4257
+ }
4258
+
4259
+ @media (min-width: 768px) {
4260
+ .plinth-text-field-input {
4261
+ font-size: var(--plinth-text-field-text);
4262
+ }
4263
+ }
4264
+
4265
+ @media (forced-colors: active) {
4266
+ .plinth-text-field-control:has(.plinth-text-field-input:disabled) {
4267
+ color: GrayText;
4268
+ }
4269
+
4270
+ .plinth-text-field-input:disabled {
4271
+ color: GrayText;
4272
+ border-color: GrayText;
4273
+ }
4274
+ }
4275
+ }
4276
+
4277
+ /* textarea.css */
4278
+ /*
4279
+ * Textarea: several lines of text, with its label, an optional description and an error. With
4280
+ * grow, it grows with its content.
4281
+ *
4282
+ * <div class="plinth-textarea">
4283
+ * <label class="plinth-textarea-label" for="reply">Reply</label>
4284
+ * <textarea class="plinth-textarea-input" id="reply" rows="3"
4285
+ * aria-describedby="reply-description reply-error" aria-invalid="true"></textarea>
4286
+ * <p class="plinth-textarea-description" id="reply-description">The customer sees it.</p>
4287
+ * <p class="plinth-textarea-error" id="reply-error">Write a reply first.</p>
4288
+ * </div>
4289
+ *
4290
+ * The textarea needs an id: the label points at it with for, and the description's and error's
4291
+ * ids are the textarea's id with -description and -error. The description and the error are
4292
+ * optional; aria-describedby lists the description, then the error, and is left out when there is
4293
+ * neither. rows is 3 by default.
4294
+ *
4295
+ * Attributes go to the textarea, not the root (name, placeholder, maxlength, required,
4296
+ * wire:model, x-model, data-*, aria-*), so the app talks to the textarea; only a project's class
4297
+ * is added to the root's.
4298
+ *
4299
+ * - grow: data-grow on the textarea, with its rows and maximum as custom properties:
4300
+ * <textarea class="plinth-textarea-input" id="reply" rows="3" data-grow
4301
+ * style="--plinth-textarea-rows: 3; --plinth-textarea-max-rows: 8"></textarea>
4302
+ * It starts at rows lines and grows with its content (field-sizing: content) up to max-rows
4303
+ * (8 by default), then scrolls. Where the browser has no field-sizing it keeps rows lines and
4304
+ * the person can resize it, as without grow.
4305
+ * - data-label="hidden" on the root hides the label visually; screen readers still read it. Use it
4306
+ * only where the field's purpose is plain without it, such as a reply box under a thread.
4307
+ *
4308
+ * The text is 16px on a phone, so iOS doesn't zoom into the field, and 14px from 768px up.
4309
+ *
4310
+ * States:
4311
+ * - error: aria-invalid="true" on the textarea and the error message under it; the edge turns
4312
+ * danger and thick, so it isn't only a colour, and the message says what to fix.
4313
+ * - disabled: `disabled` on the textarea; the field is faded and can't be focused.
4314
+ * - read-only: `readonly` on the textarea; it keeps its text and focus, without the field's fill
4315
+ * and with a dashed edge.
4316
+ * - the focus ring on the textarea's :focus-visible.
4317
+ */
4318
+
4319
+ @layer plinth {
4320
+ .plinth-textarea {
4321
+ display: grid;
4322
+ gap: var(--plinth-space-2);
4323
+ min-inline-size: 0;
4324
+ font-family: var(--plinth-font-family-body);
4325
+ }
4326
+
4327
+ .plinth-textarea-label {
4328
+ font-size: var(--plinth-font-size-sm);
4329
+ font-weight: var(--plinth-font-weight-medium);
4330
+ line-height: var(--plinth-font-leading-normal);
4331
+ color: var(--plinth-color-text-muted);
4332
+ overflow-wrap: anywhere;
4333
+ }
4334
+
4335
+ /* Hidden from sight, still read by screen readers. */
4336
+ .plinth-textarea[data-label='hidden'] .plinth-textarea-label {
4337
+ position: absolute;
4338
+ inline-size: 1px;
4339
+ block-size: 1px;
4340
+ overflow: hidden;
4341
+ white-space: nowrap;
4342
+ clip-path: inset(50%);
4343
+ }
4344
+
4345
+ .plinth-textarea-input {
4346
+ box-sizing: border-box;
4347
+ inline-size: 100%;
4348
+ min-block-size: var(--plinth-touch-target);
4349
+ padding-block: var(--plinth-space-2);
4350
+ padding-inline: var(--plinth-space-3);
4351
+ margin: 0;
4352
+ font-family: inherit;
4353
+ font-size: var(--plinth-font-size-field);
4354
+ line-height: var(--plinth-font-leading-normal);
4355
+ color: var(--plinth-color-text);
4356
+ resize: vertical;
4357
+ background-color: var(--plinth-color-surface-sunken);
4358
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
4359
+ border-radius: var(--plinth-radius-control);
4360
+ transition: border-color var(--plinth-duration-fast) var(--plinth-easing-standard);
4361
+ }
4362
+
4363
+ .plinth-textarea-input::placeholder {
4364
+ color: var(--plinth-color-text-subtle);
4365
+ opacity: 1;
4366
+ }
4367
+
4368
+ @media (hover: hover) {
4369
+ .plinth-textarea-input:hover:not(:disabled, :read-only, [aria-invalid='true']) {
4370
+ border-color: var(--plinth-color-text-subtle);
4371
+ }
4372
+ }
4373
+
4374
+ .plinth-textarea-input:focus-visible {
4375
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
4376
+ outline-offset: var(--plinth-focus-ring-offset);
4377
+ }
4378
+
4379
+ .plinth-textarea-input[aria-invalid='true'] {
4380
+ border-color: var(--plinth-color-danger);
4381
+ border-width: var(--plinth-border-width-thick);
4382
+ }
4383
+
4384
+ .plinth-textarea-input:disabled {
4385
+ cursor: not-allowed;
4386
+ resize: none;
4387
+ opacity: var(--plinth-opacity-disabled);
4388
+ }
4389
+
4390
+ /* Without the fill, and with a dashed edge, so it doesn't read as a field to type in. */
4391
+ .plinth-textarea-input:read-only:not(:disabled) {
4392
+ background-color: transparent;
4393
+ border-style: dashed;
4394
+ }
4395
+
4396
+ /*
4397
+ * Grow: from rows lines to max-rows lines (in the textarea's own line height, with its padding
4398
+ * and edge), then it scrolls.
4399
+ */
4400
+ @supports (field-sizing: content) {
4401
+ .plinth-textarea-input[data-grow] {
4402
+ field-sizing: content;
4403
+ min-block-size: max(
4404
+ var(--plinth-touch-target),
4405
+ calc(
4406
+ var(--plinth-textarea-rows, 3) * 1lh + 2 * var(--plinth-space-2) + 2 *
4407
+ var(--plinth-border-width-thin)
4408
+ )
4409
+ );
4410
+ max-block-size: calc(
4411
+ var(--plinth-textarea-max-rows, 8) * 1lh + 2 * var(--plinth-space-2) + 2 *
4412
+ var(--plinth-border-width-thin)
4413
+ );
4414
+ resize: none;
4415
+ }
4416
+ }
4417
+
4418
+ .plinth-textarea-description,
4419
+ .plinth-textarea-error {
4420
+ margin: 0;
4421
+ font-size: var(--plinth-font-size-sm);
4422
+ line-height: var(--plinth-font-leading-normal);
4423
+ overflow-wrap: anywhere;
4424
+ }
4425
+
4426
+ .plinth-textarea-description {
4427
+ color: var(--plinth-color-text-muted);
4428
+ }
4429
+
4430
+ .plinth-textarea-error {
4431
+ color: var(--plinth-color-danger-text);
4432
+ }
4433
+
4434
+ @media (min-width: 768px) {
4435
+ .plinth-textarea-input {
4436
+ font-size: var(--plinth-font-size-md);
4437
+ }
4438
+ }
4439
+
4440
+ @media (forced-colors: active) {
4441
+ .plinth-textarea-input:disabled {
4442
+ color: GrayText;
4443
+ border-color: GrayText;
4444
+ }
4445
+ }
4446
+ }