@putdotio/design 1.5.0 → 2.0.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.
@@ -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); }