unmagic-components 0.2.0 → 0.3.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,1164 @@
1
+ /* unmagic-components
2
+ *
3
+ * The components' styles, written for Tailwind CSS v4 and compiled by the host
4
+ * application's own Tailwind build. The Ruby renders stable classes
5
+ * (UnmagicCard, UnmagicInput, …); this file gives those classes their look
6
+ * with @apply and Tailwind's theme, so nothing in the gem needs scanning.
7
+ *
8
+ * With tailwindcss-rails, the first build writes an entry point for this file
9
+ * to app/assets/builds/tailwind/unmagic_components.css. Import it after
10
+ * Tailwind in app/assets/tailwind/application.css:
11
+ *
12
+ * @import "tailwindcss";
13
+ * @import "../builds/tailwind/unmagic_components";
14
+ *
15
+ * With the npm package or the standalone CLI, import this file by its path
16
+ * inside the installed gem instead (bundle show unmagic-components).
17
+ *
18
+ * Every rule sits in @layer components, below Tailwind's utilities, so a
19
+ * utility passed to a component wins over the gem's own style:
20
+ *
21
+ * <%= card class: "rounded-none" do %>…<% end %>
22
+ *
23
+ * Colours come from the host's theme palette (neutral, red, green, amber), so
24
+ * redefining those in @theme restyles the components too. Dark mode uses the
25
+ * host's `dark` variant, whether that is the default prefers-color-scheme or a
26
+ * @custom-variant keyed to a class or attribute.
27
+ */
28
+
29
+ @layer components {
30
+ /* ------------------------------------------------------------------ Table */
31
+
32
+ /* Content-sized by default, so a Markdown table wearing this class hugs its
33
+ content rather than stretching. table_tag adds the --full modifier, which is
34
+ what an index table wants. */
35
+ .UnmagicTable {
36
+ @apply overflow-hidden rounded-xl border border-separate border-spacing-0 border-neutral-200 bg-white text-sm text-neutral-900;
37
+ @apply dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100;
38
+ }
39
+
40
+ .UnmagicTable--full {
41
+ @apply w-full;
42
+ }
43
+
44
+ .UnmagicTable--fixed {
45
+ @apply table-fixed;
46
+ }
47
+
48
+ .UnmagicTable__caption {
49
+ @apply sr-only;
50
+ }
51
+
52
+ .UnmagicTable thead th {
53
+ @apply border-b border-neutral-200 bg-neutral-50 px-4 py-3 text-left text-xs font-semibold whitespace-nowrap text-neutral-600;
54
+ @apply dark:border-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-400;
55
+ }
56
+
57
+ .UnmagicTable tbody td {
58
+ @apply border-b border-neutral-200 px-4 py-3 align-middle dark:border-neutral-800;
59
+ }
60
+
61
+ /* The border rides on the cells rather than the row: with border-collapse
62
+ separate, a border set on a <tr> is not painted. */
63
+ .UnmagicTable tbody tr:last-child > td {
64
+ @apply border-b-0;
65
+ }
66
+
67
+ .UnmagicTable tbody tr:hover > td {
68
+ @apply bg-neutral-50 dark:bg-neutral-800;
69
+ }
70
+
71
+ /* A row carrying a companion details row below it drops its bottom border, so
72
+ the pair reads as one row. */
73
+ .UnmagicTable tbody tr.has-details > td {
74
+ @apply border-b-0;
75
+ }
76
+
77
+ .UnmagicTable tbody tr.UnmagicTable__details > td {
78
+ @apply pt-0;
79
+ }
80
+
81
+ .UnmagicTable .is-right {
82
+ @apply text-right;
83
+ }
84
+
85
+ .UnmagicTable .is-center {
86
+ @apply text-center;
87
+ }
88
+
89
+ .UnmagicTable .is-numeric {
90
+ @apply tabular-nums;
91
+ }
92
+
93
+ .UnmagicTable__sort {
94
+ @apply inline-flex items-center gap-1 no-underline;
95
+ color: inherit;
96
+ }
97
+
98
+ .UnmagicTable__sort:hover {
99
+ @apply text-neutral-900 dark:text-neutral-100;
100
+ }
101
+
102
+ /* --------------------------------------------------------------- Skeleton */
103
+
104
+ .UnmagicSkeleton {
105
+ @apply relative h-4 overflow-hidden rounded-sm bg-neutral-200 dark:bg-neutral-800;
106
+ }
107
+
108
+ .UnmagicSkeleton.is-right {
109
+ @apply ml-auto;
110
+ }
111
+
112
+ /* The shimmer is a translated ::after so it stays on the compositor; the radius
113
+ and sizing stay on the element itself, which clips the band to the shape. */
114
+ .UnmagicSkeleton::after {
115
+ content: "";
116
+ position: absolute;
117
+ inset: 0;
118
+ background-image: linear-gradient(90deg, transparent, --alpha(var(--color-white) / 70%), transparent);
119
+ transform: translateX(-100%);
120
+ animation: unmagic-skeleton-shimmer 1.5s ease-in-out infinite;
121
+ }
122
+
123
+ /* A fainter band on a dark shape. After the rule above, which it ties with on
124
+ specificity. */
125
+ .UnmagicSkeleton {
126
+ @variant dark {
127
+ &::after {
128
+ background-image: linear-gradient(90deg, transparent, --alpha(var(--color-white) / 5%), transparent);
129
+ }
130
+ }
131
+ }
132
+
133
+ @keyframes unmagic-skeleton-shimmer {
134
+ 100% {
135
+ transform: translateX(100%);
136
+ }
137
+ }
138
+
139
+ @media (prefers-reduced-motion: reduce) {
140
+ .UnmagicSkeleton::after {
141
+ animation: none;
142
+ }
143
+ }
144
+
145
+ /* Shapes for blocking out an interface (skeleton, skeleton_text, …). Each is sized
146
+ from what it stands in for, so nothing moves when the real content arrives: a
147
+ text line takes exactly one line box of the font around it, and a button shape
148
+ the height of a real .UnmagicButton. Width and height set on the element win. */
149
+ /* Exactly one line box tall, with the thinner bar centred inside; margins around
150
+ the bar would collapse through its container and lose the height. */
151
+ .UnmagicSkeletonLine {
152
+ @apply flex max-w-full items-center;
153
+ height: 1.2em;
154
+ height: 1lh;
155
+ }
156
+
157
+ .UnmagicSkeleton--text {
158
+ @apply w-full rounded-sm;
159
+ height: 0.7em;
160
+ }
161
+
162
+ .UnmagicSkeletonText {
163
+ @apply block max-w-full;
164
+ }
165
+
166
+ .UnmagicSkeleton--circle {
167
+ @apply block shrink-0 rounded-full;
168
+ }
169
+
170
+ .UnmagicSkeleton--block {
171
+ @apply block w-full max-w-full rounded-lg;
172
+ }
173
+
174
+ /* A button's height is its vertical padding, its line height and a 1px border
175
+ each side; these match .UnmagicButton at each size. */
176
+ .UnmagicSkeleton--button {
177
+ @apply inline-block w-22 rounded-md align-middle;
178
+ height: calc(2rem + 2px);
179
+ }
180
+
181
+ .UnmagicSkeleton--button.UnmagicSkeleton--small {
182
+ @apply w-18;
183
+ height: calc(1.5rem + 2px);
184
+ }
185
+
186
+ .UnmagicSkeleton--button.UnmagicSkeleton--large {
187
+ @apply w-28 rounded-lg;
188
+ height: calc(2.75rem + 2px);
189
+ }
190
+
191
+ /* ------------------------------------------------------------ Empty state */
192
+
193
+ .UnmagicEmptyState {
194
+ @apply rounded-lg border border-dashed border-neutral-300 p-8 text-center text-sm text-neutral-500 dark:border-neutral-700;
195
+ }
196
+
197
+ /* ------------------------------------------------------------- Pagination */
198
+
199
+ .UnmagicPagination {
200
+ @apply mt-5 flex items-center justify-center gap-4;
201
+ }
202
+
203
+ .UnmagicPagination__link {
204
+ @apply rounded-md border border-neutral-300 bg-white px-3 py-1 text-sm text-neutral-900 no-underline;
205
+ @apply dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100;
206
+ }
207
+
208
+ .UnmagicPagination__link:hover {
209
+ @apply bg-neutral-50 dark:bg-neutral-800;
210
+ }
211
+
212
+ .UnmagicPagination__link[aria-disabled="true"] {
213
+ @apply pointer-events-none opacity-50;
214
+ }
215
+
216
+ /* -------------------------------------------------------- Description list */
217
+
218
+ .UnmagicDescriptionList {
219
+ @apply grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm;
220
+ }
221
+
222
+ .UnmagicDescriptionList dt {
223
+ @apply col-start-1 text-neutral-600 dark:text-neutral-400;
224
+ }
225
+
226
+ .UnmagicDescriptionList dd {
227
+ @apply col-start-2 m-0 text-neutral-900 dark:text-neutral-100;
228
+ }
229
+
230
+ /* The stacked variant is a different layout, not a restyle of the inline one:
231
+ small caps labels above their values, in two columns once there is room. */
232
+ .UnmagicDescriptionList--stacked {
233
+ @apply grid grid-cols-1 gap-x-8 gap-y-5 sm:grid-cols-2;
234
+ }
235
+
236
+ .UnmagicDescriptionList--stacked > .is-full {
237
+ @apply sm:col-span-2;
238
+ }
239
+
240
+ .UnmagicDescriptionList--stacked dt {
241
+ @apply col-auto text-xs font-medium tracking-wide text-neutral-400 uppercase dark:text-neutral-500;
242
+ }
243
+
244
+ .UnmagicDescriptionList--stacked dd {
245
+ @apply col-auto mt-1 text-neutral-900 dark:text-neutral-100;
246
+ }
247
+
248
+ /* ------------------------------------------------------------------- Forms */
249
+
250
+ /* The field's structure, then the controls themselves. Controls are styled by
251
+ class — UnmagicInput, UnmagicSelect, UnmagicCheck, UnmagicRadio — and never by
252
+ bare element, so an input the gem didn't render is left as the app has it. The
253
+ builder puts the class on through config.control_class, which an app with input
254
+ styles of its own points at those instead. */
255
+
256
+ .UnmagicField {
257
+ @apply mb-4 flex flex-col gap-1.5;
258
+ }
259
+
260
+ .UnmagicField--in-row {
261
+ @apply mb-0 flex-1;
262
+ }
263
+
264
+ .UnmagicField--inline {
265
+ @apply mb-0;
266
+ }
267
+
268
+ .UnmagicFieldGroup {
269
+ @apply mb-4 flex flex-col gap-4 sm:flex-row;
270
+ }
271
+
272
+ .UnmagicFieldGroup--inline {
273
+ @apply flex flex-wrap items-center gap-2;
274
+ }
275
+
276
+ /* Label, hint and error set only the font size, keeping the line height of the
277
+ text around them. */
278
+ .UnmagicLabel {
279
+ @apply font-medium text-neutral-600 dark:text-neutral-400;
280
+ font-size: var(--text-sm);
281
+ }
282
+
283
+ .UnmagicLabel__required {
284
+ @apply text-red-500 dark:text-red-400;
285
+ }
286
+
287
+ .UnmagicHint {
288
+ @apply m-0 text-neutral-500;
289
+ font-size: var(--text-sm);
290
+ }
291
+
292
+ .UnmagicError {
293
+ @apply m-0 text-red-600 dark:text-red-400;
294
+ font-size: var(--text-sm);
295
+ }
296
+
297
+ .UnmagicFormErrors {
298
+ @apply mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-red-700;
299
+ @apply dark:border-red-400/30 dark:bg-red-400/10 dark:text-red-400;
300
+ font-size: var(--text-sm);
301
+ }
302
+
303
+ .UnmagicCheckField {
304
+ @apply flex cursor-pointer items-start gap-3;
305
+ }
306
+
307
+ .UnmagicCheckField__text {
308
+ @apply flex flex-col gap-0.5;
309
+ }
310
+
311
+ .UnmagicCheckField__label {
312
+ @apply font-medium text-neutral-900 dark:text-neutral-100;
313
+ font-size: var(--text-sm);
314
+ }
315
+
316
+ .UnmagicCheckList {
317
+ @apply flex flex-col gap-2;
318
+ }
319
+
320
+ .UnmagicCheckList--inline {
321
+ @apply flex flex-wrap gap-x-6 gap-y-2;
322
+ }
323
+
324
+ /* Boxes: text, password, date and textarea inputs, and selects. Padding and type
325
+ match an UnmagicButton of the same size, so the two sit level in a row. */
326
+ .UnmagicInput,
327
+ .UnmagicSelect {
328
+ font: inherit;
329
+ @apply m-0 box-border max-w-full rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 shadow-xs;
330
+ @apply dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100;
331
+ }
332
+
333
+ /* A field stretches its control; an inline one (a filter toolbar) lets it size
334
+ itself. :where keeps these as easy to override as the class alone. Rails wraps
335
+ an invalid control in a field_with_errors div, hence a descendant selector. */
336
+ .UnmagicField :where(.UnmagicInput, .UnmagicSelect) {
337
+ @apply w-full;
338
+ }
339
+
340
+ .UnmagicField--inline :where(.UnmagicInput, .UnmagicSelect) {
341
+ @apply w-auto;
342
+ }
343
+
344
+ .UnmagicInput::placeholder {
345
+ @apply text-neutral-500 opacity-100;
346
+ }
347
+
348
+ .UnmagicInput:focus-visible,
349
+ .UnmagicSelect:focus-visible {
350
+ @apply outline-2 outline-offset-2 outline-neutral-400 dark:outline-neutral-500;
351
+ }
352
+
353
+ .UnmagicInput[aria-invalid="true"],
354
+ .UnmagicSelect[aria-invalid="true"] {
355
+ @apply border-red-600 dark:border-red-400;
356
+ }
357
+
358
+ .UnmagicInput[aria-invalid="true"]:focus-visible,
359
+ .UnmagicSelect[aria-invalid="true"]:focus-visible {
360
+ @apply outline-red-600 dark:outline-red-400;
361
+ }
362
+
363
+ .UnmagicInput[readonly],
364
+ .UnmagicInput:disabled,
365
+ .UnmagicSelect:disabled {
366
+ @apply bg-neutral-50 shadow-none dark:bg-neutral-800/50;
367
+ }
368
+
369
+ .UnmagicInput:disabled,
370
+ .UnmagicSelect:disabled {
371
+ @apply cursor-not-allowed text-neutral-500 dark:text-neutral-500;
372
+ }
373
+
374
+ textarea.UnmagicInput {
375
+ @apply resize-y;
376
+ }
377
+
378
+ /* Safari centres a date's value, and collapses an empty one to no height. */
379
+ .UnmagicInput::-webkit-date-and-time-value {
380
+ min-height: 1.25rem;
381
+ text-align: start;
382
+ }
383
+
384
+ /* The native arrow is replaced by one drawn in the text colour, so it follows the
385
+ theme: two gradient triangles, since a select can't hold a pseudo-element. */
386
+ .UnmagicSelect {
387
+ @apply appearance-none pr-8;
388
+ background-image:
389
+ linear-gradient(45deg, transparent 50%, currentColor 50%),
390
+ linear-gradient(135deg, currentColor 50%, transparent 50%);
391
+ background-position: calc(100% - 1.125rem) 50%, calc(100% - 0.8125rem) 50%;
392
+ background-size: 0.3125rem 0.3125rem;
393
+ background-repeat: no-repeat;
394
+ }
395
+
396
+ /* A list box shows its options, so it has no arrow to make room for. */
397
+ .UnmagicSelect[multiple],
398
+ .UnmagicSelect[size]:not([size="1"]) {
399
+ @apply pr-3;
400
+ background-image: none;
401
+ }
402
+
403
+ .UnmagicInput--small,
404
+ .UnmagicSelect--small {
405
+ @apply py-1 pl-2.5 text-xs;
406
+ }
407
+
408
+ .UnmagicInput--small {
409
+ @apply pr-2.5;
410
+ }
411
+
412
+ .UnmagicSelect--small {
413
+ @apply pr-7;
414
+ }
415
+
416
+ .UnmagicInput--large,
417
+ .UnmagicSelect--large {
418
+ @apply rounded-lg py-2.5 pl-4 text-base;
419
+ }
420
+
421
+ .UnmagicInput--large {
422
+ @apply pr-4;
423
+ }
424
+
425
+ .UnmagicSelect--large {
426
+ @apply pr-9;
427
+ }
428
+
429
+ /* Checkboxes and radios stay real inputs, drawn with CSS: the box, and a mark
430
+ that shows once checked. Nothing animates, so reduced motion has nothing to
431
+ switch off. The border is darker than a text box's: a box this small is
432
+ identified by its edge alone, so the edge needs 3:1 against the surface. */
433
+ .UnmagicCheck,
434
+ .UnmagicRadio {
435
+ @apply m-0 box-border inline-grid size-4 flex-none cursor-pointer appearance-none place-content-center rounded-sm border border-neutral-500 bg-white dark:bg-neutral-900;
436
+ vertical-align: -0.125rem;
437
+ }
438
+
439
+ .UnmagicRadio {
440
+ @apply rounded-full;
441
+ }
442
+
443
+ /* Beside a label, level the box with the label's first line. */
444
+ .UnmagicCheckField :is(.UnmagicCheck, .UnmagicRadio) {
445
+ @apply mt-0.5;
446
+ }
447
+
448
+ .UnmagicCheck::before,
449
+ .UnmagicRadio::before {
450
+ @apply invisible bg-white;
451
+ content: "";
452
+ }
453
+
454
+ .UnmagicCheck::before {
455
+ @apply size-2.5;
456
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
457
+ }
458
+
459
+ .UnmagicRadio::before {
460
+ @apply size-1.5 rounded-full;
461
+ }
462
+
463
+ /* The mark's dark colour. A dark variant can't sit on the pseudo-element itself
464
+ (a pseudo-element must end its selector), so it hangs off the box. It has to
465
+ come after the rule above: :where() adds no specificity, so the two tie and
466
+ the later one wins. */
467
+ .UnmagicCheck,
468
+ .UnmagicRadio {
469
+ @variant dark {
470
+ &::before {
471
+ @apply bg-neutral-900;
472
+ }
473
+ }
474
+ }
475
+
476
+ .UnmagicCheck:checked,
477
+ .UnmagicCheck:indeterminate,
478
+ .UnmagicRadio:checked {
479
+ @apply border-neutral-900 bg-neutral-900 dark:border-white dark:bg-white;
480
+ }
481
+
482
+ .UnmagicCheck:checked::before,
483
+ .UnmagicCheck:indeterminate::before,
484
+ .UnmagicRadio:checked::before {
485
+ @apply visible;
486
+ }
487
+
488
+ /* A bar rather than a tick, for a box standing for some of several. */
489
+ .UnmagicCheck:indeterminate::before {
490
+ @apply h-0.5;
491
+ clip-path: none;
492
+ }
493
+
494
+ .UnmagicCheck:focus-visible,
495
+ .UnmagicRadio:focus-visible {
496
+ @apply outline-2 outline-offset-2 outline-neutral-400 dark:outline-neutral-500;
497
+ }
498
+
499
+ .UnmagicCheck[aria-invalid="true"],
500
+ .UnmagicRadio[aria-invalid="true"] {
501
+ @apply border-red-600 dark:border-red-400;
502
+ }
503
+
504
+ .UnmagicCheck:disabled,
505
+ .UnmagicRadio:disabled {
506
+ @apply cursor-not-allowed opacity-50;
507
+ }
508
+
509
+ /* Forced colours drop the gradient arrow and repaint the checked box, which
510
+ would hide the mark; hand both back to the system. */
511
+ @media (forced-colors: active) {
512
+ .UnmagicSelect {
513
+ appearance: auto;
514
+ padding-right: 0.75rem;
515
+ background-image: none;
516
+ }
517
+
518
+ .UnmagicCheck::before,
519
+ .UnmagicRadio::before {
520
+ forced-color-adjust: none;
521
+ background-color: CanvasText;
522
+ }
523
+ }
524
+
525
+ /* ---------------------------------------------------------------- Buttons */
526
+
527
+ /* A plain, unobtrusive button, so an unconfigured form still has one that looks
528
+ deliberate. Most apps point config.submit_class at their own button helper. */
529
+ .UnmagicButton {
530
+ font: inherit;
531
+ @apply inline-flex cursor-pointer items-center justify-center gap-2 rounded-md border border-neutral-300 bg-white px-3.5 py-1.5 text-sm font-medium whitespace-nowrap text-neutral-900 no-underline;
532
+ @apply dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100;
533
+ }
534
+
535
+ .UnmagicButton:hover {
536
+ @apply bg-neutral-50 dark:bg-neutral-800;
537
+ }
538
+
539
+ .UnmagicButton:focus-visible {
540
+ @apply outline-2 outline-offset-2 outline-neutral-400 dark:outline-neutral-500;
541
+ }
542
+
543
+ .UnmagicButton:disabled,
544
+ .UnmagicButton[aria-disabled="true"] {
545
+ @apply pointer-events-none opacity-50;
546
+ }
547
+
548
+ .UnmagicButton--primary {
549
+ @apply border-transparent bg-neutral-900 text-white dark:border-transparent dark:bg-white dark:text-neutral-900;
550
+ }
551
+
552
+ .UnmagicButton--primary:hover {
553
+ @apply bg-neutral-900 opacity-90 dark:bg-white;
554
+ }
555
+
556
+ .UnmagicButton--ghost {
557
+ @apply border-transparent bg-transparent dark:border-transparent dark:bg-transparent;
558
+ }
559
+
560
+ /* Styled like the default so the two sit level in an actions row; only the label
561
+ and the hover carry the warning. */
562
+ .UnmagicButton--danger {
563
+ @apply text-red-600 dark:text-red-400;
564
+ }
565
+
566
+ .UnmagicButton--danger:hover {
567
+ @apply bg-red-50 dark:bg-red-400/10;
568
+ }
569
+
570
+ .UnmagicButton--icon {
571
+ @apply border-transparent bg-transparent p-1.5 text-neutral-500;
572
+ @apply dark:border-transparent dark:bg-transparent dark:text-neutral-500;
573
+ }
574
+
575
+ .UnmagicButton--icon:hover {
576
+ @apply text-neutral-900 dark:text-neutral-100;
577
+ }
578
+
579
+ .UnmagicButton--small {
580
+ @apply px-2.5 py-1 text-xs;
581
+ }
582
+
583
+ .UnmagicButton--large {
584
+ @apply rounded-lg px-5 py-2.5 text-base;
585
+ }
586
+
587
+ /* ------------------------------------------------------------------ Icons */
588
+
589
+ .UnmagicIcon {
590
+ @apply size-4 shrink-0;
591
+ }
592
+
593
+ .UnmagicVisuallyHidden {
594
+ @apply sr-only;
595
+ }
596
+
597
+ /* ---------------------------------------------------------------- Dialogs */
598
+
599
+ /* The <dialog> itself is only a transparent positioning box; the panel inside is
600
+ the visible surface. overflow stays visible so a dropdown inside a dialog form
601
+ isn't clipped, which means a dialog keeps its content short enough to fit. */
602
+ .UnmagicDialogBox {
603
+ @apply m-auto max-h-[90dvh] max-w-[calc(100vw-2rem)] overflow-visible border-0 bg-transparent p-0;
604
+ color: inherit;
605
+ }
606
+
607
+ .UnmagicDialogBox:focus {
608
+ @apply outline-none;
609
+ }
610
+
611
+ .UnmagicDialogBox::backdrop {
612
+ @apply bg-black/50;
613
+ backdrop-filter: blur(var(--blur-xs));
614
+ }
615
+
616
+ /* Darker behind a dark panel. After the rule above, which it ties with on
617
+ specificity. */
618
+ .UnmagicDialogBox {
619
+ @variant dark {
620
+ &::backdrop {
621
+ @apply bg-black/60;
622
+ }
623
+ }
624
+ }
625
+
626
+ .UnmagicDialogBox[open] {
627
+ animation: unmagic-dialog-in 150ms ease-out;
628
+ }
629
+
630
+ @keyframes unmagic-dialog-in {
631
+ from {
632
+ opacity: 0;
633
+ transform: translateY(0.5rem) scale(0.98);
634
+ }
635
+ }
636
+
637
+ @media (prefers-reduced-motion: reduce) {
638
+ .UnmagicDialogBox[open] {
639
+ animation: none;
640
+ }
641
+ }
642
+
643
+ .UnmagicDialog {
644
+ @apply w-[min(100vw-2rem,32rem)] rounded-xl border border-neutral-200 bg-white text-sm leading-normal text-neutral-900 shadow-xl;
645
+ @apply dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100;
646
+ }
647
+
648
+ .UnmagicDialog--wide {
649
+ @apply w-[min(100vw-2rem,48rem)];
650
+ }
651
+
652
+ /* margin is reset because this is a <header>, and hosts often style bare
653
+ header elements for their page chrome. */
654
+ .UnmagicDialog__header {
655
+ @apply m-0 flex items-center justify-between gap-3 px-5 pt-4 pb-2;
656
+ }
657
+
658
+ .UnmagicDialog__title {
659
+ @apply m-0 text-base font-semibold;
660
+ }
661
+
662
+ /* Pushed right even when the panel has no title beside it. */
663
+ .UnmagicDialog__close {
664
+ @apply -mr-1.5 ml-auto;
665
+ }
666
+
667
+ .UnmagicDialog__body {
668
+ @apply px-5 py-4;
669
+ }
670
+
671
+ .UnmagicDialog__body > :first-child {
672
+ @apply mt-0;
673
+ }
674
+
675
+ .UnmagicDialog__body > :last-child {
676
+ @apply mb-0;
677
+ }
678
+
679
+ .UnmagicDialog__footer {
680
+ @apply flex items-center justify-end gap-3 px-5 pt-2 pb-4;
681
+ }
682
+
683
+ .UnmagicDialog__error {
684
+ @apply flex items-start gap-3 text-neutral-600 dark:text-neutral-400;
685
+ }
686
+
687
+ .UnmagicDialog__error > .UnmagicIcon {
688
+ @apply mt-0.5 text-red-500 dark:text-red-400;
689
+ }
690
+
691
+ .UnmagicDialog__error > p {
692
+ @apply m-0;
693
+ }
694
+
695
+ .UnmagicDialog__skeleton-field {
696
+ @apply mb-5 grid gap-2;
697
+ }
698
+
699
+ .UnmagicDialog__skeleton-title {
700
+ @apply h-5 w-40;
701
+ }
702
+
703
+ .UnmagicDialog__skeleton-label {
704
+ @apply h-3 w-24;
705
+ }
706
+
707
+ .UnmagicDialog__skeleton-input {
708
+ @apply h-9 rounded-md;
709
+ }
710
+
711
+ .UnmagicDialog__skeleton-button {
712
+ @apply h-9 w-24 rounded-md;
713
+ }
714
+
715
+ /* ----------------------------------------------------------------- Toasts */
716
+
717
+ /* The stack is a manual popover, so the popover defaults (centred, bordered,
718
+ painted) are undone here. It never takes pointer events itself; the toasts
719
+ inside it do. */
720
+ .UnmagicToasts__stack {
721
+ @apply pointer-events-none fixed z-60 m-0 flex w-[min(100vw-2rem,24rem)] flex-col gap-2 overflow-visible border-0 bg-transparent p-0;
722
+ inset: 1rem 1rem auto auto;
723
+ color: inherit;
724
+ }
725
+
726
+ .UnmagicToast {
727
+ @apply pointer-events-auto flex items-start gap-2 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 opacity-0 shadow-lg;
728
+ @apply dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100;
729
+ transform: translateY(-0.5rem);
730
+ transition: opacity 200ms ease, transform 200ms ease;
731
+ }
732
+
733
+ .UnmagicToast[data-open] {
734
+ @apply opacity-100;
735
+ transform: none;
736
+ }
737
+
738
+ @media (prefers-reduced-motion: reduce) {
739
+ .UnmagicToast {
740
+ transform: none;
741
+ transition: none;
742
+ }
743
+ }
744
+
745
+ .UnmagicToast__icon {
746
+ @apply mt-0.5;
747
+ }
748
+
749
+ .UnmagicToast__message {
750
+ @apply min-w-0 flex-auto wrap-anywhere;
751
+ }
752
+
753
+ .UnmagicToast__dismiss {
754
+ @apply -my-0.5 mr-[-0.25rem] ml-0 inline-flex shrink-0 cursor-pointer rounded-sm border-0 bg-transparent p-1 opacity-60;
755
+ color: inherit;
756
+ }
757
+
758
+ .UnmagicToast__dismiss:hover,
759
+ .UnmagicToast__dismiss:focus-visible {
760
+ @apply opacity-100;
761
+ }
762
+
763
+ .UnmagicToast__dismiss .UnmagicIcon {
764
+ @apply size-3.5;
765
+ }
766
+
767
+ /* A toast floats over the page, so its dark tone surfaces are opaque rather
768
+ than the translucent tints a callout uses in place. */
769
+ .UnmagicToast--good {
770
+ @apply border-green-200 bg-green-50 text-green-700;
771
+ @apply dark:border-green-900 dark:bg-green-950 dark:text-green-400;
772
+ }
773
+
774
+ .UnmagicToast--warn {
775
+ @apply border-amber-200 bg-amber-50 text-amber-700;
776
+ @apply dark:border-amber-900 dark:bg-amber-950 dark:text-amber-400;
777
+ }
778
+
779
+ .UnmagicToast--bad {
780
+ @apply border-red-200 bg-red-50 text-red-700;
781
+ @apply dark:border-red-900 dark:bg-red-950 dark:text-red-400;
782
+ }
783
+
784
+ .UnmagicToast--info .UnmagicToast__icon {
785
+ @apply text-neutral-500;
786
+ }
787
+
788
+ /* ----------------------------------------------------------------- Badges */
789
+
790
+ .UnmagicBadge {
791
+ @apply inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium whitespace-nowrap text-neutral-600;
792
+ @apply dark:bg-neutral-800 dark:text-neutral-400;
793
+ }
794
+
795
+ .UnmagicBadge--good {
796
+ @apply bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-400;
797
+ }
798
+
799
+ .UnmagicBadge--warn {
800
+ @apply bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-400;
801
+ }
802
+
803
+ .UnmagicBadge--bad {
804
+ @apply bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-400;
805
+ }
806
+
807
+ /* Neutral, but outlined, so it reads as a note rather than a state. */
808
+ .UnmagicBadge--info {
809
+ @apply bg-transparent inset-ring inset-ring-neutral-300 dark:bg-transparent dark:inset-ring-neutral-700;
810
+ }
811
+
812
+ .UnmagicBadge--accent {
813
+ @apply bg-neutral-900 text-white dark:bg-white dark:text-neutral-900;
814
+ }
815
+
816
+ /* ------------------------------------------------------------------ Cards */
817
+
818
+ /* No overflow: hidden, so a dropdown inside a card isn't clipped; the footer
819
+ rounds its own corners instead. A column whose body grows, so a card stretched
820
+ to its row's height in a grid keeps its footer on the bottom edge. */
821
+ .UnmagicCard {
822
+ @apply flex flex-col rounded-xl border border-neutral-200 bg-white text-neutral-900;
823
+ @apply dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100;
824
+ }
825
+
826
+ .UnmagicCard--link {
827
+ @apply no-underline;
828
+ transition: border-color 150ms ease, box-shadow 150ms ease;
829
+ }
830
+
831
+ .UnmagicCard--link:hover {
832
+ @apply border-neutral-300 dark:border-neutral-700;
833
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.08);
834
+ }
835
+
836
+ .UnmagicCard--link:focus-visible {
837
+ @apply outline-2 outline-offset-2 outline-neutral-400 dark:outline-neutral-500;
838
+ }
839
+
840
+ .UnmagicCard__header {
841
+ @apply m-0 flex items-center justify-between gap-3 px-4 pt-4 pb-0;
842
+ }
843
+
844
+ .UnmagicCard__title {
845
+ @apply m-0 text-base font-semibold;
846
+ }
847
+
848
+ .UnmagicCard__actions {
849
+ @apply ml-auto flex items-center gap-2;
850
+ }
851
+
852
+ .UnmagicCard__body {
853
+ @apply flex-auto p-4 text-sm leading-normal;
854
+ }
855
+
856
+ .UnmagicCard__body--flush {
857
+ @apply p-0;
858
+ }
859
+
860
+ .UnmagicCard__header + .UnmagicCard__body--flush {
861
+ @apply mt-4;
862
+ }
863
+
864
+ /* A table in a flush card borrows the card's edge rather than drawing its own. */
865
+ .UnmagicCard__body--flush > .UnmagicTable {
866
+ @apply rounded-none border-0;
867
+ }
868
+
869
+ .UnmagicCard__footer {
870
+ @apply flex items-center justify-end gap-3 border-t border-neutral-200 bg-neutral-50 px-4 py-3 text-neutral-600;
871
+ @apply dark:border-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-400;
872
+ font-size: var(--text-sm);
873
+ border-radius: 0 0 calc(var(--radius-xl) - 1px) calc(var(--radius-xl) - 1px);
874
+ }
875
+
876
+ /* ----------------------------------------------------------- Page headers */
877
+
878
+ .UnmagicPageHeader {
879
+ @apply mx-0 mt-0 mb-6 p-0;
880
+ }
881
+
882
+ .UnmagicPageHeader__back {
883
+ @apply mb-2 inline-flex items-center gap-1 text-neutral-500 no-underline;
884
+ font-size: var(--text-sm);
885
+ }
886
+
887
+ .UnmagicPageHeader__back:hover {
888
+ @apply text-neutral-900 dark:text-neutral-100;
889
+ }
890
+
891
+ /* The title column shrinks and wraps, so a long description sits beside the
892
+ actions rather than pushing them onto a row of their own — until there's no
893
+ room, when the actions drop below. */
894
+ .UnmagicPageHeader__row {
895
+ @apply flex flex-wrap items-start justify-between gap-4;
896
+ }
897
+
898
+ .UnmagicPageHeader__main {
899
+ @apply min-w-0 flex-[1_1_20rem];
900
+ }
901
+
902
+ .UnmagicPageHeader__heading {
903
+ @apply flex flex-wrap items-center gap-3;
904
+ }
905
+
906
+ .UnmagicPageHeader__title {
907
+ @apply m-0 text-2xl font-semibold text-neutral-900 dark:text-neutral-100;
908
+ letter-spacing: -0.01em;
909
+ }
910
+
911
+ .UnmagicPageHeader__description {
912
+ @apply mt-1 max-w-2xl text-sm leading-normal text-neutral-500;
913
+ }
914
+
915
+ .UnmagicPageHeader__actions {
916
+ @apply flex shrink-0 items-center gap-3;
917
+ }
918
+
919
+ /* --------------------------------------------------------------- Callouts */
920
+
921
+ .UnmagicCallout {
922
+ @apply flex items-start gap-3 rounded-lg border border-neutral-200 p-4 text-sm leading-normal text-neutral-600;
923
+ @apply dark:border-neutral-800 dark:text-neutral-400;
924
+ }
925
+
926
+ .UnmagicCallout__icon {
927
+ @apply mt-0.5;
928
+ }
929
+
930
+ .UnmagicCallout__content {
931
+ @apply min-w-0 flex-auto;
932
+ }
933
+
934
+ .UnmagicCallout__heading {
935
+ @apply mb-1 flex flex-wrap items-center gap-2;
936
+ }
937
+
938
+ .UnmagicCallout__title {
939
+ @apply m-0 font-semibold text-neutral-900 dark:text-neutral-100;
940
+ }
941
+
942
+ .UnmagicCallout__body > :first-child {
943
+ @apply mt-0;
944
+ }
945
+
946
+ .UnmagicCallout__body > :last-child {
947
+ @apply mb-0;
948
+ }
949
+
950
+ .UnmagicCallout--good {
951
+ @apply border-green-200 bg-green-50 dark:border-green-400/30 dark:bg-green-400/10;
952
+ }
953
+
954
+ .UnmagicCallout--good .UnmagicCallout__icon {
955
+ @apply text-green-700 dark:text-green-400;
956
+ }
957
+
958
+ .UnmagicCallout--warn {
959
+ @apply border-amber-200 bg-amber-50 dark:border-amber-400/30 dark:bg-amber-400/10;
960
+ }
961
+
962
+ .UnmagicCallout--warn .UnmagicCallout__icon {
963
+ @apply text-amber-700 dark:text-amber-400;
964
+ }
965
+
966
+ .UnmagicCallout--bad {
967
+ @apply border-red-200 bg-red-50 dark:border-red-400/30 dark:bg-red-400/10;
968
+ }
969
+
970
+ .UnmagicCallout--bad .UnmagicCallout__icon {
971
+ @apply text-red-700 dark:text-red-400;
972
+ }
973
+
974
+ .UnmagicCallout--info {
975
+ @apply bg-neutral-50 dark:bg-neutral-800/50;
976
+ }
977
+
978
+ .UnmagicCallout--info .UnmagicCallout__icon {
979
+ @apply text-neutral-500;
980
+ }
981
+
982
+ /* --------------------------------------------------------------- Tooltips */
983
+
984
+ /* A term explains itself, so it looks like one before the script upgrades it. */
985
+ .UnmagicTooltip--term {
986
+ @apply cursor-help decoration-neutral-500 underline-offset-[0.2em];
987
+ text-decoration-line: underline;
988
+ text-decoration-style: dashed;
989
+ }
990
+
991
+ .UnmagicTooltip--term:focus-visible {
992
+ @apply rounded-xs outline-2 outline-offset-2 outline-neutral-400 dark:outline-neutral-500;
993
+ }
994
+
995
+ /* A manual popover, placed by the element; the popover defaults are undone and
996
+ the host's type styles reset, since the hint inherits from wherever it sits. */
997
+ .UnmagicTooltip__popup {
998
+ @apply pointer-events-none fixed m-0 w-max max-w-[min(20rem,calc(100vw-1rem))] rounded-md border-0 bg-neutral-900 px-2.5 py-1.5 text-left text-xs font-normal tracking-normal whitespace-normal text-white normal-case not-italic opacity-0 shadow-md;
999
+ @apply dark:bg-neutral-700 dark:text-neutral-100;
1000
+ inset: auto;
1001
+ line-height: 1.4;
1002
+ transition: opacity 100ms ease;
1003
+ }
1004
+
1005
+ .UnmagicTooltip__popup[data-open] {
1006
+ @apply opacity-100;
1007
+ }
1008
+
1009
+ @media (prefers-reduced-motion: reduce) {
1010
+ .UnmagicTooltip__popup {
1011
+ transition: none;
1012
+ }
1013
+ }
1014
+
1015
+ /* ------------------------------------------------------------------ Menus */
1016
+
1017
+ .UnmagicMenu {
1018
+ @apply relative inline-block;
1019
+ }
1020
+
1021
+ .UnmagicMenu__trigger {
1022
+ @apply list-none;
1023
+ }
1024
+
1025
+ .UnmagicMenu__trigger::-webkit-details-marker {
1026
+ display: none;
1027
+ }
1028
+
1029
+ .UnmagicMenu__panel {
1030
+ @apply absolute z-50 flex min-w-44 flex-col rounded-lg border border-neutral-200 bg-white p-1 shadow-lg;
1031
+ @apply dark:border-neutral-800 dark:bg-neutral-900;
1032
+ top: calc(100% + 0.25rem);
1033
+ }
1034
+
1035
+ .UnmagicMenu__panel--end {
1036
+ @apply right-0;
1037
+ }
1038
+
1039
+ .UnmagicMenu__panel--start {
1040
+ @apply left-0;
1041
+ }
1042
+
1043
+ /* button_to wraps each button in a form; this keeps it out of the column's way. */
1044
+ .UnmagicMenu__form {
1045
+ @apply contents;
1046
+ }
1047
+
1048
+ .UnmagicMenu__item {
1049
+ font: inherit;
1050
+ @apply flex w-full cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2.5 py-1.5 text-left text-sm whitespace-nowrap text-neutral-900 no-underline;
1051
+ @apply dark:text-neutral-100;
1052
+ }
1053
+
1054
+ .UnmagicMenu__item:hover,
1055
+ .UnmagicMenu__item:focus-visible {
1056
+ @apply bg-neutral-100 outline-none dark:bg-neutral-800;
1057
+ }
1058
+
1059
+ .UnmagicMenu__item .UnmagicIcon {
1060
+ @apply text-neutral-500;
1061
+ }
1062
+
1063
+ .UnmagicMenu__item--danger,
1064
+ .UnmagicMenu__item--danger .UnmagicIcon {
1065
+ @apply text-red-600 dark:text-red-400;
1066
+ }
1067
+
1068
+ .UnmagicMenu__item--danger:hover,
1069
+ .UnmagicMenu__item--danger:focus-visible {
1070
+ @apply bg-red-50 dark:bg-red-400/10;
1071
+ }
1072
+
1073
+ .UnmagicMenu__divider {
1074
+ @apply -mx-1 my-1 border-0 border-t border-neutral-200 dark:border-neutral-800;
1075
+ }
1076
+
1077
+ /* ------------------------------------------------------------------- Tabs */
1078
+
1079
+ /* A segmented control: an inset track with the selected tab raised out of it.
1080
+ In a dark theme the selected tab raises by lightening, rather than by staying
1081
+ the page's own surface colour. */
1082
+ .UnmagicTabs {
1083
+ @apply block;
1084
+ }
1085
+
1086
+ .UnmagicTabs__list {
1087
+ @apply inline-flex flex-wrap gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800;
1088
+ }
1089
+
1090
+ /* margin is reset because link tabs sit in a <nav>, and hosts often style bare
1091
+ nav links for their page chrome. */
1092
+ .UnmagicTabs__tab {
1093
+ font: inherit;
1094
+ @apply m-0 inline-flex cursor-pointer items-center gap-1.5 rounded-md border-0 bg-transparent px-3 py-1.5 text-sm font-medium whitespace-nowrap text-neutral-500 no-underline;
1095
+ }
1096
+
1097
+ .UnmagicTabs__tab:hover {
1098
+ @apply text-neutral-900 dark:text-neutral-100;
1099
+ }
1100
+
1101
+ .UnmagicTabs__tab[aria-selected="true"],
1102
+ .UnmagicTabs__tab[aria-current="page"] {
1103
+ @apply bg-white text-neutral-900 dark:bg-neutral-700 dark:text-neutral-100;
1104
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08);
1105
+ }
1106
+
1107
+ .UnmagicTabs__tab:focus-visible,
1108
+ .UnmagicTabs__panel:focus-visible {
1109
+ @apply outline-2 outline-offset-2 outline-neutral-400 dark:outline-neutral-500;
1110
+ }
1111
+
1112
+ .UnmagicTabs__tab[aria-disabled="true"] {
1113
+ @apply cursor-not-allowed;
1114
+ opacity: 0.55;
1115
+ }
1116
+
1117
+ .UnmagicTabs__tab[aria-disabled="true"]:hover {
1118
+ @apply text-neutral-500 dark:text-neutral-500;
1119
+ }
1120
+
1121
+ .UnmagicTabs__reason {
1122
+ @apply font-normal;
1123
+ }
1124
+
1125
+ .UnmagicTabs__panel {
1126
+ @apply rounded-sm pt-4;
1127
+ }
1128
+
1129
+ /* -------------------------------------------------------------- Clipboard */
1130
+
1131
+ .UnmagicClipboard {
1132
+ @apply inline-flex align-middle;
1133
+ }
1134
+
1135
+ .UnmagicClipboard__done {
1136
+ @apply hidden;
1137
+ }
1138
+
1139
+ .UnmagicClipboard[data-copied] .UnmagicClipboard__idle {
1140
+ @apply hidden;
1141
+ }
1142
+
1143
+ .UnmagicClipboard[data-copied] .UnmagicClipboard__done {
1144
+ @apply block;
1145
+ }
1146
+
1147
+ .UnmagicClipboard[data-copied] .UnmagicClipboard__button {
1148
+ @apply text-green-700 dark:text-green-400;
1149
+ }
1150
+
1151
+ /* ------------------------------------------------------- Autogrow textarea */
1152
+
1153
+ /* The element sets the height, so a manual resize handle would only fight it. Give
1154
+ the textarea a max-height to cap the growth. A descendant rather than a child
1155
+ selector: Rails wraps an invalid control in a field_with_errors div, inside the
1156
+ element. */
1157
+ .UnmagicAutogrow {
1158
+ @apply block;
1159
+ }
1160
+
1161
+ .UnmagicAutogrow textarea {
1162
+ @apply resize-none overflow-y-hidden;
1163
+ }
1164
+ }