@ambasada/ui 0.7.4 → 0.8.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.
package/README.md CHANGED
@@ -40,9 +40,14 @@ The shell's insets, for anything you position yourself:
40
40
  |---|---|---|
41
41
  | `--ambasada-safe-area-top` / `bottom` / `left` / `right` | `env(safe-area-inset-*, 0px)` | set on `<html>` by `@ambasada/miniapp`'s `ready()` |
42
42
  | `--ambasada-host-bar` | `0px` | the height of the shell's top bar |
43
+ | `--ambasada-inset-top` / `bottom` / `left` / `right` | `max(--ambasada-safe-area-*, env(safe-area-inset-*))` | what to keep clear: use these |
44
+
45
+ Use the `--ambasada-inset-*` tokens for anything you place yourself. They take the larger of the shell's report and
46
+ the browser's own inset, so the inset is right inside the app and on the open web and is never counted twice. Keep
47
+ the margin on top of it small (8 px): an empty band above the home indicator is a bug.
43
48
 
44
49
  ```css
45
- .my-bar { position: fixed; bottom: 0; padding-bottom: calc(12px + var(--ambasada-safe-area-bottom, 0px)); }
50
+ .my-bar { position: fixed; bottom: 0; padding-bottom: calc(8px + var(--ambasada-inset-bottom)); }
46
51
  ```
47
52
 
48
53
  ## Components
@@ -56,7 +61,8 @@ The shell's insets, for anything you position yourself:
56
61
  | `ui-group` (`ui-group-head`, `ui-group-body`) | A list: rows in one bordered block, with a title. |
57
62
  | `ui-row` (`row-icon`, `row-text`, `row-title`, `row-sub`, `row-right`, `row-chev`) | List row; `tappable`, `danger`, `muted`; `row-icon accent` / `good` / `bad`. |
58
63
  | `ui-pill` | Status pill with a dot: `good`, `bad`, `warn`, `accent`, `pulse`. |
59
- | `ui-sheet-backdrop` > `ui-sheet` (`ui-sheet-grip`, `-head`, `-body`, `-foot`), `ui-icon-btn` | Bottom sheet; a centred dialog from 640 px. Add `ui-sheet-open` to `<body>` while open. |
64
+ | `ui-sheet-backdrop` > `ui-sheet` (`ui-sheet-handle` > `ui-sheet-grip` + `ui-sheet-head`, `-body`, `-foot`), `ui-icon-btn` | Bottom sheet at every width (no centred dialogs): grip, rounded top, dimmed page, a body that scrolls, the one primary action in the foot. Add `ui-sheet-open` to `<body>` while open. The React `Sheet` also portals to `<body>`, closes on a drag down, and keeps the foot above the keyboard. |
65
+ | `ui-field` (`ui-field-label`, `ui-field-hint`, `.bad`/`.good`), `ui-input` | A form field: label, control, one line of hint or error under it, 8 px apart; compact inside `ui-shell` with a mouse. `ui-input` on `<input>`, `<textarea>`, `<select>` (`mono`, `code`). |
60
66
  | `ui-stepper-wrap` > `ui-stepper` (`ui-stepper-value`), `ui-presets` | − value + for amounts, with preset chips (`on`). |
61
67
  | `ui-empty` (`ui-empty-icon`) | Empty state: icon, `<strong>` title, hint, action. |
62
68
  | `ui-skeleton` | Grey bars (`<span>`s) while loading: skeletons, not spinners. |
@@ -68,7 +74,7 @@ For tools built for the laptop that still open in the Ambasada app (the Develope
68
74
  | `ui-shell` > `ui-shell-side`, `ui-shell-scrim`, `ui-shell-main` > `ui-shell-top` (`ui-shell-menu`), `ui-shell-content` | 240 px sidebar, sticky top bar, content up to 1088 px. Under 900 px the sidebar is a drawer (`ui-shell open`). Clears the host bar and safe areas once. |
69
75
  | `ui-nav` (`ui-nav-title`), `ui-nav-item` (`ui-nav-label`, `ui-nav-right`, `on`) | Sidebar navigation; the current item gets a red marker. |
70
76
  | `ui-tabs` | Tabs as links under a title (`a.on`); scrolls sideways when narrow. |
71
- | `ui-field` (`ui-field-label`, `ui-field-hint` `good` / `bad`), `ui-input` | Form field with one line of hint or error. |
77
+ | `ui-field`, `ui-input` | The same field as above, compact here with a mouse (40 px, 14 px text). `<Field label value onChange />` renders the input itself. |
72
78
  | `ui-table` (`.num`, `tr.tappable`) | Data table: hairline rows, tabular figures. |
73
79
 
74
80
  ```html
@@ -1,4 +1,4 @@
1
- /* @ambasada/ui 0.7.4: fonts, tokens and components in one stylesheet. MIT, CENTYSO. https://ambasada.id */
1
+ /* @ambasada/ui 0.8.0: fonts, tokens and components in one stylesheet. MIT, CENTYSO. https://ambasada.id */
2
2
  /*
3
3
  * Inter and JetBrains Mono, self-hosted (variable, SIL Open Font License 1.1: fonts/OFL-*.txt). Latin and
4
4
  * Latin Extended, so Romanian ț and ș render. The URLs are relative to this file: bundlers copy the files,
@@ -158,7 +158,18 @@
158
158
  --ambasada-safe-area-bottom: env(safe-area-inset-bottom, 0px);
159
159
  --ambasada-safe-area-left: env(safe-area-inset-left, 0px);
160
160
  --ambasada-safe-area-right: env(safe-area-inset-right, 0px);
161
- --ambasada-host-bar: 0px; /* height of the shell's top bar (back, app name, "…" menu) */
161
+ --ambasada-host-bar: 0px; /* height of the shell's top bar (close, app name, "…" menu) */
162
+
163
+ /*
164
+ * What a page actually keeps clear, per side: the larger of the shell's report and the browser's own inset.
165
+ * A shell that reports 0 while its WebView runs under the home indicator, and a browser that knows nothing of
166
+ * the shell, both come out right, and an inset is never counted twice (docs/MINI-APPS.md §15, decision 11).
167
+ * Use these (plus --ambasada-host-bar at the top), never env() or the raw --ambasada-safe-area-* directly.
168
+ */
169
+ --ambasada-inset-top: max(var(--ambasada-safe-area-top), env(safe-area-inset-top, 0px));
170
+ --ambasada-inset-bottom: max(var(--ambasada-safe-area-bottom), env(safe-area-inset-bottom, 0px));
171
+ --ambasada-inset-left: max(var(--ambasada-safe-area-left), env(safe-area-inset-left, 0px));
172
+ --ambasada-inset-right: max(var(--ambasada-safe-area-right), env(safe-area-inset-right, 0px));
162
173
  }
163
174
 
164
175
  /*
@@ -198,10 +209,10 @@
198
209
  max-width: var(--page-max);
199
210
  margin: 0 auto;
200
211
  padding:
201
- calc(var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px))
202
- calc(var(--space-4) + var(--ambasada-safe-area-right, 0px))
203
- calc(var(--space-8) + var(--ambasada-safe-area-bottom, 0px))
204
- calc(var(--space-4) + var(--ambasada-safe-area-left, 0px));
212
+ calc(var(--space-4) + var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px))
213
+ calc(var(--space-4) + var(--ambasada-inset-right))
214
+ calc(var(--space-8) + var(--ambasada-inset-bottom))
215
+ calc(var(--space-4) + var(--ambasada-inset-left));
205
216
  display: flex;
206
217
  flex-direction: column;
207
218
  gap: var(--space-4);
@@ -438,73 +449,110 @@
438
449
  }
439
450
 
440
451
  /* ── bottom sheet ───────────────────────────────────────────────────────────
441
- * Forms and details open in a sheet, not a new page. A centred dialog from 640px up. */
452
+ * Anything that opens over a page is a bottom sheet, at every width: never a centred dialog (docs/MINI-APPS.md
453
+ * §15, decision 12). A grip and a head you can drag down, a body that scrolls, a foot with the one primary
454
+ * action. The React Sheet portals it to <body> (so no transformed ancestor can trap it) and sizes the backdrop
455
+ * to the visual viewport (--ui-vv-top, --ui-vv-height, --ui-keyboard), so the foot stays above the keyboard. */
442
456
 
443
457
  body.ui-sheet-open { overflow: hidden; }
444
458
  .ui-sheet-backdrop {
445
459
  position: fixed;
446
- inset: 0;
460
+ left: 0;
461
+ right: 0;
462
+ top: var(--ui-vv-top, 0px);
463
+ height: var(--ui-vv-height, 100%);
447
464
  z-index: 50;
448
- background: var(--color-backdrop);
449
465
  display: flex;
450
- align-items: flex-end;
451
- justify-content: center;
466
+ flex-direction: column;
467
+ justify-content: flex-end;
468
+ align-items: center;
469
+ padding-top: calc(var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px) + var(--space-2));
470
+ background: var(--color-backdrop);
452
471
  animation: ui-fade var(--duration) var(--ease);
453
472
  }
454
473
  .ui-sheet {
455
474
  width: 100%;
456
475
  max-width: var(--page-max);
457
- max-height: 92dvh;
476
+ max-height: 100%;
477
+ min-height: 0;
458
478
  display: flex;
459
479
  flex-direction: column;
460
480
  background: var(--color-bg);
461
481
  border-radius: var(--radius-hero) var(--radius-hero) 0 0;
462
482
  box-shadow: var(--shadow-raised);
463
- animation: ui-rise 260ms var(--ease);
464
- padding-bottom: var(--ambasada-safe-area-bottom, 0px);
483
+ animation: ui-rise 320ms var(--ease);
484
+ /* The home indicator stays clear; with the keyboard up it is covered anyway, so the inset goes. */
485
+ padding-bottom: max(0px, calc(var(--ambasada-inset-bottom) - var(--ui-keyboard, 0px)));
486
+ padding-left: var(--ambasada-inset-left);
487
+ padding-right: var(--ambasada-inset-right);
488
+ will-change: transform;
489
+ }
490
+ .ui-sheet-backdrop.leaving { animation: ui-fade-out 200ms var(--ease) both; }
491
+ .ui-sheet-backdrop.leaving .ui-sheet { animation: ui-sink 200ms var(--ease) both; }
492
+ /* Grip and head: the drag handle. touch-action none so a drag moves the sheet, not the page. */
493
+ .ui-sheet-handle {
494
+ flex: none;
495
+ touch-action: none;
496
+ cursor: grab;
497
+ -webkit-user-select: none;
498
+ user-select: none;
465
499
  }
466
500
  .ui-sheet-grip {
467
501
  width: 36px;
468
- height: 4px;
469
- margin: 8px auto 0;
470
- border-radius: 2px;
502
+ height: 5px;
503
+ margin: var(--space-2) auto 0;
504
+ border-radius: 3px;
471
505
  background: var(--color-line-strong);
472
506
  }
473
507
  .ui-sheet-head {
474
508
  display: flex;
475
509
  align-items: center;
476
510
  justify-content: space-between;
477
- padding: var(--space-3) var(--space-4) 0 var(--space-5);
511
+ gap: var(--space-3);
512
+ min-height: 52px;
513
+ padding: var(--space-1) var(--space-3) var(--space-1) var(--space-5);
478
514
  }
479
515
  .ui-sheet-head h2 {
516
+ flex: 1;
517
+ min-width: 0;
480
518
  margin: 0;
481
519
  font-size: var(--text-xl);
520
+ font-weight: var(--weight-semibold);
521
+ letter-spacing: var(--tracking-tight);
522
+ line-height: 1.2;
523
+ overflow-wrap: anywhere;
482
524
  }
525
+ .ui-sheet-head .ui-icon-btn { flex: none; }
483
526
  .ui-sheet-body {
527
+ flex: 1 1 auto;
528
+ min-height: 0;
484
529
  overflow-y: auto;
485
- padding: var(--space-4) var(--space-5);
530
+ overscroll-behavior: contain;
531
+ -webkit-overflow-scrolling: touch;
532
+ padding: var(--space-3) var(--space-5) var(--space-5);
486
533
  display: flex;
487
534
  flex-direction: column;
488
- gap: var(--space-4);
535
+ gap: var(--space-5);
489
536
  }
537
+ .ui-sheet-body > * { flex: none; }
490
538
  .ui-sheet-foot {
491
- padding: var(--space-3) var(--space-5) var(--space-4);
539
+ flex: none;
540
+ padding: var(--space-3) var(--space-5);
492
541
  border-top: 1px solid var(--color-line);
493
542
  display: flex;
494
543
  gap: var(--space-2);
495
544
  }
496
545
  .ui-sheet-foot > button,
497
- .ui-sheet-foot > .ui-button { flex: 1; }
498
- @keyframes ui-rise { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
546
+ .ui-sheet-foot > a,
547
+ .ui-sheet-foot > .ui-button { flex: 1; min-height: 48px; }
548
+ @keyframes ui-rise { from { transform: translate3d(0, 100%, 0); } to { transform: none; } }
549
+ @keyframes ui-sink { from { transform: none; } to { transform: translate3d(0, 100%, 0); } }
499
550
  @keyframes ui-fade { from { opacity: 0; } to { opacity: 1; } }
500
- @media (min-width: 640px) {
501
- .ui-sheet-backdrop { align-items: center; padding: var(--space-6); }
502
- .ui-sheet { border-radius: var(--radius-hero); max-height: 86dvh; }
503
- .ui-sheet-grip { display: none; }
504
- }
551
+ @keyframes ui-fade-out { from { opacity: 1; } to { opacity: 0; } }
505
552
 
506
- /* Round icon button (the sheet's close). */
553
+ /* Round icon button (the sheet's close): a 36px circle in a 44px target. */
507
554
  .ui-icon-btn {
555
+ position: relative;
508
556
  width: var(--tap);
509
557
  height: var(--tap);
510
558
  min-height: 0;
@@ -512,12 +560,77 @@ body.ui-sheet-open { overflow: hidden; }
512
560
  display: grid;
513
561
  place-items: center;
514
562
  border-radius: 50%;
515
- background: var(--color-surface-2);
563
+ background: none;
516
564
  color: var(--color-ink-2);
517
565
  border: 0;
518
566
  cursor: pointer;
567
+ isolation: isolate;
568
+ }
569
+ .ui-icon-btn::before {
570
+ content: "";
571
+ position: absolute;
572
+ inset: 4px;
573
+ z-index: -1;
574
+ border-radius: 50%;
575
+ background: var(--color-surface-2);
576
+ transition: background-color var(--duration) var(--ease);
519
577
  }
520
578
  .ui-icon-btn:hover { filter: none; color: var(--color-ink); }
579
+ .ui-icon-btn:hover::before { background: var(--color-line); }
580
+
581
+ /* ── fields ─────────────────────────────────────────────────────────────────
582
+ * A label, the control, one line of hint or error (.bad) under it, 8px apart. Inputs are 16px so iOS does not zoom into them. */
583
+
584
+ .ui-field {
585
+ display: flex;
586
+ flex-direction: column;
587
+ gap: var(--space-2);
588
+ min-width: 0;
589
+ }
590
+ .ui-field-label {
591
+ font-size: var(--text-sm);
592
+ font-weight: var(--weight-medium);
593
+ color: var(--color-ink);
594
+ line-height: 1.3;
595
+ }
596
+ .ui-field-hint {
597
+ font-size: var(--text-xs);
598
+ color: var(--color-ink-3);
599
+ line-height: 1.45;
600
+ }
601
+ .ui-field-hint.bad { color: var(--color-danger); }
602
+ .ui-field-hint.good { color: var(--color-success); }
603
+ .ui-input {
604
+ display: block;
605
+ width: 100%;
606
+ min-width: 0;
607
+ min-height: 48px;
608
+ margin: 0;
609
+ padding: 12px 14px;
610
+ font: inherit;
611
+ font-family: var(--font-sans);
612
+ font-size: var(--text-base);
613
+ letter-spacing: normal;
614
+ line-height: 1.4;
615
+ color: var(--color-ink);
616
+ background: var(--color-surface);
617
+ border: 1px solid var(--color-line);
618
+ border-radius: var(--radius-md);
619
+ -webkit-appearance: none;
620
+ appearance: none;
621
+ transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
622
+ }
623
+ .ui-input::placeholder { color: var(--color-ink-3); opacity: 1; }
624
+ .ui-input:hover { border-color: var(--color-line-strong); }
625
+ .ui-input:focus {
626
+ outline: none;
627
+ border-color: var(--color-accent);
628
+ box-shadow: 0 0 0 3px var(--color-accent-soft);
629
+ }
630
+ .ui-input:disabled { background: var(--color-surface-2); color: var(--color-ink-2); border-color: var(--color-line); }
631
+ .ui-input.mono { font-family: var(--font-mono); letter-spacing: 0.02em; }
632
+ .ui-input.code { font-family: var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; }
633
+ textarea.ui-input { resize: vertical; min-height: 104px; }
521
634
 
522
635
  /* ── stepper ────────────────────────────────────────────────────────────────
523
636
  * − value + for amounts, with optional preset chips underneath. */
@@ -527,19 +640,20 @@ body.ui-sheet-open { overflow: hidden; }
527
640
  display: flex;
528
641
  align-items: center;
529
642
  justify-content: space-between;
643
+ min-height: 48px;
530
644
  border: 1px solid var(--color-line);
531
645
  border-radius: var(--radius-md);
532
646
  background: var(--color-surface);
533
- padding: 4px;
647
+ padding: 3px;
534
648
  }
535
649
  .ui-stepper button {
536
- width: 48px;
537
- height: 44px;
650
+ width: 52px;
651
+ height: 40px;
538
652
  min-height: 0;
539
653
  padding: 0;
540
654
  display: grid;
541
655
  place-items: center;
542
- border-radius: 9px;
656
+ border-radius: var(--radius-sm);
543
657
  background: var(--color-surface-2);
544
658
  color: var(--color-ink);
545
659
  border: 0;
@@ -553,10 +667,11 @@ body.ui-sheet-open { overflow: hidden; }
553
667
  font-variant-numeric: tabular-nums;
554
668
  }
555
669
  .ui-stepper-value small { font-size: var(--text-xs); color: var(--color-ink-3); font-weight: var(--weight-regular); }
556
- .ui-presets { display: flex; gap: 6px; flex-wrap: wrap; }
670
+ .ui-presets { display: flex; gap: var(--space-2); flex-wrap: wrap; }
557
671
  .ui-presets button {
558
- min-height: 34px;
559
- padding: 4px 12px;
672
+ min-width: 48px;
673
+ min-height: 36px;
674
+ padding: 4px 14px;
560
675
  border-radius: var(--radius-pill);
561
676
  background: var(--color-surface);
562
677
  color: var(--color-ink-2);
@@ -811,48 +926,21 @@ body.ui-sheet-open { overflow: hidden; }
811
926
  border-bottom-color: var(--color-ink);
812
927
  }
813
928
 
814
- /* Form field: label, the input, one line of hint or error under it. */
815
- .ui-field {
816
- display: flex;
817
- flex-direction: column;
818
- gap: 6px;
819
- }
820
- .ui-field-label {
821
- font-size: var(--text-sm);
822
- font-weight: var(--weight-medium);
823
- }
824
- .ui-field-hint {
825
- font-size: var(--text-xs);
826
- color: var(--color-ink-3);
827
- }
828
- .ui-field-hint.bad {
829
- color: var(--color-danger);
830
- }
831
- .ui-field-hint.good {
832
- color: var(--color-success);
833
- }
834
- .ui-input {
835
- width: 100%;
836
- min-height: 40px;
837
- padding: 8px 12px;
838
- border: 1px solid var(--color-line);
839
- border-radius: var(--radius-sm);
840
- background: var(--color-surface);
841
- color: var(--color-ink);
842
- font: inherit;
843
- font-size: var(--text-sm);
844
- }
845
- .ui-input:hover {
846
- border-color: var(--color-line-strong);
847
- }
848
- .ui-input:focus {
849
- outline: none;
850
- border-color: var(--color-ink);
851
- box-shadow: 0 0 0 3px var(--color-surface-2);
852
- }
853
- .ui-input:disabled {
854
- background: var(--color-surface-2);
855
- color: var(--color-ink-3);
929
+ /* Fields inside a desktop tool are compact with a mouse. On a phone they stay 48 px and 16 px, so iOS does not zoom. */
930
+ @media (pointer: fine) {
931
+ .ui-shell .ui-field {
932
+ gap: 6px;
933
+ }
934
+ .ui-shell .ui-input {
935
+ min-height: 40px;
936
+ padding: 8px 12px;
937
+ font-size: var(--text-sm);
938
+ border-radius: var(--radius-sm);
939
+ }
940
+ .ui-shell .ui-input:focus {
941
+ border-color: var(--color-ink);
942
+ box-shadow: 0 0 0 3px var(--color-surface-2);
943
+ }
856
944
  }
857
945
 
858
946
  /* Data table: hairline rows, numbers right-aligned in tabular figures (`.num`). */
@@ -892,6 +980,8 @@ body.ui-sheet-open { overflow: hidden; }
892
980
  @media (prefers-reduced-motion: reduce) {
893
981
  .ui-sheet,
894
982
  .ui-sheet-backdrop,
983
+ .ui-sheet-backdrop.leaving,
984
+ .ui-sheet-backdrop.leaving .ui-sheet,
895
985
  .ui-pill.pulse i,
896
986
  .ui-skeleton span {
897
987
  animation: none;
@@ -35,10 +35,10 @@
35
35
  max-width: var(--page-max);
36
36
  margin: 0 auto;
37
37
  padding:
38
- calc(var(--space-4) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px))
39
- calc(var(--space-4) + var(--ambasada-safe-area-right, 0px))
40
- calc(var(--space-8) + var(--ambasada-safe-area-bottom, 0px))
41
- calc(var(--space-4) + var(--ambasada-safe-area-left, 0px));
38
+ calc(var(--space-4) + var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px))
39
+ calc(var(--space-4) + var(--ambasada-inset-right))
40
+ calc(var(--space-8) + var(--ambasada-inset-bottom))
41
+ calc(var(--space-4) + var(--ambasada-inset-left));
42
42
  display: flex;
43
43
  flex-direction: column;
44
44
  gap: var(--space-4);
@@ -275,73 +275,110 @@
275
275
  }
276
276
 
277
277
  /* ── bottom sheet ───────────────────────────────────────────────────────────
278
- * Forms and details open in a sheet, not a new page. A centred dialog from 640px up. */
278
+ * Anything that opens over a page is a bottom sheet, at every width: never a centred dialog (docs/MINI-APPS.md
279
+ * §15, decision 12). A grip and a head you can drag down, a body that scrolls, a foot with the one primary
280
+ * action. The React Sheet portals it to <body> (so no transformed ancestor can trap it) and sizes the backdrop
281
+ * to the visual viewport (--ui-vv-top, --ui-vv-height, --ui-keyboard), so the foot stays above the keyboard. */
279
282
 
280
283
  body.ui-sheet-open { overflow: hidden; }
281
284
  .ui-sheet-backdrop {
282
285
  position: fixed;
283
- inset: 0;
286
+ left: 0;
287
+ right: 0;
288
+ top: var(--ui-vv-top, 0px);
289
+ height: var(--ui-vv-height, 100%);
284
290
  z-index: 50;
285
- background: var(--color-backdrop);
286
291
  display: flex;
287
- align-items: flex-end;
288
- justify-content: center;
292
+ flex-direction: column;
293
+ justify-content: flex-end;
294
+ align-items: center;
295
+ padding-top: calc(var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px) + var(--space-2));
296
+ background: var(--color-backdrop);
289
297
  animation: ui-fade var(--duration) var(--ease);
290
298
  }
291
299
  .ui-sheet {
292
300
  width: 100%;
293
301
  max-width: var(--page-max);
294
- max-height: 92dvh;
302
+ max-height: 100%;
303
+ min-height: 0;
295
304
  display: flex;
296
305
  flex-direction: column;
297
306
  background: var(--color-bg);
298
307
  border-radius: var(--radius-hero) var(--radius-hero) 0 0;
299
308
  box-shadow: var(--shadow-raised);
300
- animation: ui-rise 260ms var(--ease);
301
- padding-bottom: var(--ambasada-safe-area-bottom, 0px);
309
+ animation: ui-rise 320ms var(--ease);
310
+ /* The home indicator stays clear; with the keyboard up it is covered anyway, so the inset goes. */
311
+ padding-bottom: max(0px, calc(var(--ambasada-inset-bottom) - var(--ui-keyboard, 0px)));
312
+ padding-left: var(--ambasada-inset-left);
313
+ padding-right: var(--ambasada-inset-right);
314
+ will-change: transform;
315
+ }
316
+ .ui-sheet-backdrop.leaving { animation: ui-fade-out 200ms var(--ease) both; }
317
+ .ui-sheet-backdrop.leaving .ui-sheet { animation: ui-sink 200ms var(--ease) both; }
318
+ /* Grip and head: the drag handle. touch-action none so a drag moves the sheet, not the page. */
319
+ .ui-sheet-handle {
320
+ flex: none;
321
+ touch-action: none;
322
+ cursor: grab;
323
+ -webkit-user-select: none;
324
+ user-select: none;
302
325
  }
303
326
  .ui-sheet-grip {
304
327
  width: 36px;
305
- height: 4px;
306
- margin: 8px auto 0;
307
- border-radius: 2px;
328
+ height: 5px;
329
+ margin: var(--space-2) auto 0;
330
+ border-radius: 3px;
308
331
  background: var(--color-line-strong);
309
332
  }
310
333
  .ui-sheet-head {
311
334
  display: flex;
312
335
  align-items: center;
313
336
  justify-content: space-between;
314
- padding: var(--space-3) var(--space-4) 0 var(--space-5);
337
+ gap: var(--space-3);
338
+ min-height: 52px;
339
+ padding: var(--space-1) var(--space-3) var(--space-1) var(--space-5);
315
340
  }
316
341
  .ui-sheet-head h2 {
342
+ flex: 1;
343
+ min-width: 0;
317
344
  margin: 0;
318
345
  font-size: var(--text-xl);
346
+ font-weight: var(--weight-semibold);
347
+ letter-spacing: var(--tracking-tight);
348
+ line-height: 1.2;
349
+ overflow-wrap: anywhere;
319
350
  }
351
+ .ui-sheet-head .ui-icon-btn { flex: none; }
320
352
  .ui-sheet-body {
353
+ flex: 1 1 auto;
354
+ min-height: 0;
321
355
  overflow-y: auto;
322
- padding: var(--space-4) var(--space-5);
356
+ overscroll-behavior: contain;
357
+ -webkit-overflow-scrolling: touch;
358
+ padding: var(--space-3) var(--space-5) var(--space-5);
323
359
  display: flex;
324
360
  flex-direction: column;
325
- gap: var(--space-4);
361
+ gap: var(--space-5);
326
362
  }
363
+ .ui-sheet-body > * { flex: none; }
327
364
  .ui-sheet-foot {
328
- padding: var(--space-3) var(--space-5) var(--space-4);
365
+ flex: none;
366
+ padding: var(--space-3) var(--space-5);
329
367
  border-top: 1px solid var(--color-line);
330
368
  display: flex;
331
369
  gap: var(--space-2);
332
370
  }
333
371
  .ui-sheet-foot > button,
334
- .ui-sheet-foot > .ui-button { flex: 1; }
335
- @keyframes ui-rise { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
372
+ .ui-sheet-foot > a,
373
+ .ui-sheet-foot > .ui-button { flex: 1; min-height: 48px; }
374
+ @keyframes ui-rise { from { transform: translate3d(0, 100%, 0); } to { transform: none; } }
375
+ @keyframes ui-sink { from { transform: none; } to { transform: translate3d(0, 100%, 0); } }
336
376
  @keyframes ui-fade { from { opacity: 0; } to { opacity: 1; } }
337
- @media (min-width: 640px) {
338
- .ui-sheet-backdrop { align-items: center; padding: var(--space-6); }
339
- .ui-sheet { border-radius: var(--radius-hero); max-height: 86dvh; }
340
- .ui-sheet-grip { display: none; }
341
- }
377
+ @keyframes ui-fade-out { from { opacity: 1; } to { opacity: 0; } }
342
378
 
343
- /* Round icon button (the sheet's close). */
379
+ /* Round icon button (the sheet's close): a 36px circle in a 44px target. */
344
380
  .ui-icon-btn {
381
+ position: relative;
345
382
  width: var(--tap);
346
383
  height: var(--tap);
347
384
  min-height: 0;
@@ -349,12 +386,77 @@ body.ui-sheet-open { overflow: hidden; }
349
386
  display: grid;
350
387
  place-items: center;
351
388
  border-radius: 50%;
352
- background: var(--color-surface-2);
389
+ background: none;
353
390
  color: var(--color-ink-2);
354
391
  border: 0;
355
392
  cursor: pointer;
393
+ isolation: isolate;
394
+ }
395
+ .ui-icon-btn::before {
396
+ content: "";
397
+ position: absolute;
398
+ inset: 4px;
399
+ z-index: -1;
400
+ border-radius: 50%;
401
+ background: var(--color-surface-2);
402
+ transition: background-color var(--duration) var(--ease);
356
403
  }
357
404
  .ui-icon-btn:hover { filter: none; color: var(--color-ink); }
405
+ .ui-icon-btn:hover::before { background: var(--color-line); }
406
+
407
+ /* ── fields ─────────────────────────────────────────────────────────────────
408
+ * A label, the control, one line of hint or error (.bad) under it, 8px apart. Inputs are 16px so iOS does not zoom into them. */
409
+
410
+ .ui-field {
411
+ display: flex;
412
+ flex-direction: column;
413
+ gap: var(--space-2);
414
+ min-width: 0;
415
+ }
416
+ .ui-field-label {
417
+ font-size: var(--text-sm);
418
+ font-weight: var(--weight-medium);
419
+ color: var(--color-ink);
420
+ line-height: 1.3;
421
+ }
422
+ .ui-field-hint {
423
+ font-size: var(--text-xs);
424
+ color: var(--color-ink-3);
425
+ line-height: 1.45;
426
+ }
427
+ .ui-field-hint.bad { color: var(--color-danger); }
428
+ .ui-field-hint.good { color: var(--color-success); }
429
+ .ui-input {
430
+ display: block;
431
+ width: 100%;
432
+ min-width: 0;
433
+ min-height: 48px;
434
+ margin: 0;
435
+ padding: 12px 14px;
436
+ font: inherit;
437
+ font-family: var(--font-sans);
438
+ font-size: var(--text-base);
439
+ letter-spacing: normal;
440
+ line-height: 1.4;
441
+ color: var(--color-ink);
442
+ background: var(--color-surface);
443
+ border: 1px solid var(--color-line);
444
+ border-radius: var(--radius-md);
445
+ -webkit-appearance: none;
446
+ appearance: none;
447
+ transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
448
+ }
449
+ .ui-input::placeholder { color: var(--color-ink-3); opacity: 1; }
450
+ .ui-input:hover { border-color: var(--color-line-strong); }
451
+ .ui-input:focus {
452
+ outline: none;
453
+ border-color: var(--color-accent);
454
+ box-shadow: 0 0 0 3px var(--color-accent-soft);
455
+ }
456
+ .ui-input:disabled { background: var(--color-surface-2); color: var(--color-ink-2); border-color: var(--color-line); }
457
+ .ui-input.mono { font-family: var(--font-mono); letter-spacing: 0.02em; }
458
+ .ui-input.code { font-family: var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; }
459
+ textarea.ui-input { resize: vertical; min-height: 104px; }
358
460
 
359
461
  /* ── stepper ────────────────────────────────────────────────────────────────
360
462
  * − value + for amounts, with optional preset chips underneath. */
@@ -364,19 +466,20 @@ body.ui-sheet-open { overflow: hidden; }
364
466
  display: flex;
365
467
  align-items: center;
366
468
  justify-content: space-between;
469
+ min-height: 48px;
367
470
  border: 1px solid var(--color-line);
368
471
  border-radius: var(--radius-md);
369
472
  background: var(--color-surface);
370
- padding: 4px;
473
+ padding: 3px;
371
474
  }
372
475
  .ui-stepper button {
373
- width: 48px;
374
- height: 44px;
476
+ width: 52px;
477
+ height: 40px;
375
478
  min-height: 0;
376
479
  padding: 0;
377
480
  display: grid;
378
481
  place-items: center;
379
- border-radius: 9px;
482
+ border-radius: var(--radius-sm);
380
483
  background: var(--color-surface-2);
381
484
  color: var(--color-ink);
382
485
  border: 0;
@@ -390,10 +493,11 @@ body.ui-sheet-open { overflow: hidden; }
390
493
  font-variant-numeric: tabular-nums;
391
494
  }
392
495
  .ui-stepper-value small { font-size: var(--text-xs); color: var(--color-ink-3); font-weight: var(--weight-regular); }
393
- .ui-presets { display: flex; gap: 6px; flex-wrap: wrap; }
496
+ .ui-presets { display: flex; gap: var(--space-2); flex-wrap: wrap; }
394
497
  .ui-presets button {
395
- min-height: 34px;
396
- padding: 4px 12px;
498
+ min-width: 48px;
499
+ min-height: 36px;
500
+ padding: 4px 14px;
397
501
  border-radius: var(--radius-pill);
398
502
  background: var(--color-surface);
399
503
  color: var(--color-ink-2);
@@ -648,48 +752,21 @@ body.ui-sheet-open { overflow: hidden; }
648
752
  border-bottom-color: var(--color-ink);
649
753
  }
650
754
 
651
- /* Form field: label, the input, one line of hint or error under it. */
652
- .ui-field {
653
- display: flex;
654
- flex-direction: column;
655
- gap: 6px;
656
- }
657
- .ui-field-label {
658
- font-size: var(--text-sm);
659
- font-weight: var(--weight-medium);
660
- }
661
- .ui-field-hint {
662
- font-size: var(--text-xs);
663
- color: var(--color-ink-3);
664
- }
665
- .ui-field-hint.bad {
666
- color: var(--color-danger);
667
- }
668
- .ui-field-hint.good {
669
- color: var(--color-success);
670
- }
671
- .ui-input {
672
- width: 100%;
673
- min-height: 40px;
674
- padding: 8px 12px;
675
- border: 1px solid var(--color-line);
676
- border-radius: var(--radius-sm);
677
- background: var(--color-surface);
678
- color: var(--color-ink);
679
- font: inherit;
680
- font-size: var(--text-sm);
681
- }
682
- .ui-input:hover {
683
- border-color: var(--color-line-strong);
684
- }
685
- .ui-input:focus {
686
- outline: none;
687
- border-color: var(--color-ink);
688
- box-shadow: 0 0 0 3px var(--color-surface-2);
689
- }
690
- .ui-input:disabled {
691
- background: var(--color-surface-2);
692
- color: var(--color-ink-3);
755
+ /* Fields inside a desktop tool are compact with a mouse. On a phone they stay 48 px and 16 px, so iOS does not zoom. */
756
+ @media (pointer: fine) {
757
+ .ui-shell .ui-field {
758
+ gap: 6px;
759
+ }
760
+ .ui-shell .ui-input {
761
+ min-height: 40px;
762
+ padding: 8px 12px;
763
+ font-size: var(--text-sm);
764
+ border-radius: var(--radius-sm);
765
+ }
766
+ .ui-shell .ui-input:focus {
767
+ border-color: var(--color-ink);
768
+ box-shadow: 0 0 0 3px var(--color-surface-2);
769
+ }
693
770
  }
694
771
 
695
772
  /* Data table: hairline rows, numbers right-aligned in tabular figures (`.num`). */
@@ -729,6 +806,8 @@ body.ui-sheet-open { overflow: hidden; }
729
806
  @media (prefers-reduced-motion: reduce) {
730
807
  .ui-sheet,
731
808
  .ui-sheet-backdrop,
809
+ .ui-sheet-backdrop.leaving,
810
+ .ui-sheet-backdrop.leaving .ui-sheet,
732
811
  .ui-pill.pulse i,
733
812
  .ui-skeleton span {
734
813
  animation: none;
package/dist/react.d.ts CHANGED
@@ -38,7 +38,12 @@ export declare function Pill({ tone, children, pulse }: {
38
38
  children: ReactNode;
39
39
  pulse?: boolean;
40
40
  }): import("react").JSX.Element;
41
- /** Bottom sheet (a centred dialog on wide screens). Closes on backdrop tap and Escape; the page behind does not scroll. */
41
+ /**
42
+ * Bottom sheet, at every width (never a centred dialog). Closes on a tap on the dim, the close button, Escape or a
43
+ * drag down on the grip and title, sliding out first; the page behind does not scroll. Rendered into <body>, so a
44
+ * transformed or clipped ancestor cannot trap it, and fitted to the visual viewport, so the foot (put the one
45
+ * primary action there) stays above the on-screen keyboard on iOS and Android.
46
+ */
42
47
  export declare function Sheet({ open, onClose, title, children, footer }: {
43
48
  open: boolean;
44
49
  onClose: () => void;
@@ -46,6 +51,19 @@ export declare function Sheet({ open, onClose, title, children, footer }: {
46
51
  children: ReactNode;
47
52
  footer?: ReactNode;
48
53
  }): import("react").JSX.Element | null;
54
+ export type FieldProps = InputHTMLAttributes<HTMLInputElement> & {
55
+ label?: ReactNode;
56
+ hint?: ReactNode;
57
+ error?: ReactNode;
58
+ tone?: "good" | "bad";
59
+ htmlFor?: string;
60
+ children?: ReactNode;
61
+ };
62
+ /**
63
+ * Label, control, one line of hint (or `error`, which wins) under it. Without children it is the input itself (the
64
+ * other props go to the <input>); with children it wraps them: a textarea, a Stepper, … (give inputs className="ui-input").
65
+ */
66
+ export declare function Field({ label, hint, error, tone, htmlFor, children, className, ...input }: FieldProps): import("react").JSX.Element;
49
67
  export type StepperProps = {
50
68
  value: number;
51
69
  onChange: (v: number) => void;
@@ -106,10 +124,3 @@ export declare function Tabs({ tabs, current, onClick, label }: {
106
124
  onClick?: MouseEventHandler<HTMLAnchorElement>;
107
125
  label?: string;
108
126
  }): import("react").JSX.Element;
109
- /** Label, input, one line of hint (or `error`, which wins) under it. */
110
- export declare function Field({ label, hint, error, tone, className, ...input }: InputHTMLAttributes<HTMLInputElement> & {
111
- label: ReactNode;
112
- hint?: ReactNode;
113
- error?: ReactNode;
114
- tone?: "good" | "bad";
115
- }): import("react").JSX.Element;
package/dist/react.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  // ../ui/src/react.tsx
4
- import { useEffect, useId } from "react";
4
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
5
+ import { createPortal } from "react-dom";
5
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
7
  var cx = (...names) => names.filter(Boolean).join(" ");
7
8
  var fmt = (v) => v.toLocaleString("en-US", { maximumFractionDigits: 2 });
@@ -49,28 +50,103 @@ function Pill({ tone = "muted", children, pulse }) {
49
50
  children
50
51
  ] });
51
52
  }
53
+ var reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
54
+ var OUT_MS = 200;
52
55
  function Sheet({ open, onClose, title, children, footer }) {
53
56
  const id = useId();
57
+ const backdrop = useRef(null);
58
+ const sheet = useRef(null);
59
+ const drag = useRef(null);
60
+ const close = useRef(onClose);
61
+ close.current = onClose;
62
+ const [leaving, setLeaving] = useState(false);
63
+ const dismiss = useCallback(() => reducedMotion() ? close.current() : setLeaving(true), []);
64
+ useEffect(() => {
65
+ if (!leaving) return;
66
+ const t = setTimeout(() => {
67
+ setLeaving(false);
68
+ close.current();
69
+ }, OUT_MS);
70
+ return () => clearTimeout(t);
71
+ }, [leaving]);
72
+ useEffect(() => {
73
+ if (!open) setLeaving(false);
74
+ }, [open]);
54
75
  useEffect(() => {
55
76
  if (!open) return;
56
- const onKey = (e) => e.key === "Escape" && onClose();
77
+ const onKey = (e) => e.key === "Escape" && dismiss();
57
78
  document.addEventListener("keydown", onKey);
58
79
  document.body.classList.add("ui-sheet-open");
80
+ const vv = window.visualViewport;
81
+ const fit = () => {
82
+ const el = backdrop.current;
83
+ if (!vv || !el) return;
84
+ const keyboard = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
85
+ el.style.setProperty("--ui-vv-top", `${vv.offsetTop}px`);
86
+ el.style.setProperty("--ui-vv-height", `${vv.height}px`);
87
+ el.style.setProperty("--ui-keyboard", `${keyboard}px`);
88
+ const focused = document.activeElement;
89
+ if (keyboard > 0 && focused instanceof HTMLElement && sheet.current?.contains(focused)) focused.scrollIntoView({ block: "nearest" });
90
+ };
91
+ fit();
92
+ vv?.addEventListener("resize", fit);
93
+ vv?.addEventListener("scroll", fit);
59
94
  return () => {
60
95
  document.removeEventListener("keydown", onKey);
61
96
  document.body.classList.remove("ui-sheet-open");
97
+ vv?.removeEventListener("resize", fit);
98
+ vv?.removeEventListener("scroll", fit);
62
99
  };
63
- }, [open, onClose]);
100
+ }, [open, dismiss]);
101
+ const onDown = (e) => {
102
+ if (e.button !== 0 || e.target.closest("button, a, input, textarea, select")) return;
103
+ drag.current = { y: e.clientY, t: e.timeStamp, dy: 0 };
104
+ e.currentTarget.setPointerCapture(e.pointerId);
105
+ if (sheet.current) sheet.current.style.transition = "none";
106
+ };
107
+ const onMove = (e) => {
108
+ const d = drag.current;
109
+ if (!d || !sheet.current) return;
110
+ d.dy = Math.max(0, e.clientY - d.y);
111
+ sheet.current.style.transform = d.dy ? `translate3d(0, ${d.dy}px, 0)` : "";
112
+ };
113
+ const onUp = (e) => {
114
+ const d = drag.current;
115
+ const el = sheet.current;
116
+ drag.current = null;
117
+ if (!d || !el) return;
118
+ const flick = d.dy > 24 && d.dy / Math.max(1, e.timeStamp - d.t) > 0.5;
119
+ el.style.transition = `transform ${OUT_MS}ms cubic-bezier(0.2, 0, 0, 1)`;
120
+ if (flick || d.dy > Math.min(160, el.offsetHeight / 3)) {
121
+ el.style.transform = "translate3d(0, 100%, 0)";
122
+ if (backdrop.current) Object.assign(backdrop.current.style, { transition: `opacity ${OUT_MS}ms`, opacity: "0" });
123
+ setTimeout(() => close.current(), OUT_MS);
124
+ } else el.style.transform = "";
125
+ };
64
126
  if (!open) return null;
65
- return /* @__PURE__ */ jsx("div", { className: "ui-sheet-backdrop", onClick: onClose, children: /* @__PURE__ */ jsxs("div", { className: "ui-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": title ? id : void 0, onClick: (e) => e.stopPropagation(), children: [
66
- /* @__PURE__ */ jsx("div", { className: "ui-sheet-grip" }),
67
- title && /* @__PURE__ */ jsxs("div", { className: "ui-sheet-head", children: [
68
- /* @__PURE__ */ jsx("h2", { id, children: title }),
69
- /* @__PURE__ */ jsx("button", { type: "button", className: "ui-icon-btn", "aria-label": "Close", onClick: onClose, children: /* @__PURE__ */ jsx(Icon, { d: "M6 6l12 12M18 6 6 18" }) })
127
+ const node = /* @__PURE__ */ jsx("div", { ref: backdrop, className: cx("ui-sheet-backdrop", leaving && "leaving"), onClick: dismiss, children: /* @__PURE__ */ jsxs("div", { ref: sheet, className: "ui-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": title ? id : void 0, onClick: (e) => e.stopPropagation(), children: [
128
+ /* @__PURE__ */ jsxs("div", { className: "ui-sheet-handle", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp, children: [
129
+ /* @__PURE__ */ jsx("div", { className: "ui-sheet-grip" }),
130
+ title && /* @__PURE__ */ jsxs("div", { className: "ui-sheet-head", children: [
131
+ /* @__PURE__ */ jsx("h2", { id, children: title }),
132
+ /* @__PURE__ */ jsx("button", { type: "button", className: "ui-icon-btn", "aria-label": "Close", onClick: dismiss, children: /* @__PURE__ */ jsx(Icon, { d: "M6 6l12 12M18 6 6 18", size: 20 }) })
133
+ ] })
70
134
  ] }),
71
135
  /* @__PURE__ */ jsx("div", { className: "ui-sheet-body", children }),
72
136
  footer && /* @__PURE__ */ jsx("div", { className: "ui-sheet-foot", children: footer })
73
137
  ] }) });
138
+ return typeof document === "undefined" ? node : createPortal(node, document.body);
139
+ }
140
+ function Field({ label, hint, error, tone, htmlFor, children, className, ...input }) {
141
+ const auto = useId();
142
+ const id = children === void 0 ? input.id ?? auto : htmlFor;
143
+ const note = error ?? hint;
144
+ const noteId = id && `${id}-hint`;
145
+ return /* @__PURE__ */ jsxs("div", { className: "ui-field", children: [
146
+ label && (id ? /* @__PURE__ */ jsx("label", { className: "ui-field-label", htmlFor: id, children: label }) : /* @__PURE__ */ jsx("span", { className: "ui-field-label", children: label })),
147
+ children ?? /* @__PURE__ */ jsx("input", { ...input, id, className: cx("ui-input", className), "aria-invalid": error ? true : void 0, "aria-describedby": note ? noteId : void 0 }),
148
+ note && /* @__PURE__ */ jsx("span", { id: noteId, className: cx("ui-field-hint", error ? "bad" : tone), role: error ? "alert" : void 0, children: note })
149
+ ] });
74
150
  }
75
151
  function Stepper({ value, onChange, min = 0, max = 1e5, step = 1, unit, presets, format = fmt }) {
76
152
  const clamp = (v) => Math.min(max, Math.max(min, Math.round(v * 100) / 100));
@@ -138,15 +214,6 @@ function NavItem({ icon, label, right, href, onClick, current }) {
138
214
  function Tabs({ tabs, current, onClick, label }) {
139
215
  return /* @__PURE__ */ jsx("nav", { className: "ui-tabs", "aria-label": label, children: tabs.map((t) => /* @__PURE__ */ jsx("a", { href: t.href, onClick, className: t.id === current ? "on" : void 0, "aria-current": t.id === current ? "page" : void 0, children: t.label }, t.id)) });
140
216
  }
141
- function Field({ label, hint, error, tone, className, ...input }) {
142
- const id = useId();
143
- const note = error ?? hint;
144
- return /* @__PURE__ */ jsxs("div", { className: "ui-field", children: [
145
- /* @__PURE__ */ jsx("label", { className: "ui-field-label", htmlFor: input.id ?? id, children: label }),
146
- /* @__PURE__ */ jsx("input", { id, ...input, className: cx("ui-input", className), "aria-invalid": error ? true : void 0, "aria-describedby": note ? `${input.id ?? id}-hint` : void 0 }),
147
- note && /* @__PURE__ */ jsx("span", { id: `${input.id ?? id}-hint`, className: cx("ui-field-hint", error ? "bad" : tone), children: note })
148
- ] });
149
- }
150
217
  export {
151
218
  Button,
152
219
  Card,
package/dist/tokens.css CHANGED
@@ -117,5 +117,16 @@
117
117
  --ambasada-safe-area-bottom: env(safe-area-inset-bottom, 0px);
118
118
  --ambasada-safe-area-left: env(safe-area-inset-left, 0px);
119
119
  --ambasada-safe-area-right: env(safe-area-inset-right, 0px);
120
- --ambasada-host-bar: 0px; /* height of the shell's top bar (back, app name, "…" menu) */
120
+ --ambasada-host-bar: 0px; /* height of the shell's top bar (close, app name, "…" menu) */
121
+
122
+ /*
123
+ * What a page actually keeps clear, per side: the larger of the shell's report and the browser's own inset.
124
+ * A shell that reports 0 while its WebView runs under the home indicator, and a browser that knows nothing of
125
+ * the shell, both come out right, and an inset is never counted twice (docs/MINI-APPS.md §15, decision 11).
126
+ * Use these (plus --ambasada-host-bar at the top), never env() or the raw --ambasada-safe-area-* directly.
127
+ */
128
+ --ambasada-inset-top: max(var(--ambasada-safe-area-top), env(safe-area-inset-top, 0px));
129
+ --ambasada-inset-bottom: max(var(--ambasada-safe-area-bottom), env(safe-area-inset-bottom, 0px));
130
+ --ambasada-inset-left: max(var(--ambasada-safe-area-left), env(safe-area-inset-left, 0px));
131
+ --ambasada-inset-right: max(var(--ambasada-safe-area-right), env(safe-area-inset-right, 0px));
121
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambasada/ui",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "The Ambasada look for mini apps: design tokens (colours, Inter + JetBrains Mono, radii, spacing, safe areas) and a small component set in plain CSS, with optional React wrappers. One stylesheet to <link>.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,11 +27,15 @@
27
27
  "LICENSE"
28
28
  ],
29
29
  "peerDependencies": {
30
- "react": ">=18"
30
+ "react": ">=18",
31
+ "react-dom": ">=18"
31
32
  },
32
33
  "peerDependenciesMeta": {
33
34
  "react": {
34
35
  "optional": true
36
+ },
37
+ "react-dom": {
38
+ "optional": true
35
39
  }
36
40
  },
37
41
  "devDependencies": {