@ambasada/ui 0.7.1 → 0.7.2

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
@@ -61,6 +61,16 @@ The shell's insets, for anything you position yourself:
61
61
  | `ui-empty` (`ui-empty-icon`) | Empty state: icon, `<strong>` title, hint, action. |
62
62
  | `ui-skeleton` | Grey bars (`<span>`s) while loading: skeletons, not spinners. |
63
63
 
64
+ For tools built for the laptop that still open in the Ambasada app (the Developer app):
65
+
66
+ | Class | What |
67
+ |---|---|
68
+ | `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
+ | `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
+ | `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. |
72
+ | `ui-table` (`.num`, `tr.tappable`) | Data table: hairline rows, tabular figures. |
73
+
64
74
  ```html
65
75
  <main class="ui-page">
66
76
  <p class="ui-label">// My Company</p>
@@ -83,7 +93,7 @@ The shell's insets, for anything you position yourself:
83
93
  ## React
84
94
 
85
95
  ```tsx
86
- import { Button, Card, Empty, Group, Page, Pill, Row, Sheet, Skeleton, Stepper } from "@ambasada/ui/react";
96
+ import { Button, Card, Empty, Field, Group, Nav, NavItem, Page, Pill, Row, Shell, Sheet, Skeleton, Stepper, Tabs } from "@ambasada/ui/react";
87
97
 
88
98
  <Page>
89
99
  <Card title="Your name as a folk poem">
@@ -96,6 +106,11 @@ import { Button, Card, Empty, Group, Page, Pill, Row, Sheet, Skeleton, Stepper }
96
106
  <Stepper value={amount} onChange={setAmount} min={1} unit="TON" presets={[1, 2, 5]} />
97
107
  </Sheet>
98
108
  </Page>
109
+
110
+ <Shell sidebar={<Nav title="Apps"><NavItem label="Acme" href="/p/acme" current /></Nav>} top="Acme" open={menu} onMenu={setMenu}>
111
+ <Tabs tabs={[{ id: "overview", label: "Overview", href: "/p/acme" }]} current="overview" />
112
+ <Field label="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} hint="acme.ambasada.dev" />
113
+ </Shell>
99
114
  ```
100
115
 
101
116
  The wrappers render markup only and import no CSS (load the stylesheet yourself). The module is marked
@@ -1,4 +1,4 @@
1
- /* @ambasada/ui 0.7.1: fonts, tokens and components in one stylesheet. MIT, CENTYSO. https://ambasada.id */
1
+ /* @ambasada/ui 0.7.2: 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,
@@ -163,7 +163,8 @@
163
163
 
164
164
  /*
165
165
  * @ambasada/ui components, in plain CSS: page, card, buttons, list rows, pill, bottom sheet, stepper,
166
- * empty state, skeleton, the mono label. Tokens only (tokens.css): no raw colours.
166
+ * empty state, skeleton, the mono label; for desktop tools the app shell, sidebar nav, tabs, fields and tables.
167
+ * Tokens only (tokens.css): no raw colours.
167
168
  * Markup for each one: packages/ui/README.md, or the React wrappers in @ambasada/ui/react.
168
169
  */
169
170
 
@@ -605,6 +606,289 @@ body.ui-sheet-open { overflow: hidden; }
605
606
  }
606
607
  @keyframes ui-shimmer { to { background-position: -200% 0; } }
607
608
 
609
+ /* ── app shell (desktop tools) ──────────────────────────────────────────────
610
+ * For apps built for the laptop that still open in the Ambasada app: a sidebar, a top bar, the content. Under
611
+ * 900 px the sidebar becomes a drawer behind the top bar's menu button. Clears the host bar and the safe areas
612
+ * once: the top bar takes the top, the content the bottom (docs/MINI-APPS.md §15 decision 11). */
613
+
614
+ .ui-shell {
615
+ min-height: 100vh;
616
+ min-height: 100dvh;
617
+ display: grid;
618
+ grid-template-columns: 240px minmax(0, 1fr);
619
+ }
620
+ .ui-shell-side {
621
+ position: sticky;
622
+ top: 0;
623
+ height: 100vh;
624
+ height: 100dvh;
625
+ display: flex;
626
+ flex-direction: column;
627
+ gap: var(--space-4);
628
+ padding:
629
+ calc(var(--space-3) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px))
630
+ var(--space-3)
631
+ calc(var(--space-3) + var(--ambasada-safe-area-bottom, 0px))
632
+ calc(var(--space-3) + var(--ambasada-safe-area-left, 0px));
633
+ background: var(--color-surface);
634
+ border-right: 1px solid var(--color-line);
635
+ overflow-y: auto;
636
+ }
637
+ .ui-shell-main {
638
+ min-width: 0;
639
+ display: flex;
640
+ flex-direction: column;
641
+ }
642
+ .ui-shell-top {
643
+ position: sticky;
644
+ top: 0;
645
+ z-index: 20;
646
+ display: flex;
647
+ align-items: center;
648
+ gap: var(--space-3);
649
+ min-height: calc(56px + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px));
650
+ padding:
651
+ calc(var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px))
652
+ calc(var(--space-6) + var(--ambasada-safe-area-right, 0px))
653
+ 0
654
+ var(--space-6);
655
+ background: var(--color-bg);
656
+ border-bottom: 1px solid var(--color-line);
657
+ }
658
+ .ui-shell-menu {
659
+ display: none;
660
+ }
661
+ .ui-shell-content {
662
+ flex: 1;
663
+ width: 100%;
664
+ max-width: 1088px;
665
+ padding:
666
+ var(--space-6)
667
+ calc(var(--space-6) + var(--ambasada-safe-area-right, 0px))
668
+ calc(var(--space-10) + var(--ambasada-safe-area-bottom, 0px))
669
+ var(--space-6);
670
+ display: flex;
671
+ flex-direction: column;
672
+ gap: var(--space-6);
673
+ }
674
+ .ui-shell-scrim {
675
+ display: none;
676
+ }
677
+ @media (max-width: 899px) {
678
+ .ui-shell {
679
+ grid-template-columns: minmax(0, 1fr);
680
+ }
681
+ .ui-shell-side {
682
+ position: fixed;
683
+ inset: 0 auto 0 0;
684
+ z-index: 40;
685
+ width: min(300px, 86vw);
686
+ transform: translateX(-100%);
687
+ transition: transform var(--duration) var(--ease);
688
+ box-shadow: var(--shadow-raised);
689
+ }
690
+ .ui-shell.open .ui-shell-side {
691
+ transform: none;
692
+ }
693
+ .ui-shell.open .ui-shell-scrim {
694
+ display: block;
695
+ position: fixed;
696
+ inset: 0;
697
+ z-index: 30;
698
+ background: var(--color-backdrop);
699
+ }
700
+ .ui-shell-menu {
701
+ display: inline-grid;
702
+ margin-left: calc(-1 * var(--space-2));
703
+ }
704
+ .ui-shell-top {
705
+ padding-left: calc(var(--space-4) + var(--ambasada-safe-area-left, 0px));
706
+ padding-right: calc(var(--space-4) + var(--ambasada-safe-area-right, 0px));
707
+ }
708
+ .ui-shell-content {
709
+ padding-left: calc(var(--space-4) + var(--ambasada-safe-area-left, 0px));
710
+ padding-right: calc(var(--space-4) + var(--ambasada-safe-area-right, 0px));
711
+ padding-top: var(--space-4);
712
+ gap: var(--space-5);
713
+ }
714
+ }
715
+
716
+ /* Sidebar navigation: a title, then items (icon, label, a value on the right). The current one is marked. */
717
+ .ui-nav {
718
+ display: flex;
719
+ flex-direction: column;
720
+ gap: 2px;
721
+ }
722
+ .ui-nav-title {
723
+ margin: 0;
724
+ padding: var(--space-2) var(--space-2) var(--space-1);
725
+ font-size: var(--text-xs);
726
+ font-weight: var(--weight-medium);
727
+ color: var(--color-ink-3);
728
+ }
729
+ .ui-nav-item {
730
+ position: relative;
731
+ display: flex;
732
+ align-items: center;
733
+ gap: var(--space-2);
734
+ min-height: 36px;
735
+ padding: 6px var(--space-2);
736
+ border: 0;
737
+ border-radius: var(--radius-sm);
738
+ background: none;
739
+ color: var(--color-ink-2);
740
+ font: inherit;
741
+ font-size: var(--text-sm);
742
+ text-align: left;
743
+ text-decoration: none;
744
+ cursor: pointer;
745
+ }
746
+ .ui-nav-item:hover {
747
+ background: var(--color-surface-2);
748
+ color: var(--color-ink);
749
+ }
750
+ .ui-nav-item.on {
751
+ background: var(--color-surface-2);
752
+ color: var(--color-ink);
753
+ font-weight: var(--weight-medium);
754
+ }
755
+ .ui-nav-item.on::before {
756
+ content: "";
757
+ position: absolute;
758
+ left: calc(-1 * var(--space-3));
759
+ top: 8px;
760
+ bottom: 8px;
761
+ width: 3px;
762
+ border-radius: 0 2px 2px 0;
763
+ background: var(--color-accent);
764
+ }
765
+ .ui-nav-item:focus-visible,
766
+ .ui-tabs a:focus-visible {
767
+ outline: 2px solid var(--color-accent);
768
+ outline-offset: -2px;
769
+ }
770
+ .ui-nav-label {
771
+ flex: 1;
772
+ min-width: 0;
773
+ overflow: hidden;
774
+ text-overflow: ellipsis;
775
+ white-space: nowrap;
776
+ }
777
+ .ui-nav-right {
778
+ flex: none;
779
+ color: var(--color-ink-3);
780
+ font-size: var(--text-xs);
781
+ font-variant-numeric: tabular-nums;
782
+ }
783
+
784
+ /* Tabs: a row of links under a page title; scrolls sideways when it doesn't fit. */
785
+ .ui-tabs {
786
+ display: flex;
787
+ gap: var(--space-1);
788
+ margin: 0;
789
+ border-bottom: 1px solid var(--color-line);
790
+ overflow-x: auto;
791
+ scrollbar-width: none;
792
+ }
793
+ .ui-tabs::-webkit-scrollbar {
794
+ display: none;
795
+ }
796
+ .ui-tabs a {
797
+ flex: none;
798
+ padding: 10px var(--space-3);
799
+ margin-bottom: -1px;
800
+ border-bottom: 2px solid transparent;
801
+ color: var(--color-ink-3);
802
+ font-size: var(--text-sm);
803
+ text-decoration: none;
804
+ }
805
+ .ui-tabs a:hover {
806
+ color: var(--color-ink);
807
+ }
808
+ .ui-tabs a.on {
809
+ color: var(--color-ink);
810
+ font-weight: var(--weight-medium);
811
+ border-bottom-color: var(--color-ink);
812
+ }
813
+
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);
856
+ }
857
+
858
+ /* Data table: hairline rows, numbers right-aligned in tabular figures (`.num`). */
859
+ .ui-table {
860
+ width: 100%;
861
+ border-collapse: collapse;
862
+ font-size: var(--text-sm);
863
+ font-variant-numeric: tabular-nums;
864
+ }
865
+ .ui-table th {
866
+ padding: 10px var(--space-4);
867
+ text-align: left;
868
+ font-weight: var(--weight-medium);
869
+ color: var(--color-ink-3);
870
+ background: var(--color-surface-2);
871
+ border-bottom: 1px solid var(--color-line);
872
+ white-space: nowrap;
873
+ }
874
+ .ui-table td {
875
+ padding: 12px var(--space-4);
876
+ border-bottom: 1px solid var(--color-line);
877
+ vertical-align: middle;
878
+ }
879
+ .ui-table tr:last-child td {
880
+ border-bottom: 0;
881
+ }
882
+ .ui-table .num {
883
+ text-align: right;
884
+ }
885
+ .ui-table tbody tr.tappable {
886
+ cursor: pointer;
887
+ }
888
+ .ui-table tbody tr.tappable:hover td {
889
+ background: var(--color-surface-2);
890
+ }
891
+
608
892
  @media (prefers-reduced-motion: reduce) {
609
893
  .ui-sheet,
610
894
  .ui-sheet-backdrop,
@@ -612,4 +896,7 @@ body.ui-sheet-open { overflow: hidden; }
612
896
  .ui-skeleton span {
613
897
  animation: none;
614
898
  }
899
+ .ui-shell-side {
900
+ transition: none;
901
+ }
615
902
  }
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  * @ambasada/ui components, in plain CSS: page, card, buttons, list rows, pill, bottom sheet, stepper,
3
- * empty state, skeleton, the mono label. Tokens only (tokens.css): no raw colours.
3
+ * empty state, skeleton, the mono label; for desktop tools the app shell, sidebar nav, tabs, fields and tables.
4
+ * Tokens only (tokens.css): no raw colours.
4
5
  * Markup for each one: packages/ui/README.md, or the React wrappers in @ambasada/ui/react.
5
6
  */
6
7
 
@@ -442,6 +443,289 @@ body.ui-sheet-open { overflow: hidden; }
442
443
  }
443
444
  @keyframes ui-shimmer { to { background-position: -200% 0; } }
444
445
 
446
+ /* ── app shell (desktop tools) ──────────────────────────────────────────────
447
+ * For apps built for the laptop that still open in the Ambasada app: a sidebar, a top bar, the content. Under
448
+ * 900 px the sidebar becomes a drawer behind the top bar's menu button. Clears the host bar and the safe areas
449
+ * once: the top bar takes the top, the content the bottom (docs/MINI-APPS.md §15 decision 11). */
450
+
451
+ .ui-shell {
452
+ min-height: 100vh;
453
+ min-height: 100dvh;
454
+ display: grid;
455
+ grid-template-columns: 240px minmax(0, 1fr);
456
+ }
457
+ .ui-shell-side {
458
+ position: sticky;
459
+ top: 0;
460
+ height: 100vh;
461
+ height: 100dvh;
462
+ display: flex;
463
+ flex-direction: column;
464
+ gap: var(--space-4);
465
+ padding:
466
+ calc(var(--space-3) + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px))
467
+ var(--space-3)
468
+ calc(var(--space-3) + var(--ambasada-safe-area-bottom, 0px))
469
+ calc(var(--space-3) + var(--ambasada-safe-area-left, 0px));
470
+ background: var(--color-surface);
471
+ border-right: 1px solid var(--color-line);
472
+ overflow-y: auto;
473
+ }
474
+ .ui-shell-main {
475
+ min-width: 0;
476
+ display: flex;
477
+ flex-direction: column;
478
+ }
479
+ .ui-shell-top {
480
+ position: sticky;
481
+ top: 0;
482
+ z-index: 20;
483
+ display: flex;
484
+ align-items: center;
485
+ gap: var(--space-3);
486
+ min-height: calc(56px + var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px));
487
+ padding:
488
+ calc(var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px))
489
+ calc(var(--space-6) + var(--ambasada-safe-area-right, 0px))
490
+ 0
491
+ var(--space-6);
492
+ background: var(--color-bg);
493
+ border-bottom: 1px solid var(--color-line);
494
+ }
495
+ .ui-shell-menu {
496
+ display: none;
497
+ }
498
+ .ui-shell-content {
499
+ flex: 1;
500
+ width: 100%;
501
+ max-width: 1088px;
502
+ padding:
503
+ var(--space-6)
504
+ calc(var(--space-6) + var(--ambasada-safe-area-right, 0px))
505
+ calc(var(--space-10) + var(--ambasada-safe-area-bottom, 0px))
506
+ var(--space-6);
507
+ display: flex;
508
+ flex-direction: column;
509
+ gap: var(--space-6);
510
+ }
511
+ .ui-shell-scrim {
512
+ display: none;
513
+ }
514
+ @media (max-width: 899px) {
515
+ .ui-shell {
516
+ grid-template-columns: minmax(0, 1fr);
517
+ }
518
+ .ui-shell-side {
519
+ position: fixed;
520
+ inset: 0 auto 0 0;
521
+ z-index: 40;
522
+ width: min(300px, 86vw);
523
+ transform: translateX(-100%);
524
+ transition: transform var(--duration) var(--ease);
525
+ box-shadow: var(--shadow-raised);
526
+ }
527
+ .ui-shell.open .ui-shell-side {
528
+ transform: none;
529
+ }
530
+ .ui-shell.open .ui-shell-scrim {
531
+ display: block;
532
+ position: fixed;
533
+ inset: 0;
534
+ z-index: 30;
535
+ background: var(--color-backdrop);
536
+ }
537
+ .ui-shell-menu {
538
+ display: inline-grid;
539
+ margin-left: calc(-1 * var(--space-2));
540
+ }
541
+ .ui-shell-top {
542
+ padding-left: calc(var(--space-4) + var(--ambasada-safe-area-left, 0px));
543
+ padding-right: calc(var(--space-4) + var(--ambasada-safe-area-right, 0px));
544
+ }
545
+ .ui-shell-content {
546
+ padding-left: calc(var(--space-4) + var(--ambasada-safe-area-left, 0px));
547
+ padding-right: calc(var(--space-4) + var(--ambasada-safe-area-right, 0px));
548
+ padding-top: var(--space-4);
549
+ gap: var(--space-5);
550
+ }
551
+ }
552
+
553
+ /* Sidebar navigation: a title, then items (icon, label, a value on the right). The current one is marked. */
554
+ .ui-nav {
555
+ display: flex;
556
+ flex-direction: column;
557
+ gap: 2px;
558
+ }
559
+ .ui-nav-title {
560
+ margin: 0;
561
+ padding: var(--space-2) var(--space-2) var(--space-1);
562
+ font-size: var(--text-xs);
563
+ font-weight: var(--weight-medium);
564
+ color: var(--color-ink-3);
565
+ }
566
+ .ui-nav-item {
567
+ position: relative;
568
+ display: flex;
569
+ align-items: center;
570
+ gap: var(--space-2);
571
+ min-height: 36px;
572
+ padding: 6px var(--space-2);
573
+ border: 0;
574
+ border-radius: var(--radius-sm);
575
+ background: none;
576
+ color: var(--color-ink-2);
577
+ font: inherit;
578
+ font-size: var(--text-sm);
579
+ text-align: left;
580
+ text-decoration: none;
581
+ cursor: pointer;
582
+ }
583
+ .ui-nav-item:hover {
584
+ background: var(--color-surface-2);
585
+ color: var(--color-ink);
586
+ }
587
+ .ui-nav-item.on {
588
+ background: var(--color-surface-2);
589
+ color: var(--color-ink);
590
+ font-weight: var(--weight-medium);
591
+ }
592
+ .ui-nav-item.on::before {
593
+ content: "";
594
+ position: absolute;
595
+ left: calc(-1 * var(--space-3));
596
+ top: 8px;
597
+ bottom: 8px;
598
+ width: 3px;
599
+ border-radius: 0 2px 2px 0;
600
+ background: var(--color-accent);
601
+ }
602
+ .ui-nav-item:focus-visible,
603
+ .ui-tabs a:focus-visible {
604
+ outline: 2px solid var(--color-accent);
605
+ outline-offset: -2px;
606
+ }
607
+ .ui-nav-label {
608
+ flex: 1;
609
+ min-width: 0;
610
+ overflow: hidden;
611
+ text-overflow: ellipsis;
612
+ white-space: nowrap;
613
+ }
614
+ .ui-nav-right {
615
+ flex: none;
616
+ color: var(--color-ink-3);
617
+ font-size: var(--text-xs);
618
+ font-variant-numeric: tabular-nums;
619
+ }
620
+
621
+ /* Tabs: a row of links under a page title; scrolls sideways when it doesn't fit. */
622
+ .ui-tabs {
623
+ display: flex;
624
+ gap: var(--space-1);
625
+ margin: 0;
626
+ border-bottom: 1px solid var(--color-line);
627
+ overflow-x: auto;
628
+ scrollbar-width: none;
629
+ }
630
+ .ui-tabs::-webkit-scrollbar {
631
+ display: none;
632
+ }
633
+ .ui-tabs a {
634
+ flex: none;
635
+ padding: 10px var(--space-3);
636
+ margin-bottom: -1px;
637
+ border-bottom: 2px solid transparent;
638
+ color: var(--color-ink-3);
639
+ font-size: var(--text-sm);
640
+ text-decoration: none;
641
+ }
642
+ .ui-tabs a:hover {
643
+ color: var(--color-ink);
644
+ }
645
+ .ui-tabs a.on {
646
+ color: var(--color-ink);
647
+ font-weight: var(--weight-medium);
648
+ border-bottom-color: var(--color-ink);
649
+ }
650
+
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);
693
+ }
694
+
695
+ /* Data table: hairline rows, numbers right-aligned in tabular figures (`.num`). */
696
+ .ui-table {
697
+ width: 100%;
698
+ border-collapse: collapse;
699
+ font-size: var(--text-sm);
700
+ font-variant-numeric: tabular-nums;
701
+ }
702
+ .ui-table th {
703
+ padding: 10px var(--space-4);
704
+ text-align: left;
705
+ font-weight: var(--weight-medium);
706
+ color: var(--color-ink-3);
707
+ background: var(--color-surface-2);
708
+ border-bottom: 1px solid var(--color-line);
709
+ white-space: nowrap;
710
+ }
711
+ .ui-table td {
712
+ padding: 12px var(--space-4);
713
+ border-bottom: 1px solid var(--color-line);
714
+ vertical-align: middle;
715
+ }
716
+ .ui-table tr:last-child td {
717
+ border-bottom: 0;
718
+ }
719
+ .ui-table .num {
720
+ text-align: right;
721
+ }
722
+ .ui-table tbody tr.tappable {
723
+ cursor: pointer;
724
+ }
725
+ .ui-table tbody tr.tappable:hover td {
726
+ background: var(--color-surface-2);
727
+ }
728
+
445
729
  @media (prefers-reduced-motion: reduce) {
446
730
  .ui-sheet,
447
731
  .ui-sheet-backdrop,
@@ -449,4 +733,7 @@ body.ui-sheet-open { overflow: hidden; }
449
733
  .ui-skeleton span {
450
734
  animation: none;
451
735
  }
736
+ .ui-shell-side {
737
+ transition: none;
738
+ }
452
739
  }
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ButtonHTMLAttributes, type ReactNode } from "react";
1
+ import { type ButtonHTMLAttributes, type InputHTMLAttributes, type MouseEventHandler, type ReactNode } from "react";
2
2
  /** The mini-app column: clear of the host bar and the safe areas. */
3
3
  export declare function Page({ children, className }: {
4
4
  children: ReactNode;
@@ -68,3 +68,48 @@ export declare function Empty({ icon, title, hint, action }: {
68
68
  export declare function Skeleton({ lines }: {
69
69
  lines?: number;
70
70
  }): import("react").JSX.Element;
71
+ /**
72
+ * Sidebar, top bar, content: for apps built for the laptop that still open in the Ambasada app. Under 900 px the
73
+ * sidebar is a drawer: `open` shows it, the top bar's menu button and the scrim call `onMenu(next)`.
74
+ */
75
+ export declare function Shell({ sidebar, top, children, open, onMenu }: {
76
+ sidebar: ReactNode;
77
+ top?: ReactNode;
78
+ children: ReactNode;
79
+ open?: boolean;
80
+ onMenu?: (open: boolean) => void;
81
+ }): import("react").JSX.Element;
82
+ /** A sidebar section: an optional title over its items. */
83
+ export declare function Nav({ title, children, label }: {
84
+ title?: ReactNode;
85
+ children: ReactNode;
86
+ label?: string;
87
+ }): import("react").JSX.Element;
88
+ export type NavItemProps = {
89
+ icon?: ReactNode;
90
+ label: ReactNode;
91
+ right?: ReactNode;
92
+ href?: string;
93
+ onClick?: MouseEventHandler<HTMLElement>;
94
+ current?: boolean;
95
+ };
96
+ /** A sidebar item: a link with `href` (current = aria-current page), else a button. */
97
+ export declare function NavItem({ icon, label, right, href, onClick, current }: NavItemProps): import("react").JSX.Element;
98
+ /** Tabs as links (each tab its own URL); `onClick` lets a client router take plain clicks. */
99
+ export declare function Tabs({ tabs, current, onClick, label }: {
100
+ tabs: {
101
+ id: string;
102
+ label: ReactNode;
103
+ href: string;
104
+ }[];
105
+ current: string;
106
+ onClick?: MouseEventHandler<HTMLAnchorElement>;
107
+ label?: string;
108
+ }): 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
@@ -100,15 +100,67 @@ function Empty({ icon, title, hint, action }) {
100
100
  function Skeleton({ lines = 3 }) {
101
101
  return /* @__PURE__ */ jsx("div", { className: "ui-skeleton", "aria-busy": "true", children: Array.from({ length: lines }).map((_, i) => /* @__PURE__ */ jsx("span", { style: { width: `${90 - i % 3 * 18}%` } }, i)) });
102
102
  }
103
+ function Shell({ sidebar, top, children, open = false, onMenu }) {
104
+ useEffect(() => {
105
+ if (!open) return;
106
+ const onKey = (e) => e.key === "Escape" && onMenu?.(false);
107
+ document.addEventListener("keydown", onKey);
108
+ return () => document.removeEventListener("keydown", onKey);
109
+ }, [open, onMenu]);
110
+ return /* @__PURE__ */ jsxs("div", { className: cx("ui-shell", open && "open"), children: [
111
+ /* @__PURE__ */ jsx("aside", { className: "ui-shell-side", children: sidebar }),
112
+ /* @__PURE__ */ jsx("div", { className: "ui-shell-scrim", onClick: () => onMenu?.(false) }),
113
+ /* @__PURE__ */ jsxs("div", { className: "ui-shell-main", children: [
114
+ /* @__PURE__ */ jsxs("header", { className: "ui-shell-top", children: [
115
+ /* @__PURE__ */ jsx("button", { type: "button", className: "ui-icon-btn ui-shell-menu", "aria-label": "Menu", "aria-expanded": open, onClick: () => onMenu?.(!open), children: /* @__PURE__ */ jsx(Icon, { d: "M4 7h16M4 12h16M4 17h16" }) }),
116
+ top
117
+ ] }),
118
+ /* @__PURE__ */ jsx("main", { className: "ui-shell-content", children })
119
+ ] })
120
+ ] });
121
+ }
122
+ function Nav({ title, children, label }) {
123
+ return /* @__PURE__ */ jsxs("nav", { className: "ui-nav", "aria-label": label, children: [
124
+ title && /* @__PURE__ */ jsx("h2", { className: "ui-nav-title", children: title }),
125
+ children
126
+ ] });
127
+ }
128
+ function NavItem({ icon, label, right, href, onClick, current }) {
129
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
130
+ icon,
131
+ /* @__PURE__ */ jsx("span", { className: "ui-nav-label", children: label }),
132
+ right !== void 0 && /* @__PURE__ */ jsx("span", { className: "ui-nav-right", children: right })
133
+ ] });
134
+ const cls = cx("ui-nav-item", current && "on");
135
+ if (href) return /* @__PURE__ */ jsx("a", { className: cls, href, onClick, "aria-current": current ? "page" : void 0, children: body });
136
+ return /* @__PURE__ */ jsx("button", { type: "button", className: cls, onClick, children: body });
137
+ }
138
+ function Tabs({ tabs, current, onClick, label }) {
139
+ 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
+ }
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
+ }
103
150
  export {
104
151
  Button,
105
152
  Card,
106
153
  Empty,
154
+ Field,
107
155
  Group,
156
+ Nav,
157
+ NavItem,
108
158
  Page,
109
159
  Pill,
110
160
  Row,
111
161
  Sheet,
162
+ Shell,
112
163
  Skeleton,
113
- Stepper
164
+ Stepper,
165
+ Tabs
114
166
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambasada/ui",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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",