@akonwi/mica 0.1.0 → 0.1.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/README.md CHANGED
@@ -70,11 +70,21 @@ CSS by spec — overriding mica never requires specificity games. Theming is
70
70
  swapping token values (`--hue`, semantic color roles, spacing scale); dark
71
71
  mode is `light-dark()` — automatic, no classes.
72
72
 
73
+ ## Browser support
74
+
75
+ Mica targets [Baseline](https://web.dev/baseline) widely-available.
76
+ Newer features (anchor positioning, customizable select) are used behind
77
+ graceful fallbacks — each component's docs page notes what degrades and
78
+ how. Nothing breaks; some things get plainer.
79
+
73
80
  ## Docs
74
81
 
75
- Read [VISION.md](VISION.md) for the philosophy, [TIERS.md](TIERS.md) for
76
- the tier system, and the `docs/` pages for every component. Or open
77
- `demo.html` — the whole library on one page.
82
+ **[akonwi.io/mica](https://akonwi.io/mica/)** every component, built with
83
+ mica itself (view source). The [demo](https://akonwi.io/mica/demo.html) is
84
+ the whole library on one page.
85
+
86
+ Read [VISION.md](VISION.md) for the philosophy and [TIERS.md](TIERS.md)
87
+ for the tier system.
78
88
 
79
89
  ## License
80
90
 
package/drawer.js ADDED
@@ -0,0 +1,80 @@
1
+ /* mica/drawer.js — Tier 2: swipe-to-dismiss for dialog.drawer bottom
2
+ * sheets on small screens.
3
+ *
4
+ * Enhances working markup, never replaces it: without this module the
5
+ * drawer opens/closes via buttons and Esc; with it, the mobile sheet
6
+ * grows a grab handle (CSS keys off :root[data-drawer-gestures] — an
7
+ * affordance may only appear when the behavior exists) and vaul-style
8
+ * drag physics: the sheet follows a downward drag, resists upward,
9
+ * and dismisses past 1/3 of its height or on a flick.
10
+ *
11
+ * Drag surface: the handle / header strip. Drag-from-content is
12
+ * deliberately out of scope — it requires scroll-vs-drag heuristics
13
+ * (vaul's hardest code) for little gain over a clear grab target.
14
+ */
15
+
16
+ const SHEET = matchMedia("(max-width: 40rem)");
17
+ const CLOSE_DISTANCE = 1 / 3; // of sheet height
18
+ const CLOSE_VELOCITY = 0.6; // px/ms, downward flick
19
+
20
+ document.documentElement.setAttribute("data-drawer-gestures", "");
21
+
22
+ document.addEventListener("pointerdown", (down) => {
23
+ if (!SHEET.matches) return;
24
+ const dialog =
25
+ down.target instanceof Element &&
26
+ down.target.closest("dialog.drawer[open]");
27
+ if (!dialog) return;
28
+ if (down.target.closest("button, a, input, select, textarea")) return;
29
+
30
+ // start only from the header or the top (handle) strip
31
+ const rect = dialog.getBoundingClientRect();
32
+ const inHeader = down.target.closest("dialog.drawer > header");
33
+ if (!inHeader && down.clientY - rect.top > 32) return;
34
+
35
+ let dy = 0;
36
+ let lastY = down.clientY;
37
+ let lastT = down.timeStamp;
38
+ let velocity = 0;
39
+
40
+ dialog.setPointerCapture(down.pointerId);
41
+ const baseTransition = dialog.style.transition;
42
+ dialog.style.transition = "none";
43
+
44
+ const move = (e) => {
45
+ dy = e.clientY - down.clientY;
46
+ velocity = (e.clientY - lastY) / Math.max(1, e.timeStamp - lastT);
47
+ lastY = e.clientY;
48
+ lastT = e.timeStamp;
49
+ // follow downward; resist upward
50
+ dialog.style.translate = `0 ${dy > 0 ? dy : dy / 4}px`;
51
+ };
52
+
53
+ const up = () => {
54
+ dialog.removeEventListener("pointermove", move);
55
+ dialog.removeEventListener("pointerup", up);
56
+ dialog.removeEventListener("pointercancel", up);
57
+ dialog.style.transition = baseTransition;
58
+ if (dy > rect.height * CLOSE_DISTANCE || velocity > CLOSE_VELOCITY) {
59
+ // continue the motion DOWNWARD off-screen. close() fires its exit
60
+ // (backdrop fade + display flip at the end via allow-discrete);
61
+ // the inline translate overrides the small CSS exit offset so the
62
+ // sheet keeps sliding down instead of snapping back up into view
63
+ // (the flash). Clear it only at transitionend — display:none by
64
+ // then, so clearing is invisible and the next open starts clean.
65
+ dialog.addEventListener(
66
+ "transitionend",
67
+ (e) => { if (e.propertyName === "translate") dialog.style.translate = ""; },
68
+ { once: true },
69
+ );
70
+ requestAnimationFrame(() => { dialog.style.translate = "0 100dvh"; });
71
+ dialog.close();
72
+ } else {
73
+ dialog.style.translate = ""; // spring back
74
+ }
75
+ };
76
+
77
+ dialog.addEventListener("pointermove", move);
78
+ dialog.addEventListener("pointerup", up);
79
+ dialog.addEventListener("pointercancel", up);
80
+ });
package/invoker.js ADDED
@@ -0,0 +1,25 @@
1
+ /* mica/invoker.js — temporary shim for <button commandfor command>.
2
+ *
3
+ * Invoker commands are Baseline newly-available (Chrome 135+, Firefox
4
+ * 144+, Safari/iOS 26.2+). Mica's dialog recipes use them; include this
5
+ * shim until support is widely available. Where the browser handles
6
+ * commands natively, this module installs nothing.
7
+ *
8
+ * Scope: the dialog commands mica's recipes use — show-modal and close.
9
+ * (Popover recipes use popovertarget, which is already widely available.)
10
+ * Note: in shimmed browsers a commandfor button inside a <form> would
11
+ * still submit it — mica's recipes never place one there.
12
+ */
13
+ if (!("commandForElement" in HTMLButtonElement.prototype)) {
14
+ document.addEventListener("click", (event) => {
15
+ const button =
16
+ event.target instanceof Element &&
17
+ event.target.closest("button[commandfor][command]");
18
+ if (!button || button.disabled) return;
19
+ const dialog = document.getElementById(button.getAttribute("commandfor"));
20
+ if (!(dialog instanceof HTMLDialogElement)) return;
21
+ const command = button.getAttribute("command");
22
+ if (command === "show-modal" && !dialog.open) dialog.showModal();
23
+ else if (command === "close" && dialog.open) dialog.close();
24
+ });
25
+ }
package/mica.css CHANGED
@@ -44,11 +44,17 @@
44
44
  --focus-ring-offset: 2px;
45
45
  --focus-ring-color: var(--color-accent);
46
46
 
47
- /* checkmark glyph — SVG URIs can't read custom properties, so this
48
- ships as a light-dark pair tracking on-primary (white / near-black) */
49
- --check-glyph: light-dark(
50
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E"),
51
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23151922' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E"));
47
+ /* checkmark glyph — SVG URIs can't read custom properties, and
48
+ light-dark() only accepts <color>s: feeding it url()s is invalid
49
+ (Chromium computes it to none; iOS Safari drops the glyph at
50
+ paint). So: light glyph by default, dark swapped in by the media
51
+ query after this block. The pair tracks on-primary (white /
52
+ near-black, since primary inverts in dark).
53
+ Forcing a scheme against the OS? Also pin the glyph:
54
+ :root { color-scheme: dark; --check-glyph: var(--check-glyph-dark); } */
55
+ --check-glyph-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E");
56
+ --check-glyph-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23151922' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E");
57
+ --check-glyph: var(--check-glyph-light);
52
58
 
53
59
  /* -------------------------------------------------------------- *
54
60
  * Color. Two knobs — change the theme by changing the knobs:
@@ -151,6 +157,14 @@
151
157
  --color-on-warn: oklch(20% 0.03 var(--warn-hue));
152
158
  --color-warn-text: light-dark(oklch(42% calc(var(--status-chroma) * 0.9) var(--warn-hue)), oklch(80% calc(var(--status-chroma) * 0.9) var(--warn-hue)));
153
159
  }
160
+
161
+ /* scheme swap for image-valued tokens — the one place light-dark()
162
+ can't help (colors only). See --check-glyph above. */
163
+ @media (prefers-color-scheme: dark) {
164
+ :root {
165
+ --check-glyph: var(--check-glyph-dark);
166
+ }
167
+ }
154
168
  }
155
169
 
156
170
  /* ------------------------------------------------------------------ *
@@ -241,6 +255,14 @@
241
255
  transition-duration: 0.01ms !important;
242
256
  scroll-behavior: auto !important;
243
257
  }
258
+ /* `*` never matches pseudo-elements — ::backdrop needs its own rule
259
+ (and per convention gets its own block: an unrecognized selector
260
+ in the list above would kill the whole kill-switch). Found via
261
+ snapshot flakiness: the backdrop fade survived reduced-motion. */
262
+ *::backdrop {
263
+ animation-duration: 0.01ms !important;
264
+ transition-duration: 0.01ms !important;
265
+ }
244
266
  }
245
267
  }
246
268
 
@@ -808,25 +830,49 @@
808
830
  /* composition: dialog > header (title + description) / body / footer.
809
831
  layout lives on [open] — unconditional display would defeat the
810
832
  UA's display:none on closed dialogs */
811
- :where(dialog[open]) {
812
- display: flex;
833
+ /* Composition is UNCONDITIONAL so it survives the exit: the dialog
834
+ stays rendered (display transitions via allow-discrete) for 0.15s
835
+ after [open] is removed. Structure gated on [open] snaps back to
836
+ defaults the instant [open] goes — flex-direction reverts to row,
837
+ children lose their sizing — and the content visibly deforms while
838
+ fading out. Only `display` stays conditional (unconditional flex
839
+ would defeat the UA's display:none on a closed dialog); opacity and
840
+ scale are the open visual state that transitions on exit. */
841
+ :where(dialog) {
813
842
  flex-direction: column;
814
- opacity: 1;
815
- scale: 1;
816
843
 
817
844
  & > :where(*) {
818
845
  margin-block: 0; /* the dialog owns spacing, like a stack */
819
846
  }
820
847
 
821
848
  /* the body: whatever sits between header and footer. one wrapper
822
- element; it pads itself and is the scrollable region */
849
+ element; it pads itself and is the scrollable region.
850
+ flex-basis must stay `auto` (not `flex: 1`'s 0%): iOS Safari sizes
851
+ the dialog's fit-content height from the basis, so 0% collapses
852
+ the body to a one-line scroll strip (iOS 26.0; Chromium and newer
853
+ WebKit use the content contribution either way). */
823
854
  & > :where(:not(header, footer, button.close)) {
824
855
  padding: var(--space-md);
825
- flex: 1;
856
+ flex: 1 1 auto;
826
857
  min-block-size: 0;
827
858
  overflow-y: auto;
859
+ /* break the scroll chain: at the region's scroll limit iOS
860
+ otherwise hands the gesture to the page behind the modal —
861
+ the root's overflow:hidden can't stop a chained scroll. */
862
+ overscroll-behavior: contain;
828
863
  font-size: 0.875rem; /* body must not outweigh the 14px title */
829
864
  }
865
+
866
+ /* when the dialog hits its max height, only the body compresses */
867
+ & > :where(header, footer) {
868
+ flex-shrink: 0;
869
+ }
870
+ }
871
+
872
+ :where(dialog[open]) {
873
+ display: flex;
874
+ opacity: 1;
875
+ scale: 1;
830
876
  }
831
877
 
832
878
  /* corner close button — <button class="close" commandfor="…"
@@ -906,7 +952,18 @@
906
952
  }
907
953
 
908
954
  :where(dialog)::backdrop {
909
- background: transparent;
955
+ /* explicit oklch alpha, not the `transparent` keyword: iOS won't
956
+ interpolate oklch()→transparent and cuts the scrim instantly,
957
+ desyncing it from the sheet. same-space endpoints animate. */
958
+ background: oklch(0 0 0 / 0);
959
+ /* size to the LARGE viewport, don't trust inset: shipped iOS sizes
960
+ the top layer to the small viewport, so an inset-based backdrop
961
+ ends above the real bottom edge when the toolbar collapses —
962
+ page content peeked through the strip. (Radix/vaul overlays are
963
+ ordinary fixed divs and don't have this problem; ::backdrop
964
+ lives in the top layer, so we overpaint instead.) */
965
+ inline-size: 100lvw;
966
+ block-size: 100lvh;
910
967
  transition:
911
968
  background-color 0.15s,
912
969
  overlay 0.15s allow-discrete,
@@ -917,7 +974,7 @@
917
974
  }
918
975
  @starting-style {
919
976
  :where(dialog[open])::backdrop {
920
- background: transparent;
977
+ background: oklch(0 0 0 / 0);
921
978
  }
922
979
  }
923
980
 
@@ -927,8 +984,14 @@
927
984
  :where(dialog.drawer) {
928
985
  margin-inline: auto 0;
929
986
  margin-block: 0;
930
- block-size: 100svh;
931
- max-block-size: 100svh;
987
+ /* full height WITHOUT an explicit block-size: the UA gives
988
+ dialog:modal `position: fixed; inset-block: 0` — leaving height
989
+ auto stretches between both edges, and on iOS that bottom edge
990
+ tracks toolbar collapse like any bottom-fixed bar. Explicit
991
+ 100svh left a gap behind the collapsed toolbar; 100dvh lagged
992
+ in the top layer on shipped iOS. Don't reintroduce either. */
993
+ block-size: auto;
994
+ max-block-size: none;
932
995
  inline-size: 75vw;
933
996
  max-inline-size: 24rem; /* sheet's max-w-sm */
934
997
  border: 0;
@@ -940,10 +1003,10 @@
940
1003
  }
941
1004
  :where(dialog.drawer[open]) {
942
1005
  translate: 0 0;
943
-
944
- & > :where(:not(header, footer, button.close)) {
945
- font-size: inherit; /* panel runs 12/relaxed throughout */
946
- }
1006
+ }
1007
+ /* unconditional (survives the exit, like the rest of composition) */
1008
+ :where(dialog.drawer) > :where(:not(header, footer, button.close)) {
1009
+ font-size: inherit; /* panel runs 12/relaxed throughout */
947
1010
  }
948
1011
  @starting-style {
949
1012
  :where(dialog.drawer[open]) {
@@ -962,6 +1025,90 @@
962
1025
  border-block-start: 0;
963
1026
  }
964
1027
 
1028
+ /* small screens: the drawer becomes a bottom sheet — shadcn's own
1029
+ mobile drawer (vaul/radix) is exactly this. Anchored to the bottom
1030
+ edge via auto top margin: bottom attachment is the one thing iOS
1031
+ tracks reliably through toolbar collapse (same mechanism as bottom
1032
+ nav bars). Explicit 100svh gapped behind the collapsed toolbar and
1033
+ 100dvh lagged in the top layer — don't reintroduce heights here. */
1034
+ @media (max-width: 40rem) {
1035
+ :where(dialog.drawer) {
1036
+ inset-block: auto 0; /* release the UA's top anchor… */
1037
+ margin-block: 0; /* …so height resolves from content */
1038
+ block-size: auto; /* content height, like vaul */
1039
+ max-block-size: calc(100dvh - 3rem);
1040
+ inline-size: 100vw;
1041
+ max-inline-size: none;
1042
+ border-inline-start: 0;
1043
+ border-block-start: 1px solid var(--color-border);
1044
+ /* Two box-shadows, both painted by the SHEET element so they
1045
+ persist through the exit (display:allow-discrete keeps the box
1046
+ rendered) — unlike ::backdrop, which iOS drops from the top
1047
+ layer the instant you close, cutting the scrim. So the dim
1048
+ rides the sheet here and stays in sync with the slide:
1049
+ 1) bleed slab below the edge (vaul's trick) — fills any strip
1050
+ iOS exposes under the top layer when the toolbar collapses;
1051
+ 2) full-screen scrim via 100vmax spread — the visible dim,
1052
+ its alpha animating with the sheet. 100vmax keeps coverage
1053
+ even as the shadow rides the sliding sheet down. */
1054
+ box-shadow:
1055
+ 0 4rem 0 0 var(--color-surface-overlay),
1056
+ 0 0 0 100vmax oklch(0 0 0 / 0);
1057
+ transition:
1058
+ box-shadow 0.15s, translate 0.15s,
1059
+ overlay 0.15s allow-discrete, display 0.15s allow-discrete;
1060
+ /* the sheet SLIDES fully off the bottom and stays opaque — only
1061
+ the scrim fades. A small nudge + opacity fade read as a
1062
+ dissolve-in-place, wrong for a bottom sheet. */
1063
+ translate: 0 100%;
1064
+ opacity: 1;
1065
+ }
1066
+ :where(dialog.drawer[open]) {
1067
+ translate: 0 0;
1068
+ box-shadow:
1069
+ 0 4rem 0 0 var(--color-surface-overlay),
1070
+ 0 0 0 100vmax oklch(0 0 0 / 0.4);
1071
+ }
1072
+ @starting-style {
1073
+ :where(dialog.drawer[open]) {
1074
+ translate: 0 100%;
1075
+ box-shadow:
1076
+ 0 4rem 0 0 var(--color-surface-overlay),
1077
+ 0 0 0 100vmax oklch(0 0 0 / 0);
1078
+ }
1079
+ }
1080
+ /* the box-shadow scrim replaces ::backdrop here; keep the native
1081
+ backdrop clear so they don't double up or flash on the iOS cut. */
1082
+ :where(dialog.drawer[open])::backdrop {
1083
+ background: oklch(0 0 0 / 0);
1084
+ }
1085
+
1086
+ /* grab handle — only when drawer.js is loaded (an affordance
1087
+ without its behavior would fake interactivity; TIERS.md).
1088
+ the handle is the first flex item of the open sheet. */
1089
+ :where(:root[data-drawer-gestures]) :where(dialog.drawer[open])::before {
1090
+ content: "";
1091
+ align-self: center;
1092
+ flex-shrink: 0;
1093
+ inline-size: 2.5rem;
1094
+ block-size: 0.25rem;
1095
+ margin-block-start: var(--space-sm);
1096
+ background: var(--color-border-strong);
1097
+ border-radius: var(--radius-full);
1098
+ }
1099
+ :where(:root[data-drawer-gestures]) :where(dialog.drawer) > :where(header) {
1100
+ touch-action: none; /* the drag surface must own its touches */
1101
+ }
1102
+ }
1103
+
1104
+ /* scroll lock: the page must not scroll behind any modal dialog.
1105
+ CSS-only best effort (→ no JS tier violation); iOS honors
1106
+ overflow:hidden on the root for modern versions. */
1107
+ :where(html):has(:where(dialog:modal)) {
1108
+ overflow: hidden;
1109
+ overscroll-behavior: none;
1110
+ }
1111
+
965
1112
  /* --- accordion / disclosure: details + summary -------------------- *
966
1113
  * shadcn accordion look; exclusivity is <details name="group">.
967
1114
  * open/close animation via ::details-content + interpolate-size,
@@ -1042,18 +1189,27 @@
1042
1189
  opacity: 1;
1043
1190
  scale: 1;
1044
1191
  }
1045
- /* enter animates; close is instant (house rule for transient layers) */
1046
- :where([popover]:popover-open) {
1047
- transition:
1048
- opacity 0.1s,
1049
- scale 0.1s,
1050
- overlay 0.1s allow-discrete,
1051
- display 0.1s allow-discrete;
1052
- }
1053
- @starting-style {
1192
+ /* enter animates; close is instant (house rule for transient layers).
1193
+ Gated on `overlay` support: iOS lacks it, and its top-layer
1194
+ transitions are broken — a popover with an @starting-style entry
1195
+ freezes at the start (opacity:0 = invisible) with no reflow to kick
1196
+ it, and there's no CSS way to force one. Where overlay is
1197
+ unsupported, skip the entrance so popovers render at their resting
1198
+ opacity:1 instead of vanishing. (This is why toasts "didn't work"
1199
+ on iOS.) */
1200
+ @supports (overlay: auto) {
1054
1201
  :where([popover]:popover-open) {
1055
- opacity: 0;
1056
- scale: 0.95;
1202
+ transition:
1203
+ opacity 0.1s,
1204
+ scale 0.1s,
1205
+ overlay 0.1s allow-discrete,
1206
+ display 0.1s allow-discrete;
1207
+ }
1208
+ @starting-style {
1209
+ :where([popover]:popover-open) {
1210
+ opacity: 0;
1211
+ scale: 0.95;
1212
+ }
1057
1213
  }
1058
1214
  }
1059
1215
  /* anchor to the invoker where the platform can */
@@ -1246,18 +1402,24 @@
1246
1402
  border-inline-start: 2px solid var(--color-warn);
1247
1403
  }
1248
1404
  }
1249
- /* arrives from below rather than zooming; reflow (restacking) eases */
1405
+ /* restacking motion always (inset-block-end); the entrance slide/fade
1406
+ only where overlay transitions work — see the base popover note. */
1250
1407
  :where([popover].toast:popover-open) {
1251
- transition:
1252
- opacity 0.1s,
1253
- translate 0.1s,
1254
- inset-block-end 0.15s,
1255
- overlay 0.1s allow-discrete,
1256
- display 0.1s allow-discrete;
1408
+ transition: inset-block-end 0.15s;
1257
1409
  }
1258
- @starting-style {
1410
+ @supports (overlay: auto) {
1259
1411
  :where([popover].toast:popover-open) {
1260
- translate: 0 0.75rem;
1412
+ transition:
1413
+ opacity 0.1s,
1414
+ translate 0.1s,
1415
+ inset-block-end 0.15s,
1416
+ overlay 0.1s allow-discrete,
1417
+ display 0.1s allow-discrete;
1418
+ }
1419
+ @starting-style {
1420
+ :where([popover].toast:popover-open) {
1421
+ translate: 0 0.75rem;
1422
+ }
1261
1423
  }
1262
1424
  }
1263
1425
 
@@ -1348,13 +1510,15 @@
1348
1510
  m-grid,
1349
1511
  m-sidecar,
1350
1512
  m-switcher,
1351
- m-reel {
1513
+ m-reel,
1514
+ m-frame,
1515
+ m-cover {
1352
1516
  display: block;
1353
1517
  box-sizing: border-box;
1354
1518
  }
1355
1519
 
1356
1520
  /* Shared attribute vocabulary -> custom properties */
1357
- :is(m-vstack, m-hstack, m-grid, m-sidecar, m-switcher, m-reel) {
1521
+ :is(m-vstack, m-hstack, m-grid, m-sidecar, m-switcher, m-reel, m-cover) {
1358
1522
  &[gap="none"] { --gap: 0; }
1359
1523
  &[gap="2xs"] { --gap: var(--space-2xs); }
1360
1524
  &[gap="xs"] { --gap: var(--space-xs); }
@@ -1533,4 +1697,53 @@
1533
1697
  &[snap="none"] { scroll-snap-type: none; }
1534
1698
  &[snap="mandatory"] { scroll-snap-type: inline mandatory; }
1535
1699
  }
1700
+
1701
+ /* --- m-frame: hold an aspect ratio, crop media to fit ------------ */
1702
+ m-frame {
1703
+ aspect-ratio: var(--ratio, 16 / 9);
1704
+ overflow: hidden;
1705
+ display: flex;
1706
+ align-items: center;
1707
+ justify-content: center;
1708
+
1709
+ &[ratio="square"] { --ratio: 1; }
1710
+ &[ratio="16:9"] { --ratio: 16 / 9; }
1711
+ &[ratio="4:3"] { --ratio: 4 / 3; }
1712
+ &[ratio="3:2"] { --ratio: 3 / 2; }
1713
+ &[ratio="2:1"] { --ratio: 2 / 1; }
1714
+ &[ratio="9:16"] { --ratio: 9 / 16; }
1715
+
1716
+ /* media fills and crops; anything else just gets centered */
1717
+ & > :is(img, video),
1718
+ & > picture > img {
1719
+ inline-size: 100%;
1720
+ block-size: 100%;
1721
+ object-fit: cover;
1722
+ }
1723
+ }
1724
+
1725
+ /* --- m-cover: fill the viewport, center the principal element ---- *
1726
+ * The principal element (h1 by default, or mark one child with
1727
+ * .principal) is vertically centered; children before it pin to the
1728
+ * top, children after it pin to the bottom. */
1729
+ m-cover {
1730
+ display: flex;
1731
+ flex-direction: column;
1732
+ min-block-size: var(--min-height, 100svh);
1733
+ gap: var(--gap, var(--space-md));
1734
+ padding: var(--pad, var(--space-md));
1735
+
1736
+ &[pad="none"] { --pad: 0; }
1737
+ &[pad="sm"] { --pad: var(--space-sm); }
1738
+ &[pad="md"] { --pad: var(--space-md); }
1739
+ &[pad="lg"] { --pad: var(--space-lg); }
1740
+ &[pad="xl"] { --pad: var(--space-xl); }
1741
+
1742
+ & > * { margin-block: 0; }
1743
+
1744
+ & > :is(h1, .principal) { margin-block: auto; }
1745
+
1746
+ /* an explicit .principal wins over the h1 default */
1747
+ &:has(> .principal) > h1:not(.principal) { margin-block: 0; }
1748
+ }
1536
1749
  }
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@akonwi/mica",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Custom elements. Native behavior. Nearly no JavaScript.",
5
- "keywords": ["css", "custom-elements", "web-components", "layout", "no-build", "html-first"],
6
- "homepage": "https://github.com/akonwi/mica",
5
+ "keywords": [
6
+ "css",
7
+ "custom-elements",
8
+ "web-components",
9
+ "layout",
10
+ "no-build",
11
+ "html-first"
12
+ ],
13
+ "homepage": "https://akonwi.io/mica/",
7
14
  "repository": {
8
15
  "type": "git",
9
16
  "url": "git+https://github.com/akonwi/mica.git"
@@ -14,6 +21,8 @@
14
21
  "exports": {
15
22
  ".": "./mica.css",
16
23
  "./mica.css": "./mica.css",
24
+ "./invoker.js": "./invoker.js",
25
+ "./drawer.js": "./drawer.js",
17
26
  "./field.js": "./field.js",
18
27
  "./select.js": "./select.js",
19
28
  "./tabs.js": "./tabs.js",
@@ -22,11 +31,24 @@
22
31
  },
23
32
  "files": [
24
33
  "mica.css",
34
+ "invoker.js",
35
+ "drawer.js",
25
36
  "field.js",
26
37
  "select.js",
27
38
  "tabs.js",
28
39
  "toast.js",
29
40
  "combobox.js"
30
41
  ],
31
- "sideEffects": true
42
+ "sideEffects": true,
43
+ "devDependencies": {
44
+ "axe-core": "^4.12.1",
45
+ "pixelmatch": "^7.2.0",
46
+ "playwright": "^1.61.1",
47
+ "pngjs": "^7.0.0"
48
+ },
49
+ "scripts": {
50
+ "snapshot": "bun tools/snapshot.ts",
51
+ "snapshot:check": "bun tools/snapshot.ts --check",
52
+ "snapshot:setup": "playwright install chromium webkit"
53
+ }
32
54
  }