@putdotio/design 1.5.1 → 2.0.1
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.
- package/DESIGN.md +10 -2
- package/dist/css/tokens.css +26 -0
- package/dist/figma/putio.tokens.json +114 -0
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.dtcg.json +291 -0
- package/dist/tokens.flat.json +208 -0
- package/dist/tokens.js +208 -0
- package/package.json +4 -2
- package/system/assets/logo-retro-dark.svg +11 -1
- package/system/assets/logo-retro-light.svg +11 -1
- package/system/assets/logo.svg +7 -1
- package/system/components.css +440 -0
- package/tokens/components.tokens.json +291 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
put.io — components.css
|
|
3
|
+
The component layer of the design system: plain CSS classes over
|
|
4
|
+
the tokens, no framework. Import it after tokens.css.
|
|
5
|
+
|
|
6
|
+
Consumers: `@putdotio/design/components`, imported after
|
|
7
|
+
`@putdotio/design/css`. Every value here resolves from a token in the
|
|
8
|
+
DTCG graph under `tokens/` — the classes are one binding of that graph,
|
|
9
|
+
not a second source of truth. Icon glyphs assume Phosphor.
|
|
10
|
+
|
|
11
|
+
Port notes: class names are the web contract. SwiftUI / Compose /
|
|
12
|
+
Roku read the same values from the DTCG artifacts and own their
|
|
13
|
+
adapters; appearance (filled / ghost) is a variant axis on one
|
|
14
|
+
Button, never a second component.
|
|
15
|
+
============================================================ */
|
|
16
|
+
/* ===========================================================
|
|
17
|
+
NAMING HAZARD — do not define layout classes in the `ph`/`ph-*`
|
|
18
|
+
namespace. Phosphor's icon font owns it: a rule named `.ph` matches
|
|
19
|
+
every regular-weight <i class="ph ph-x"> on the page and turns the
|
|
20
|
+
glyph into a padded flex box. Page headers are `.pgh`, never `.ph`.
|
|
21
|
+
=========================================================== */
|
|
22
|
+
/* ===========================================================
|
|
23
|
+
Button base — production parity with the web app's primary button
|
|
24
|
+
- lg (default): h36 / 1.0px letter-spacing
|
|
25
|
+
- md: h32 / 0.8px
|
|
26
|
+
- sm: h28 / 0.6px
|
|
27
|
+
- xs: h24 / 0.3px / fs-xs
|
|
28
|
+
--tw-fs scales the whole thing for breakpoints (unset = 1). It is a
|
|
29
|
+
consumer hook, deliberately not a token: a breakpoint multiplier
|
|
30
|
+
belongs to the page, not the system.
|
|
31
|
+
Geometry is token-driven — --button-height(-md/-sm/-xs),
|
|
32
|
+
--button-padding-x(-xs), --button-label-size(-xs),
|
|
33
|
+
--button-tracking(-md/-sm/-xs), --button-icon-size, --button-gap —
|
|
34
|
+
so native emitters read the same box. ONE BOX PER SIZE: tiers and
|
|
35
|
+
variants change colour only, never geometry, so two buttons of the
|
|
36
|
+
same size always compare safely side by side.
|
|
37
|
+
=========================================================== */
|
|
38
|
+
/* NOTE: uppercase + tracking + sizing live on `.btn` — NOT on bare <button>.
|
|
39
|
+
Plain <button> elements (tabs, menu items, segmented controls) inherit
|
|
40
|
+
the reset below but stay sentence case at the size their component chooses. */
|
|
41
|
+
button {
|
|
42
|
+
font-family: var(--font-sans);
|
|
43
|
+
font-weight: var(--fw-medium);
|
|
44
|
+
font-size: calc(var(--button-label-size) * var(--tw-fs, 1)); /* production button = fontSize 0 (0.875rem) */
|
|
45
|
+
/* Real starting bg so transition has something to interpolate
|
|
46
|
+
from — otherwise UA `buttonface` paints silver until hover. */
|
|
47
|
+
background-color: transparent;
|
|
48
|
+
border: 1px solid transparent;
|
|
49
|
+
color: var(--text);
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
display: inline-flex; align-items: center; justify-content: center; gap: var(--button-gap);
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
appearance: none; -webkit-appearance: none;
|
|
54
|
+
transition: background-color var(--dur-fast) var(--ease-out),
|
|
55
|
+
border-color var(--dur-fast) var(--ease-out),
|
|
56
|
+
color var(--dur-fast) var(--ease-out);
|
|
57
|
+
user-select: none;
|
|
58
|
+
}
|
|
59
|
+
button i { font-size: var(--button-icon-size); line-height: 1; display: inline-flex; }
|
|
60
|
+
|
|
61
|
+
/* Brand button — uppercase + tracking + default sizing live HERE, not on
|
|
62
|
+
bare <button>. Anything that shouts "do a thing" (primary / secondary /
|
|
63
|
+
destructive) is a `.btn-*` variant and inherits these. */
|
|
64
|
+
.btn,
|
|
65
|
+
.btn-primary, .btn-success, .btn-danger, .btn-info, .btn-invert, .btn-default, .btn-ghost {
|
|
66
|
+
height: calc(var(--button-height) * var(--tw-fs, 1));
|
|
67
|
+
padding: 0 calc(var(--button-padding-x) * var(--tw-fs, 1));
|
|
68
|
+
border-radius: var(--button-radius);
|
|
69
|
+
text-transform: uppercase;
|
|
70
|
+
letter-spacing: var(--button-tracking);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Sizes — letter-spacing tracks size */
|
|
74
|
+
.btn-md { height: calc(var(--button-height-md) * var(--tw-fs, 1)) !important; padding: 0 calc(var(--button-padding-x) * var(--tw-fs, 1)) !important; letter-spacing: var(--button-tracking-md); }
|
|
75
|
+
.btn-sm { height: calc(var(--button-height-sm) * var(--tw-fs, 1)) !important; padding: 0 calc(var(--button-padding-x) * var(--tw-fs, 1)) !important; letter-spacing: var(--button-tracking-sm); }
|
|
76
|
+
.btn-xs { height: calc(var(--button-height-xs) * var(--tw-fs, 1)) !important; padding: 0 calc(var(--button-padding-x-xs) * var(--tw-fs, 1)) !important; font-size: calc(var(--button-label-size-xs) * var(--tw-fs, 1)) !important; letter-spacing: var(--button-tracking-xs); }
|
|
77
|
+
|
|
78
|
+
/* Icon-only modifier — square, no text padding, no text transform */
|
|
79
|
+
.btn-icon { width: calc(var(--button-height) * var(--tw-fs, 1)) !important; padding: 0 !important; text-transform: none; letter-spacing: normal; }
|
|
80
|
+
.btn-icon.btn-md { width: calc(var(--button-height-md) * var(--tw-fs, 1)) !important; }
|
|
81
|
+
.btn-icon.btn-sm { width: calc(var(--button-height-sm) * var(--tw-fs, 1)) !important; }
|
|
82
|
+
|
|
83
|
+
/* Layout-only modifier — full-width CTA */
|
|
84
|
+
.btn-block { width: 100%; }
|
|
85
|
+
|
|
86
|
+
/* ===========================================================
|
|
87
|
+
Quiet icon button — the app's IconButton appearance="hover" (its DEFAULT).
|
|
88
|
+
This is the GHOST appearance applied to icon-only controls: transparent
|
|
89
|
+
at rest, the variant's colour in the glyph, the variant's
|
|
90
|
+
component-bg-hover behind it on hover. The neutral case reads the
|
|
91
|
+
--button-ghost-* tokens — same tier, same values as .btn-ghost.
|
|
92
|
+
This is the treatment for icon controls that live inside a row or a
|
|
93
|
+
toolbar — list actions, close, retry, delete. Use the filled
|
|
94
|
+
`.btn-default.btn-icon` only when the button stands alone as a target
|
|
95
|
+
(appearance="filled"). Sizes come from .btn-sm / .btn-md as usual.
|
|
96
|
+
=========================================================== */
|
|
97
|
+
.btn-quiet, .btn-quiet.btn-primary, .btn-quiet.btn-warning, .btn-quiet.btn-success,
|
|
98
|
+
.btn-quiet.btn-danger, .btn-quiet.btn-info, .btn-quiet.btn-default {
|
|
99
|
+
background: transparent; border-color: transparent; color: var(--button-ghost-fg);
|
|
100
|
+
}
|
|
101
|
+
.btn-quiet:hover:not(:disabled), .btn-quiet.btn-default:hover:not(:disabled) { background: var(--button-ghost-bg-hover); border-color: transparent; color: var(--button-ghost-fg-hover); }
|
|
102
|
+
.btn-quiet.btn-info { color: var(--text); }
|
|
103
|
+
/* --yellow-text-secondary, not --yellow-solid: solid is a fill step, and in
|
|
104
|
+
light mode the brand yellow as a glyph colour on a light surface is
|
|
105
|
+
unreadable. The text step carries a legible value in both modes. */
|
|
106
|
+
.btn-quiet.btn-primary, .btn-quiet.btn-warning { color: var(--yellow-text-secondary); }
|
|
107
|
+
.btn-quiet.btn-primary:hover:not(:disabled), .btn-quiet.btn-warning:hover:not(:disabled) { background: var(--yellow-component-bg-hover); border-color: transparent; color: var(--yellow-text-secondary); }
|
|
108
|
+
.btn-quiet.btn-success { color: var(--green-solid); }
|
|
109
|
+
.btn-quiet.btn-success:hover:not(:disabled) { background: var(--green-component-bg-hover); border-color: transparent; color: var(--green-solid); }
|
|
110
|
+
.btn-quiet.btn-danger { color: var(--red-solid); }
|
|
111
|
+
.btn-quiet.btn-danger:hover:not(:disabled) { background: var(--red-component-bg-hover); border-color: transparent; color: var(--red-solid); }
|
|
112
|
+
.btn-quiet:disabled { opacity: 1; color: var(--solid); background: transparent; }
|
|
113
|
+
|
|
114
|
+
/* Variants — token-driven. Foregrounds read from --*-foreground
|
|
115
|
+
tokens in tokens.css so "what color on top" lives in one place.
|
|
116
|
+
|
|
117
|
+
CONTRAST CONTRACT: solid success/danger/info fills are tuned for
|
|
118
|
+
APCA Lc 60+ on white text (WCAG 2.x AA-large, not AA-normal). Mark
|
|
119
|
+
specimens that use them with data-contrast-contract="apca" so an
|
|
120
|
+
automated WCAG audit can tell an intentional APCA target from an
|
|
121
|
+
accidental low-contrast bug. Never use these variants at xs/sm — the
|
|
122
|
+
label drops below the large-text threshold. */
|
|
123
|
+
.btn-primary { background: var(--button-primary-bg); border-color: var(--button-primary-bg); color: var(--button-primary-fg); }
|
|
124
|
+
.btn-primary:hover:not(:disabled) { background: var(--button-primary-bg-hover); border-color: var(--button-primary-bg-hover); }
|
|
125
|
+
.btn-success { background: var(--button-success-bg); border-color: var(--button-success-bg); color: var(--button-success-fg); }
|
|
126
|
+
.btn-success:hover:not(:disabled) { background: var(--button-success-bg-hover); border-color: var(--button-success-bg-hover); }
|
|
127
|
+
.btn-danger { background: var(--button-danger-bg); border-color: var(--button-danger-bg); color: var(--button-danger-fg); }
|
|
128
|
+
.btn-danger:hover:not(:disabled) { background: var(--button-danger-bg-hover); border-color: var(--button-danger-bg-hover); }
|
|
129
|
+
.btn-info { background: var(--button-info-bg); border-color: var(--button-info-bg); color: var(--button-info-fg); }
|
|
130
|
+
.btn-info:hover:not(:disabled) { background: var(--button-info-bg-hover); border-color: var(--button-info-bg-hover); }
|
|
131
|
+
.btn-invert { background: var(--text); border-color: var(--border); color: var(--invert-foreground); }
|
|
132
|
+
.btn-invert:hover:not(:disabled) { border-color: var(--border-hover); }
|
|
133
|
+
.btn-default { background: var(--button-default-bg); border-color: var(--border); color: var(--button-default-fg); }
|
|
134
|
+
.btn-default:hover:not(:disabled) { background: var(--button-default-bg-hover); border-color: var(--border-hover); }
|
|
135
|
+
|
|
136
|
+
/* Ghost — the third tier: text-only. No fill, no border at rest;
|
|
137
|
+
hover paints the shared quiet fill and lifts the label a step
|
|
138
|
+
(--button-ghost-fg → --button-ghost-fg-hover — the fill is never the
|
|
139
|
+
only cue). For actions quieter than a filled secondary: a header
|
|
140
|
+
"Log in", toolbar text actions, "Cancel" beside a filled confirm.
|
|
141
|
+
NEVER substitute an outline: a border-only box beside a solid fill
|
|
142
|
+
reads as a different size — see the pairing rule in DESIGN.md.
|
|
143
|
+
Disabled matches the quiet treatment: the colour drops to --solid,
|
|
144
|
+
the box does not fade. */
|
|
145
|
+
.btn-ghost { background: var(--button-ghost-bg); border-color: transparent; color: var(--button-ghost-fg); }
|
|
146
|
+
.btn-ghost:hover:not(:disabled) { background: var(--button-ghost-bg-hover); border-color: transparent; color: var(--button-ghost-fg-hover); }
|
|
147
|
+
.btn-ghost:disabled { opacity: 1; color: var(--solid); background: transparent; }
|
|
148
|
+
|
|
149
|
+
/* States */
|
|
150
|
+
/* Disabled buttons freeze their resting fill at reduced opacity and ignore
|
|
151
|
+
hover entirely — hover styles above are gated on :not(:disabled), so there
|
|
152
|
+
is no need to (and we must not) reset background to `inherit` here, which
|
|
153
|
+
would strip a disabled primary/danger button of its color on hover. */
|
|
154
|
+
button:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
155
|
+
button:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
|
|
156
|
+
|
|
157
|
+
/* Loading state — hides label, paints a CSS spinner over the button */
|
|
158
|
+
button.is-loading { color: transparent !important; position: relative; pointer-events: none; opacity: 0.75; }
|
|
159
|
+
button.is-loading::after {
|
|
160
|
+
content: ''; position: absolute; inset: 0; margin: auto;
|
|
161
|
+
width: 14px; height: 14px; border-radius: 50%;
|
|
162
|
+
border: 1.5px solid currentColor; border-top-color: transparent;
|
|
163
|
+
opacity: 0.75;
|
|
164
|
+
animation: spin 0.7s linear infinite;
|
|
165
|
+
}
|
|
166
|
+
.btn-primary.is-loading::after { color: var(--primary-foreground); }
|
|
167
|
+
.btn-success.is-loading::after,
|
|
168
|
+
.btn-danger.is-loading::after,
|
|
169
|
+
.btn-info.is-loading::after,
|
|
170
|
+
.btn-invert.is-loading::after { color: var(--destructive-foreground); }
|
|
171
|
+
.btn-default.is-loading::after, .btn-ghost.is-loading::after { color: var(--text); }
|
|
172
|
+
/* The spinner borrows currentColor, and .is-loading makes the label colour
|
|
173
|
+
transparent — so a quiet button needs its own spinner colour or the control
|
|
174
|
+
shows nothing at all while it works. */
|
|
175
|
+
.btn-quiet.is-loading::after { color: var(--button-ghost-fg-hover); }
|
|
176
|
+
.btn-quiet.btn-primary.is-loading::after,
|
|
177
|
+
.btn-quiet.btn-warning.is-loading::after { color: var(--yellow-text-secondary); }
|
|
178
|
+
.btn-quiet.btn-success.is-loading::after { color: var(--green-solid); }
|
|
179
|
+
.btn-quiet.btn-danger.is-loading::after { color: var(--red-solid); }
|
|
180
|
+
/* Shared spinner keyframe — consumed by .is-loading and by form/shell
|
|
181
|
+
cards. Defined once here; do not redeclare it per card. */
|
|
182
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
183
|
+
|
|
184
|
+
/* Inputs/textareas/selects inherit nothing from the button reset.
|
|
185
|
+
These are dropped for safety in case they ever do. */
|
|
186
|
+
input, textarea, select { text-transform: none; letter-spacing: normal; }
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
/* ===========================================================
|
|
190
|
+
Specimen header — the yellow-mono index marker that anchors
|
|
191
|
+
each preview card. 11 files used to duplicate this verbatim.
|
|
192
|
+
=========================================================== */
|
|
193
|
+
.head {
|
|
194
|
+
display: flex; align-items: baseline; gap: 14px;
|
|
195
|
+
padding-bottom: 10px;
|
|
196
|
+
border-bottom: 1px solid var(--line);
|
|
197
|
+
}
|
|
198
|
+
.head .ix {
|
|
199
|
+
font-family: var(--font-mono); font-size: 11px;
|
|
200
|
+
color: var(--yellow-text-secondary); letter-spacing: 0.04em;
|
|
201
|
+
}
|
|
202
|
+
.head .t { font-size: 15px; font-weight: 500; color: var(--text); }
|
|
203
|
+
.head .n {
|
|
204
|
+
margin-left: auto;
|
|
205
|
+
font-size: 12px; color: var(--text-secondary);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
/* ===========================================================
|
|
210
|
+
Surface primitives — two roles, two chromes.
|
|
211
|
+
|
|
212
|
+
.card = focal, elevated component surface. Sits ON --bg,
|
|
213
|
+
uses --component-bg so it reads as raised in dark
|
|
214
|
+
and contained in light. Auth card, modal, settings
|
|
215
|
+
card, anything the user's attention should go TO.
|
|
216
|
+
|
|
217
|
+
.panel = quiet inline reference container. Sits ON --bg,
|
|
218
|
+
uses --bg-secondary so it stays recessive but
|
|
219
|
+
still bordered. Code blocks, info asides, sidebar
|
|
220
|
+
panels, callouts. Lower visual weight than a card.
|
|
221
|
+
|
|
222
|
+
Both use the same hairline border + 8–10 px radius — the
|
|
223
|
+
ONLY thing that differs is the surface fill. Pick the role,
|
|
224
|
+
not the chrome.
|
|
225
|
+
=========================================================== */
|
|
226
|
+
.card {
|
|
227
|
+
background: var(--component-bg);
|
|
228
|
+
border: 1px solid var(--line);
|
|
229
|
+
border-radius: var(--radius-lg);
|
|
230
|
+
}
|
|
231
|
+
.card-md { padding: 24px; }
|
|
232
|
+
.card-lg { padding: 32px 32px 28px; }
|
|
233
|
+
|
|
234
|
+
/* Reads the --panel-* aliases, not the generic surface scale. That is the
|
|
235
|
+
documented contract for panel chrome, and it is the difference between a
|
|
236
|
+
consumer being able to retheme panels and having to override this rule. */
|
|
237
|
+
.panel {
|
|
238
|
+
background: var(--panel-bg);
|
|
239
|
+
border: 1px solid var(--panel-border);
|
|
240
|
+
border-radius: var(--panel-radius);
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
}
|
|
243
|
+
.panel-pad { padding: 14px 16px; }
|
|
244
|
+
.panel-pad-lg { padding: 18px 20px; }
|
|
245
|
+
|
|
246
|
+
/* ===========================================================
|
|
247
|
+
Field primitives — production parity with app.put.io forms.
|
|
248
|
+
|
|
249
|
+
ONE source of truth for input chrome. Local preview files used
|
|
250
|
+
to redefine `.field` three times with different focus rings;
|
|
251
|
+
now they all inherit from here. To override per-screen, change
|
|
252
|
+
geometry (height/padding) — not the surface tokens.
|
|
253
|
+
|
|
254
|
+
Surface contract:
|
|
255
|
+
bg → --field-bg (= --app-bg)
|
|
256
|
+
border → --field-border (= --border)
|
|
257
|
+
border:hover → --field-border-hover
|
|
258
|
+
focus ring → --field-ring (1px border-hover, NOT yellow)
|
|
259
|
+
placeholder → --field-placeholder
|
|
260
|
+
disabled bg → --field-bg-disabled
|
|
261
|
+
|
|
262
|
+
Invalid: opt in via `aria-invalid="true"` — NO `.has-error`
|
|
263
|
+
class. The DOM contract is the same React state pushes to the
|
|
264
|
+
input. Border + ring flip red; the surface stays --field-bg
|
|
265
|
+
(no red fill) per app.put.io behavior.
|
|
266
|
+
=========================================================== */
|
|
267
|
+
.field {
|
|
268
|
+
width: 100%;
|
|
269
|
+
height: 36px;
|
|
270
|
+
padding: 0 12px;
|
|
271
|
+
box-sizing: border-box;
|
|
272
|
+
/* --field-radius, not the generic --radius. Same 6px today, but a consumer
|
|
273
|
+
retheming field chrome must not have to override this rule to move it. */
|
|
274
|
+
border-radius: var(--field-radius);
|
|
275
|
+
border: 1px solid var(--field-border);
|
|
276
|
+
background: var(--field-bg);
|
|
277
|
+
color: var(--field-text);
|
|
278
|
+
font-family: var(--font-sans);
|
|
279
|
+
font-size: var(--fs-base);
|
|
280
|
+
transition:
|
|
281
|
+
border-color var(--dur-fast) var(--ease-out),
|
|
282
|
+
box-shadow var(--dur-fast) var(--ease-out),
|
|
283
|
+
background var(--dur-fast) var(--ease-out);
|
|
284
|
+
}
|
|
285
|
+
.field::placeholder { color: var(--field-placeholder); }
|
|
286
|
+
.field:hover { border-color: var(--field-border-hover); }
|
|
287
|
+
.field:focus,
|
|
288
|
+
.field:focus-visible {
|
|
289
|
+
outline: none;
|
|
290
|
+
border-color: var(--field-border-focus);
|
|
291
|
+
box-shadow: var(--field-ring);
|
|
292
|
+
}
|
|
293
|
+
.field:disabled,
|
|
294
|
+
.field[aria-disabled="true"] {
|
|
295
|
+
background: var(--field-bg-disabled);
|
|
296
|
+
color: var(--text-secondary);
|
|
297
|
+
cursor: not-allowed;
|
|
298
|
+
}
|
|
299
|
+
/* Invalid — border + text only, no surface fill.
|
|
300
|
+
Step convention: rest = step 7 (--red-border),
|
|
301
|
+
focus/hover = step 8 (--red-border-hover). */
|
|
302
|
+
.field[aria-invalid="true"] {
|
|
303
|
+
border-color: var(--red-border);
|
|
304
|
+
}
|
|
305
|
+
.field[aria-invalid="true"]:hover {
|
|
306
|
+
border-color: var(--red-border-hover);
|
|
307
|
+
}
|
|
308
|
+
.field[aria-invalid="true"]:focus,
|
|
309
|
+
.field[aria-invalid="true"]:focus-visible {
|
|
310
|
+
border-color: var(--red-border-hover);
|
|
311
|
+
box-shadow: 0 0 0 1px var(--red-border-hover);
|
|
312
|
+
}
|
|
313
|
+
/* Loading / async-validating — suppress focus ring so the spinner
|
|
314
|
+
reads as the dominant signal. */
|
|
315
|
+
.field[data-state="loading"]:focus,
|
|
316
|
+
.field[data-state="loading"]:focus-visible {
|
|
317
|
+
box-shadow: none;
|
|
318
|
+
}
|
|
319
|
+
.field.mono { font-family: var(--font-mono); font-size: 13px; }
|
|
320
|
+
|
|
321
|
+
/* ===========================================================
|
|
322
|
+
Form layout recipes — the rhythm comes from gap, not from
|
|
323
|
+
per-screen margins. Use these instead of stacking ad-hoc
|
|
324
|
+
pixel values on every label/hint/error.
|
|
325
|
+
=========================================================== */
|
|
326
|
+
.form-stack {
|
|
327
|
+
display: flex;
|
|
328
|
+
flex-direction: column;
|
|
329
|
+
gap: 14px; /* between label-group rows (label + field + helper) */
|
|
330
|
+
}
|
|
331
|
+
.form-stack.tight { gap: 10px; }
|
|
332
|
+
.form-stack.loose { gap: 20px; }
|
|
333
|
+
|
|
334
|
+
.form-group {
|
|
335
|
+
display: flex;
|
|
336
|
+
flex-direction: column;
|
|
337
|
+
gap: 6px; /* label ↔ field ↔ helper/error */
|
|
338
|
+
}
|
|
339
|
+
.form-group .label { margin-bottom: 0; } /* gap owns the rhythm now */
|
|
340
|
+
.form-group .hint { margin-top: 0; }
|
|
341
|
+
.form-group > .form-callout.inline { margin-top: 0; }
|
|
342
|
+
|
|
343
|
+
/* Label-row: label on the left, link on the right (e.g. "Forgot your
|
|
344
|
+
password?"). Sits ABOVE the field, inside a .form-group. */
|
|
345
|
+
.form-group .label-row {
|
|
346
|
+
display: flex;
|
|
347
|
+
justify-content: space-between;
|
|
348
|
+
align-items: baseline;
|
|
349
|
+
gap: 12px;
|
|
350
|
+
}
|
|
351
|
+
.form-group .label-row .label { margin-bottom: 0; }
|
|
352
|
+
.form-group .label-row a {
|
|
353
|
+
font-size: 12px;
|
|
354
|
+
color: var(--text-secondary);
|
|
355
|
+
text-decoration: none;
|
|
356
|
+
white-space: nowrap;
|
|
357
|
+
}
|
|
358
|
+
.form-group .label-row a:hover { color: var(--text); }
|
|
359
|
+
|
|
360
|
+
/* Floating-menu surface — one recipe for dropdowns, context menus,
|
|
361
|
+
submenus, and on-player popovers. Cards keep their item styles local. */
|
|
362
|
+
/* Reads the --menu-* aliases for surface, border, radius and shadow. The
|
|
363
|
+
generic scale is close but not identical, and using it here would silently
|
|
364
|
+
break --menu-* retheming for every consumer of this recipe. */
|
|
365
|
+
.menu-pop {
|
|
366
|
+
min-width: 216px;
|
|
367
|
+
background: var(--menu-bg);
|
|
368
|
+
border: 1px solid var(--menu-border);
|
|
369
|
+
border-radius: var(--menu-radius);
|
|
370
|
+
box-shadow: var(--menu-shadow);
|
|
371
|
+
padding: 4px;
|
|
372
|
+
z-index: var(--z-dropdown);
|
|
373
|
+
display: flex; flex-direction: column; gap: 1px;
|
|
374
|
+
}
|
|
375
|
+
.menu-pop .menu-label {
|
|
376
|
+
padding: 6px 10px 4px;
|
|
377
|
+
font-family: var(--font-mono); font-size: 10.5px;
|
|
378
|
+
letter-spacing: 0.06em; text-transform: uppercase;
|
|
379
|
+
/* --menu-label, not the generic --text-secondary. Same value in both modes
|
|
380
|
+
today, so this is a retheming fix rather than a visual one: the family owns
|
|
381
|
+
menu chrome, and a consumer moving --menu-label must reach this. */
|
|
382
|
+
color: var(--menu-label);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ===========================================================
|
|
386
|
+
Selection controls — ONE checkbox and ONE switch for the
|
|
387
|
+
whole system.
|
|
388
|
+
|
|
389
|
+
State comes from `data-state="checked"`, the same attribute a
|
|
390
|
+
headless primitive already sets, so the DOM contract holds
|
|
391
|
+
whatever drives it. The legacy `.checked` / `.on` class flags
|
|
392
|
+
are still matched for the specimens that predate this, but
|
|
393
|
+
`.on` is exactly the invented flag the state contract tells
|
|
394
|
+
you not to add — prefer the attribute in new markup.
|
|
395
|
+
|
|
396
|
+
.checkbox — div or <span>, data-state="checked" when on
|
|
397
|
+
input[type="checkbox"] — native inputs get the brand accent
|
|
398
|
+
.switch — div, data-state="checked" when on
|
|
399
|
+
=========================================================== */
|
|
400
|
+
.checkbox {
|
|
401
|
+
width: 14px; height: 14px; flex-shrink: 0;
|
|
402
|
+
border: 1.5px solid var(--border-hover);
|
|
403
|
+
border-radius: var(--radius-sm);
|
|
404
|
+
background: transparent;
|
|
405
|
+
transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
|
|
406
|
+
}
|
|
407
|
+
.checkbox.checked,
|
|
408
|
+
.checkbox[data-state="checked"] {
|
|
409
|
+
background: var(--yellow-solid);
|
|
410
|
+
border-color: var(--yellow-solid);
|
|
411
|
+
position: relative;
|
|
412
|
+
}
|
|
413
|
+
.checkbox.checked::after,
|
|
414
|
+
.checkbox[data-state="checked"]::after {
|
|
415
|
+
content: ''; position: absolute; left: 3px; top: 0;
|
|
416
|
+
width: 5px; height: 9px;
|
|
417
|
+
border: solid var(--primary-foreground); border-width: 0 2px 2px 0;
|
|
418
|
+
transform: rotate(45deg);
|
|
419
|
+
}
|
|
420
|
+
input[type="checkbox"], input[type="radio"] { accent-color: var(--yellow-solid); cursor: pointer; }
|
|
421
|
+
|
|
422
|
+
.switch {
|
|
423
|
+
width: 44px; height: 24px; flex-shrink: 0;
|
|
424
|
+
border-radius: var(--radius-pill);
|
|
425
|
+
background: var(--component-bg-active);
|
|
426
|
+
position: relative; cursor: pointer;
|
|
427
|
+
transition: background var(--dur-fast) var(--ease-out);
|
|
428
|
+
}
|
|
429
|
+
.switch::after {
|
|
430
|
+
content: ''; position: absolute; top: 2px; left: 2px;
|
|
431
|
+
width: 20px; height: 20px; border-radius: 50%;
|
|
432
|
+
background: var(--text);
|
|
433
|
+
transition: left var(--dur-fast) var(--ease-out);
|
|
434
|
+
}
|
|
435
|
+
.switch.on,
|
|
436
|
+
.switch[data-state="checked"] { background: var(--green-solid); }
|
|
437
|
+
/* The thumb rides the green selected track, so it takes the token for "what
|
|
438
|
+
colour sits on green" rather than a raw white that no theme can reach. */
|
|
439
|
+
.switch.on::after,
|
|
440
|
+
.switch[data-state="checked"]::after { left: 22px; background: var(--button-success-fg); }
|