@brdgapp/ui 0.1.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +37 -201
  3. package/components/action-menu.d.ts +65 -0
  4. package/components/action-menu.js +48 -0
  5. package/components/avatar.d.ts +50 -0
  6. package/components/avatar.js +91 -0
  7. package/components/badge.d.ts +12 -0
  8. package/components/badge.js +23 -0
  9. package/components/bottom-sheet.d.ts +17 -0
  10. package/components/bottom-sheet.js +12 -0
  11. package/components/brand-mark.d.ts +20 -0
  12. package/components/brand-mark.js +44 -0
  13. package/components/button-group.d.ts +25 -0
  14. package/components/button-group.js +53 -0
  15. package/components/button.d.ts +33 -0
  16. package/components/button.js +82 -0
  17. package/components/card.d.ts +78 -0
  18. package/components/card.js +75 -0
  19. package/components/checkbox.d.ts +22 -0
  20. package/components/checkbox.js +35 -0
  21. package/components/collapsible.d.ts +29 -0
  22. package/components/collapsible.js +38 -0
  23. package/components/column-filter-menu.d.ts +15 -0
  24. package/components/column-filter-menu.js +30 -0
  25. package/components/column-sort-button.d.ts +18 -0
  26. package/components/column-sort-button.js +14 -0
  27. package/components/confirm-dialog.d.ts +20 -0
  28. package/components/confirm-dialog.js +17 -0
  29. package/components/copy-field.d.ts +17 -0
  30. package/components/copy-field.js +36 -0
  31. package/components/data-state.d.ts +44 -0
  32. package/components/data-state.js +25 -0
  33. package/components/data-table.d.ts +65 -0
  34. package/components/data-table.js +84 -0
  35. package/components/dialog.d.ts +19 -0
  36. package/components/dialog.js +10 -0
  37. package/components/drawer.d.ts +21 -0
  38. package/components/drawer.js +17 -0
  39. package/components/expandable-bio.d.ts +8 -0
  40. package/components/expandable-bio.js +26 -0
  41. package/components/field.d.ts +29 -0
  42. package/components/field.js +27 -0
  43. package/components/filter-chip.d.ts +11 -0
  44. package/components/filter-chip.js +8 -0
  45. package/components/filter-pill.d.ts +34 -0
  46. package/components/filter-pill.js +35 -0
  47. package/components/hover-card.d.ts +21 -0
  48. package/components/hover-card.js +20 -0
  49. package/components/icon-button.d.ts +16 -0
  50. package/components/icon-button.js +32 -0
  51. package/components/illustration.d.ts +33 -0
  52. package/components/illustration.js +70 -0
  53. package/components/info-hint.d.ts +29 -0
  54. package/components/info-hint.js +42 -0
  55. package/components/input.d.ts +9 -0
  56. package/components/input.js +15 -0
  57. package/components/network-graph.d.ts +48 -0
  58. package/components/network-graph.js +37 -0
  59. package/components/page-banner.d.ts +20 -0
  60. package/components/page-banner.js +29 -0
  61. package/components/scene.d.ts +12 -0
  62. package/components/scene.js +7 -0
  63. package/components/search-input.d.ts +8 -0
  64. package/components/search-input.js +14 -0
  65. package/components/segmented-control.d.ts +18 -0
  66. package/components/segmented-control.js +14 -0
  67. package/components/skeleton.d.ts +12 -0
  68. package/components/skeleton.js +21 -0
  69. package/components/skip-link.d.ts +8 -0
  70. package/components/skip-link.js +7 -0
  71. package/components/stat-box.d.ts +23 -0
  72. package/components/stat-box.js +29 -0
  73. package/components/switch.d.ts +13 -0
  74. package/components/switch.js +8 -0
  75. package/components/tabs.d.ts +6 -0
  76. package/components/tabs.js +25 -0
  77. package/components/tag-input.d.ts +15 -0
  78. package/components/tag-input.js +40 -0
  79. package/components/tag.d.ts +27 -0
  80. package/components/tag.js +35 -0
  81. package/components/textarea.d.ts +12 -0
  82. package/components/textarea.js +78 -0
  83. package/components/theme-switch.d.ts +6 -0
  84. package/components/theme-switch.js +16 -0
  85. package/components/toast.d.ts +25 -0
  86. package/components/toast.js +44 -0
  87. package/hooks/use-is-mobile.d.ts +2 -0
  88. package/hooks/use-is-mobile.js +16 -0
  89. package/hooks/use-mock-query.d.ts +23 -0
  90. package/hooks/use-mock-query.js +36 -0
  91. package/hooks/use-theme.d.ts +11 -0
  92. package/hooks/use-theme.js +34 -0
  93. package/lib/demo-controls.d.ts +24 -0
  94. package/lib/demo-controls.js +24 -0
  95. package/lib/format.d.ts +17 -0
  96. package/lib/format.js +55 -0
  97. package/lib/layout.d.ts +19 -0
  98. package/lib/layout.js +24 -0
  99. package/lib/motion.d.ts +16 -0
  100. package/lib/motion.js +18 -0
  101. package/lib/theme.d.ts +21 -0
  102. package/lib/theme.js +63 -0
  103. package/lib/utils.d.ts +22 -0
  104. package/lib/utils.js +27 -0
  105. package/package.json +32 -16
  106. package/styles/globals.css +595 -0
  107. package/src/index.ts +0 -18
  108. package/src/network-people-helper/README.md +0 -130
  109. package/src/network-people-helper/api.ts +0 -174
  110. package/src/network-people-helper/contact-avatar.tsx +0 -63
  111. package/src/network-people-helper/domain.ts +0 -14
  112. package/src/network-people-helper/icons.tsx +0 -130
  113. package/src/network-people-helper/index.ts +0 -6
  114. package/src/network-people-helper/network-people-helper.tsx +0 -397
  115. package/src/network-people-helper/network-people-nudge.tsx +0 -98
  116. package/src/network-people-helper/open-helper.ts +0 -22
  117. package/src/network-people-helper/person-menu.tsx +0 -211
  118. package/src/network-people-helper/styles.css +0 -848
  119. package/src/network-people-helper/types.ts +0 -23
  120. package/src/network-people-helper/use-network-people-list.ts +0 -122
  121. package/src/network-people-helper/use-network-preview.ts +0 -43
  122. package/src/provider.tsx +0 -59
  123. package/src/styles.css +0 -1
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Toggle } from "@base-ui/react/toggle";
4
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
5
+ import { cn } from "@brdgapp/ui/lib/utils";
6
+ function SegmentedControl({ options, value, onChange, className, ...props }) {
7
+ return (_jsx(ToggleGroup, { "data-slot": "segmented-control", "aria-label": props["aria-label"], value: [value], onValueChange: (next) => {
8
+ // A single-choice control never ends up with nothing selected.
9
+ const picked = next[0];
10
+ if (picked !== undefined && picked !== value)
11
+ onChange(picked);
12
+ }, className: cn("inline-flex gap-[2px] rounded-full border border-[var(--border-default)] bg-[var(--surface-secondary)] p-[3px]", className), children: options.map((option) => (_jsx(Toggle, { value: option.value, "aria-label": option.ariaLabel, "data-slot": "segmented-control-option", className: cn("rounded-full px-3 py-[5px] text-[12px] font-semibold text-[var(--text-muted)] transition-colors duration-150 outline-none", "hover:text-[var(--text-body)] focus-visible:ring-2 focus-visible:ring-[var(--primary)] [&_svg]:size-3.5", "data-[pressed]:bg-[var(--surface-elevated)] data-[pressed]:text-[var(--text-primary)] data-[pressed]:shadow-[0_1px_2px_rgba(13,21,49,0.10)]"), children: option.label }, option.value))) }));
13
+ }
14
+ export { SegmentedControl };
@@ -0,0 +1,12 @@
1
+ import type * as React from "react";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ declare const skeletonVariants: (props?: ({
4
+ shape?: "circle" | "line" | "block" | null | undefined;
5
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
+ type SkeletonProps = VariantProps<typeof skeletonVariants> & {
7
+ /** Size and position only: `w-1/2`, `size-8`, `h-24`. The look comes from `shape`. */
8
+ className?: string;
9
+ };
10
+ declare function Skeleton({ shape, className }: SkeletonProps): React.JSX.Element;
11
+ export { Skeleton, skeletonVariants };
12
+ export type { SkeletonProps };
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cva } from "class-variance-authority";
3
+ import { cn } from "@brdgapp/ui/lib/utils";
4
+ // A grey placeholder shaped like content that hasn't loaded yet. Started from shadcn's skeleton, then put
5
+ // on Bridge's token: Bridge OS draws about 170 of these by hand (`bg-[var(--surface-tertiary)]
6
+ // animate-pulse`), in three shapes: text lines (4px corners), avatars (circles) and whole blocks.
7
+ // Hidden from screen readers: the region that's loading says so (DataState sets that up).
8
+ const skeletonVariants = cva("block animate-pulse bg-[var(--surface-tertiary)]", {
9
+ variants: {
10
+ shape: {
11
+ line: "h-3 rounded-[4px]",
12
+ circle: "rounded-full",
13
+ block: "rounded-xl",
14
+ },
15
+ },
16
+ defaultVariants: { shape: "line" },
17
+ });
18
+ function Skeleton({ shape, className }) {
19
+ return _jsx("span", { "data-slot": "skeleton", "aria-hidden": "true", className: cn(skeletonVariants({ shape }), className) });
20
+ }
21
+ export { Skeleton, skeletonVariants };
@@ -0,0 +1,8 @@
1
+ type SkipLinkProps = {
2
+ /** The id of the page's <main>. It needs `tabIndex={-1}` so focus can land on it. */
3
+ href?: string;
4
+ className?: string;
5
+ };
6
+ /** "Skip to content". Put it first inside <body>, before the top bar. */
7
+ declare function SkipLink({ href, className }: SkipLinkProps): import("react").JSX.Element;
8
+ export { SkipLink };
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "@brdgapp/ui/lib/utils";
3
+ /** "Skip to content". Put it first inside <body>, before the top bar. */
4
+ function SkipLink({ href = "#main", className }) {
5
+ return (_jsx("a", { "data-slot": "skip-link", href: href, className: cn("fixed top-3 left-3 z-[100] rounded-full bg-[var(--primary-solid)] px-4 py-2 text-14 font-semibold text-white shadow-[var(--shadow-menu)]", "outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-primary)]", className), children: "Skip to content" }));
6
+ }
7
+ export { SkipLink };
@@ -0,0 +1,23 @@
1
+ import type * as React from "react";
2
+ type StatBoxProps = {
3
+ label: string;
4
+ value: number;
5
+ /** A muted third line: "72%", "rated great". */
6
+ sub?: string;
7
+ /** A ready-made value ("$12.5K", from `formatMoney`) instead of the formatted count. */
8
+ display?: string;
9
+ /** Explains what the number counts. */
10
+ info?: string;
11
+ /** Makes the box a link into the full list. With your router's link, use `render` too. */
12
+ href?: string;
13
+ /** Your router's link element, e.g. `render={<Link href="/outcomes/intros" />}`. */
14
+ render?: React.ReactElement;
15
+ className?: string;
16
+ };
17
+ declare function StatBox({ label, value, sub, display, info, href, render, className }: StatBoxProps): React.JSX.Element;
18
+ /** Same size and shape as StatBox, for loading. */
19
+ declare function StatBoxSkeleton({ className }: {
20
+ className?: string;
21
+ }): React.JSX.Element;
22
+ export { StatBox, StatBoxSkeleton };
23
+ export type { StatBoxProps };
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { mergeProps } from "@base-ui/react/merge-props";
4
+ import { useRender } from "@base-ui/react/use-render";
5
+ import { ChevronRight } from "lucide-react";
6
+ import { InfoHint } from "@brdgapp/ui/components/info-hint";
7
+ import { formatCount } from "@brdgapp/ui/lib/format";
8
+ import { cn } from "@brdgapp/ui/lib/utils";
9
+ function StatBox({ label, value, sub, display, info, href, render, className }) {
10
+ const clickable = Boolean(href || render);
11
+ const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "flex items-start justify-between gap-2", children: [_jsx("span", { "data-slot": "stat-box-value", className: cn("text-[22px] leading-none font-bold tabular-nums text-[var(--text-primary)]", clickable && "transition-colors group-hover:text-[var(--primary)]"), children: display ?? formatCount(value) }), clickable ? (_jsx(ChevronRight, { "aria-hidden": "true", className: "mt-0.5 size-3.5 shrink-0 text-[var(--text-disabled)] transition-colors group-hover:text-[var(--primary)]" })) : null] }), _jsxs("span", { className: "mt-1.5 flex items-center gap-1 text-[12px] font-semibold text-[var(--text-body)]", children: [label, info && !clickable ? _jsx(InfoHint, { text: info }) : null] }), sub ? _jsx("span", { className: "mt-0.5 block text-[11px] text-[var(--text-muted)]", children: sub }) : null] }));
12
+ const classes = cn("group block rounded-xl bg-[var(--surface-secondary)] px-4 py-3", className);
13
+ const link = useRender({
14
+ render: render ?? _jsx("a", { href: href }),
15
+ enabled: clickable,
16
+ props: mergeProps({
17
+ "data-slot": "stat-box",
18
+ className: cn(classes, "outline-none transition-colors hover:bg-[var(--surface-tertiary)] focus-visible:ring-2 focus-visible:ring-[var(--primary)]"),
19
+ }, { children: body }),
20
+ });
21
+ if (link)
22
+ return link;
23
+ return (_jsx("div", { "data-slot": "stat-box", className: classes, children: body }));
24
+ }
25
+ /** Same size and shape as StatBox, for loading. */
26
+ function StatBoxSkeleton({ className }) {
27
+ return (_jsxs("div", { "data-slot": "stat-box-skeleton", "aria-hidden": "true", className: cn("rounded-xl bg-[var(--surface-secondary)] px-4 py-3", className), children: [_jsx("div", { className: "h-[22px] w-10 animate-pulse rounded bg-[var(--surface-tertiary)] motion-reduce:animate-none" }), _jsx("div", { className: "mt-1.5 h-3 w-16 animate-pulse rounded bg-[var(--surface-tertiary)] motion-reduce:animate-none" })] }));
28
+ }
29
+ export { StatBox, StatBoxSkeleton };
@@ -0,0 +1,13 @@
1
+ type SwitchProps = {
2
+ checked: boolean;
3
+ onChange: (checked: boolean) => void;
4
+ disabled?: boolean;
5
+ /** A name is required: either `aria-label`, or `aria-labelledby` pointing at the visible label. */
6
+ "aria-label"?: string;
7
+ "aria-labelledby"?: string;
8
+ id?: string;
9
+ className?: string;
10
+ };
11
+ declare function Switch({ checked, onChange, disabled, className, ...props }: SwitchProps): import("react").JSX.Element;
12
+ export { Switch };
13
+ export type { SwitchProps };
@@ -0,0 +1,8 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
4
+ import { cn } from "@brdgapp/ui/lib/utils";
5
+ function Switch({ checked, onChange, disabled = false, className, ...props }) {
6
+ return (_jsx(SwitchPrimitive.Root, { "data-slot": "switch", checked: checked, onCheckedChange: (next) => onChange(next), disabled: disabled, className: cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full bg-[var(--surface-tertiary)] transition-colors duration-200 outline-none", "focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-primary)]", "data-[checked]:bg-[var(--primary-solid)]", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), ...props, children: _jsx(SwitchPrimitive.Thumb, { "data-slot": "switch-thumb", className: "inline-block size-4 translate-x-[2px] rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-transform duration-200 data-[checked]:translate-x-[18px]" }) }));
7
+ }
8
+ export { Switch };
@@ -0,0 +1,6 @@
1
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
2
+ declare function Tabs({ className, ...props }: TabsPrimitive.Root.Props): import("react").JSX.Element;
3
+ declare function TabList({ className, ...props }: TabsPrimitive.List.Props): import("react").JSX.Element;
4
+ declare function Tab({ className, ...props }: TabsPrimitive.Tab.Props): import("react").JSX.Element;
5
+ declare function TabPanel({ className, ...props }: TabsPrimitive.Panel.Props): import("react").JSX.Element;
6
+ export { Tabs, TabList, Tab, TabPanel };
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
4
+ import { cn } from "@brdgapp/ui/lib/utils";
5
+ // Underline tabs from the Arch Design System. Look ported from Bridge OS src/components/ui/tabs.tsx;
6
+ // behaviour now comes from Base UI Tabs (arrow keys move between tabs, correct tab / tabpanel roles).
7
+ // API change from Bridge OS: the group owns the selected value (`<Tabs value onValueChange>`) and each
8
+ // `<Tab>` has a `value`, instead of each tab taking `active`.
9
+ function Tabs({ className, ...props }) {
10
+ return _jsx(TabsPrimitive.Root, { "data-slot": "tabs", className: cn("min-w-0", className), ...props });
11
+ }
12
+ // The baseline is an inset shadow, not a border, so the active tab's 2px underline can cover it without
13
+ // sticking out of the row. (A border plus a -1px tab margin got clipped by the row's sideways scrolling.)
14
+ // When the page draws its own baseline, pass `shadow-none -mb-px` so the row sits on that line instead.
15
+ function TabList({ className, ...props }) {
16
+ return (_jsx(TabsPrimitive.List, { "data-slot": "tab-list", className: cn("flex items-center gap-6 overflow-x-auto shadow-[inset_0_-1px_0_var(--border-default)] scrollbar-none", className), ...props }));
17
+ }
18
+ function Tab({ className, ...props }) {
19
+ return (_jsx(TabsPrimitive.Tab, { "data-slot": "tab", className: cn("relative shrink-0 rounded-t border-b-2 border-transparent pt-1 pb-3 max-md:min-h-11 text-[14px] font-bold tracking-[0.4px] whitespace-nowrap", "text-[var(--text-muted)] transition-colors duration-150 outline-none hover:text-[var(--text-body)]", "focus-visible:ring-2 focus-visible:ring-[var(--primary)]", "data-[active]:border-[var(--text-primary)] data-[active]:text-[var(--text-primary)]", "disabled:pointer-events-none disabled:opacity-50", className), ...props }));
20
+ }
21
+ // A panel takes focus when it has nothing focusable inside, so it shows the same ring as everything else.
22
+ function TabPanel({ className, ...props }) {
23
+ return _jsx(TabsPrimitive.Panel, { "data-slot": "tab-panel", className: cn("rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-primary)]", className), ...props });
24
+ }
25
+ export { Tabs, TabList, Tab, TabPanel };
@@ -0,0 +1,15 @@
1
+ import type * as React from "react";
2
+ import { type ElementProps } from "@brdgapp/ui/lib/utils";
3
+ type TagInputProps = ElementProps<HTMLDivElement> & {
4
+ value: string[];
5
+ onChange: (next: string[]) => void;
6
+ placeholder?: string;
7
+ /** Required unless a visible <label> points at the field. */
8
+ ariaLabel?: string;
9
+ /** Chip colour: `primary` (blue tint) or `muted` (grey). */
10
+ variant?: "primary" | "muted";
11
+ className?: string;
12
+ };
13
+ declare function TagInput({ value, onChange, placeholder, ariaLabel, variant, className, ...rest }: TagInputProps): React.JSX.Element;
14
+ export { TagInput };
15
+ export type { TagInputProps };
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useRef, useState } from "react";
4
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
5
+ import { Input as InputPrimitive } from "@base-ui/react/input";
6
+ import { X } from "lucide-react";
7
+ import { cn } from "@brdgapp/ui/lib/utils";
8
+ function TagInput({ value, onChange, placeholder, ariaLabel, variant = "primary", className, ...rest }) {
9
+ const [draft, setDraft] = useState("");
10
+ const inputRef = useRef(null);
11
+ function add(raw) {
12
+ const v = raw.trim();
13
+ if (!v)
14
+ return;
15
+ if (!value.some((t) => t.toLowerCase() === v.toLowerCase()))
16
+ onChange([...value, v]);
17
+ setDraft("");
18
+ }
19
+ function removeAt(index) {
20
+ onChange(value.filter((_, i) => i !== index));
21
+ inputRef.current?.focus();
22
+ }
23
+ function onKeyDown(e) {
24
+ if (e.key === "Enter" || e.key === ",") {
25
+ e.preventDefault();
26
+ add(draft);
27
+ }
28
+ else if (e.key === "Backspace" && draft === "" && value.length > 0) {
29
+ e.preventDefault();
30
+ removeAt(value.length - 1);
31
+ }
32
+ }
33
+ return (
34
+ // Clicking anywhere in the box focuses the text field (a mouse convenience; the field is reachable by Tab).
35
+ _jsxs("div", { ...rest, "data-slot": "tag-input", onClick: () => inputRef.current?.focus(), className: cn("flex min-h-11 md:min-h-10 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-[var(--border-default)] bg-[var(--surface-primary)] px-2 py-1.5", "transition-all duration-150 hover:border-[var(--border-hover)] focus-within:border-[var(--primary)] focus-within:ring-2 focus-within:ring-[var(--primary)]/20", className), children: [value.map((tag, i) => (_jsxs("span", { "data-slot": "tag-input-chip", className: cn("inline-flex items-center gap-0.5 rounded-full py-0.5 pr-0.5 pl-2.5 text-[11px] font-semibold", variant === "primary" ? "bg-[var(--primary)]/[0.08] text-[var(--primary)]" : "bg-[var(--surface-secondary)] text-[var(--text-body)]"), children: [tag, _jsx(ButtonPrimitive, { "aria-label": `Remove ${tag}`, onClick: (e) => {
36
+ e.stopPropagation();
37
+ removeAt(i);
38
+ }, className: "inline-flex size-5 items-center justify-center rounded-full opacity-60 outline-none transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-[var(--primary)]", children: _jsx(X, { "aria-hidden": "true", className: "size-3" }) })] }, `${tag}-${i}`))), _jsx(InputPrimitive, { ref: inputRef, "data-slot": "tag-input-field", type: "text", value: draft, "aria-label": ariaLabel, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, onBlur: () => add(draft), placeholder: value.length === 0 ? placeholder : "", className: "min-w-[80px] flex-1 border-0 bg-transparent px-1.5 py-0.5 text-16 md:text-14 text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)]" })] }));
39
+ }
40
+ export { TagInput };
@@ -0,0 +1,27 @@
1
+ import type * as React from "react";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ import { type ElementProps } from "@brdgapp/ui/lib/utils";
4
+ declare const tagVariants: (props?: ({
5
+ color?: "blue" | "green" | "purple" | "grey" | "red" | "yellow" | null | undefined;
6
+ size?: "sm" | "md" | null | undefined;
7
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
+ type TagProps = VariantProps<typeof tagVariants> & ElementProps & {
9
+ /** A bare Lucide icon before the label; sized for you. */
10
+ icon?: React.ReactNode;
11
+ /** Something after the label, such as a chevron on a tag that opens a menu. */
12
+ trailing?: React.ReactNode;
13
+ /** Makes the tag a button with hover and press states. A tag that does nothing stays a plain label. */
14
+ onClick?: () => void;
15
+ className?: string;
16
+ /**
17
+ * @deprecated Use `Tip` for an explanation instead: a native title tooltip is slow, unstyled and missing on
18
+ * touch. Kept so apps can move onto the package without changing behaviour; removed in 2.0.
19
+ */
20
+ title?: string;
21
+ children: React.ReactNode;
22
+ };
23
+ type TagColor = NonNullable<VariantProps<typeof tagVariants>["color"]>;
24
+ type TagSize = NonNullable<VariantProps<typeof tagVariants>["size"]>;
25
+ declare function Tag({ color, size, icon, trailing, onClick, className, title, children, ...rest }: TagProps): React.JSX.Element;
26
+ export { Tag, tagVariants };
27
+ export type { TagProps, TagColor, TagSize };
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
4
+ import { cva } from "class-variance-authority";
5
+ import { cn } from "@brdgapp/ui/lib/utils";
6
+ // The soft "Color label" from the Arch Design System. Ported from Bridge OS src/components/ui/tag.tsx
7
+ // (components.md → Tag / Badge / RatingPill). Rounded 4px, never a pill. Every colour is a token family,
8
+ // so it follows dark mode.
9
+ const tagVariants = cva("inline-flex items-center gap-1 rounded-[4px] border font-semibold tracking-[0.4px] align-middle [&_svg]:shrink-0", {
10
+ variants: {
11
+ color: {
12
+ grey: "border-[var(--border-default)] bg-[var(--surface-secondary)] text-[var(--text-secondary)]",
13
+ blue: "border-[var(--info-border)] bg-[var(--info-bg)] text-[var(--info)]",
14
+ green: "border-[var(--success-border)] bg-[var(--success-bg)] text-[var(--success-text)]",
15
+ yellow: "border-[var(--warning-border)] bg-[var(--warning-bg)] text-[var(--warning-text)]",
16
+ red: "border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]",
17
+ purple: "border-[var(--purple-border)] bg-[var(--purple-bg)] text-[var(--purple)]",
18
+ },
19
+ size: {
20
+ sm: "px-2 py-0.5 text-[12px] [&_svg]:size-3.5",
21
+ md: "px-2.5 py-1 text-[14px] [&_svg]:size-4",
22
+ },
23
+ },
24
+ defaultVariants: { color: "grey", size: "sm" },
25
+ });
26
+ function Tag({ color, size, icon, trailing, onClick, className, title, children, ...rest }) {
27
+ const classes = cn(tagVariants({ color, size }), onClick &&
28
+ "cursor-pointer transition-[filter,opacity] hover:brightness-[0.97] active:opacity-60 outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]", className);
29
+ const inner = (_jsxs(_Fragment, { children: [icon ? (_jsx("span", { "data-slot": "tag-icon", "aria-hidden": "true", className: "inline-flex shrink-0 items-center", children: icon })) : null, _jsx("span", { "data-slot": "tag-label", className: "truncate", children: children }), trailing ? (_jsx("span", { "data-slot": "tag-trailing", className: "inline-flex shrink-0 items-center", children: trailing })) : null] }));
30
+ if (onClick) {
31
+ return (_jsx(ButtonPrimitive, { ...rest, "data-slot": "tag", onClick: onClick, title: title, className: classes, children: inner }));
32
+ }
33
+ return (_jsx("span", { ...rest, "data-slot": "tag", title: title, className: classes, children: inner }));
34
+ }
35
+ export { Tag, tagVariants };
@@ -0,0 +1,12 @@
1
+ import * as React from "react";
2
+ type TextareaProps = Omit<React.ComponentProps<"textarea">, "className" | "style"> & {
3
+ /** Lines tall when empty. */
4
+ rows?: number;
5
+ /** Lines it grows to before scrolling. Set equal to `rows` for a fixed height. */
6
+ maxRows?: number;
7
+ /** Layout only: `max-w-md`. */
8
+ className?: string;
9
+ };
10
+ declare function Textarea({ rows, maxRows, maxLength, className, value, defaultValue, onChange, ref, ...props }: TextareaProps): React.JSX.Element;
11
+ export { Textarea };
12
+ export type { TextareaProps };
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Field as FieldPrimitive } from "@base-ui/react/field";
5
+ import { cn } from "@brdgapp/ui/lib/utils";
6
+ // A multi-line text field: notes, bios, intro messages. Started from shadcn's textarea, then put on
7
+ // Bridge's look and built on Base UI Field.Control, so inside a Field the label, help text and error are
8
+ // tied to it like Input.
9
+ //
10
+ // Decisions (2026-11-03, after checking all 12 app repos: 91 files with a textarea):
11
+ // - Same box as Input: white, 14px text (16px on phones: iOS zooms into smaller fields), 8px corners, the same hover, focus and error borders.
12
+ // - Starts 3 lines tall (apps use 2 to 4), grows with the text up to 10 lines, then scrolls inside. Grown
13
+ // by hand in 15 files across 7 apps (app-cc's useAutoResizeTextarea, chat, together…). No drag handle:
14
+ // 43 places turned it off. Grows with JavaScript, since CSS `field-sizing` isn't in every browser yet.
15
+ // - With `maxLength`, a "120/500" count under the box, turning red near the limit, as in Bridge OS's
16
+ // network-entry-modal.tsx. Hand-made in 15 files across 6 apps.
17
+ const LINE = 20; // a 20px line (leading-5), at 14px and at 16px on phones
18
+ const PAD_Y = 10; // py-2.5
19
+ const BORDER = 1;
20
+ const textareaClasses = cn("block w-full min-w-0 resize-none rounded-lg border border-[var(--border-default)] bg-[var(--surface-primary)] px-3.5 py-2.5", "text-16 leading-5 md:text-14 md:leading-5 text-[var(--text-primary)] placeholder:text-[var(--text-muted)]", "transition-colors duration-150 ease-out outline-none", "hover:border-[var(--border-hover)]", "focus-visible:border-[var(--primary)] focus-visible:ring-2 focus-visible:ring-[var(--primary)]/20", "aria-invalid:border-[var(--error)] aria-invalid:hover:border-[var(--error)] aria-invalid:focus-visible:border-[var(--error)] aria-invalid:focus-visible:ring-[var(--error)]/20", "data-[invalid]:border-[var(--error)] data-[invalid]:hover:border-[var(--error)] data-[invalid]:focus-visible:border-[var(--error)] data-[invalid]:focus-visible:ring-[var(--error)]/20", "disabled:cursor-not-allowed disabled:opacity-50");
21
+ /** How close to the limit the count turns red: the last 10%, at most 50 characters. */
22
+ function nearLimit(length, max) {
23
+ return max - length <= Math.min(50, Math.ceil(max * 0.1));
24
+ }
25
+ function Textarea({ rows = 3, maxRows = 10, maxLength, className, value, defaultValue, onChange, ref, ...props }) {
26
+ const inner = React.useRef(null);
27
+ const [typed, setTyped] = React.useState(() => String(defaultValue ?? "").length);
28
+ const length = value !== undefined ? String(value).length : typed;
29
+ const countId = React.useId();
30
+ const minHeight = rows * LINE + PAD_Y * 2 + BORDER * 2;
31
+ const maxHeight = Math.max(rows, maxRows) * LINE + PAD_Y * 2 + BORDER * 2;
32
+ const fit = React.useCallback(() => {
33
+ const el = inner.current;
34
+ if (!el)
35
+ return;
36
+ el.style.height = "auto";
37
+ const needed = el.scrollHeight + BORDER * 2;
38
+ el.style.height = `${Math.min(Math.max(needed, minHeight), maxHeight)}px`;
39
+ el.style.overflowY = needed > maxHeight ? "auto" : "hidden";
40
+ }, [minHeight, maxHeight]);
41
+ // Fit on first paint, whenever a controlled value changes, and when the box changes width.
42
+ React.useLayoutEffect(fit, [fit, value]);
43
+ React.useEffect(() => {
44
+ const el = inner.current;
45
+ if (!el || typeof ResizeObserver === "undefined")
46
+ return;
47
+ let width = el.offsetWidth;
48
+ const observer = new ResizeObserver(() => {
49
+ if (el.offsetWidth !== width) {
50
+ width = el.offsetWidth;
51
+ fit();
52
+ }
53
+ });
54
+ observer.observe(el);
55
+ return () => observer.disconnect();
56
+ }, [fit]);
57
+ const setRefs = (el, controlRef) => {
58
+ inner.current = el;
59
+ for (const r of [ref, controlRef]) {
60
+ if (typeof r === "function")
61
+ r(el);
62
+ else if (r)
63
+ r.current = el;
64
+ }
65
+ };
66
+ const remaining = maxLength !== undefined ? maxLength - length : null;
67
+ const warn = maxLength !== undefined && nearLimit(length, maxLength);
68
+ return (_jsxs("div", { "data-slot": "textarea-wrapper", className: cn("flex w-full flex-col gap-1", className), children: [_jsx(FieldPrimitive.Control, { render: (controlProps) => {
69
+ const { ref: controlRef, onChange: controlOnChange, ...rest } = controlProps;
70
+ return (_jsx("textarea", { ...rest, ...props, "data-slot": "textarea", ref: (el) => setRefs(el, controlRef), rows: rows, maxLength: maxLength, value: value, defaultValue: defaultValue, "aria-describedby": [rest["aria-describedby"], props["aria-describedby"], maxLength !== undefined ? countId : null].filter(Boolean).join(" ") || undefined, className: textareaClasses, style: { minHeight, maxHeight }, onChange: (e) => {
71
+ controlOnChange?.(e);
72
+ onChange?.(e);
73
+ setTyped(e.target.value.length);
74
+ fit();
75
+ } }));
76
+ } }), maxLength !== undefined ? (_jsxs("p", { id: countId, "data-slot": "textarea-count", className: cn("self-end text-[12px] tabular-nums", warn ? "text-[var(--danger-text)]" : "text-[var(--text-muted)]"), children: [_jsxs("span", { "aria-hidden": "true", children: [length, "/", maxLength] }), _jsx("span", { className: "sr-only", children: `${remaining} of ${maxLength} characters left` })] })) : null, maxLength !== undefined ? (_jsx("span", { className: "sr-only", "aria-live": "polite", children: warn ? (remaining === 0 ? "Character limit reached" : `${remaining} characters left`) : "" })) : null] }));
77
+ }
78
+ export { Textarea };
@@ -0,0 +1,6 @@
1
+ import type * as React from "react";
2
+ /** Light, Dark or System, for the Appearance row in Settings. The choice applies in every Bridge app. */
3
+ declare function ThemeSwitch({ className }: {
4
+ className?: string;
5
+ }): React.JSX.Element;
6
+ export { ThemeSwitch };
@@ -0,0 +1,16 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Monitor, Moon, Sun } from "lucide-react";
4
+ import { SegmentedControl } from "@brdgapp/ui/components/segmented-control";
5
+ import { useTheme } from "@brdgapp/ui/hooks/use-theme";
6
+ const OPTIONS = [
7
+ { value: "light", label: _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(Sun, { "aria-hidden": "true" }), "Light"] }) },
8
+ { value: "dark", label: _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(Moon, { "aria-hidden": "true" }), "Dark"] }) },
9
+ { value: "system", label: _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(Monitor, { "aria-hidden": "true" }), "System"] }) },
10
+ ];
11
+ /** Light, Dark or System, for the Appearance row in Settings. The choice applies in every Bridge app. */
12
+ function ThemeSwitch({ className }) {
13
+ const { theme, setTheme } = useTheme();
14
+ return _jsx(SegmentedControl, { "aria-label": "Appearance", options: OPTIONS, value: theme, onChange: setTheme, className: className });
15
+ }
16
+ export { ThemeSwitch };
@@ -0,0 +1,25 @@
1
+ type ToastVariant = "success" | "error";
2
+ declare const toast: {
3
+ /** A past-tense confirmation: "Intro sent". */
4
+ success: (message: string) => string;
5
+ /** Something that failed out of sight: "Couldn't copy the link". Errors people must fix stay on the page. */
6
+ error: (message: string) => string;
7
+ /** Hide one toast, or all of them. */
8
+ close: (id?: string) => void;
9
+ };
10
+ /** Mount once in the app's root layout. Toasts last 3 seconds; at most 3 show at once. */
11
+ declare function Toaster({ timeout }: {
12
+ timeout?: number;
13
+ }): import("react").JSX.Element;
14
+ type ToastProps = {
15
+ open: boolean;
16
+ message: string;
17
+ variant?: ToastVariant;
18
+ };
19
+ /**
20
+ * @deprecated Use `<Toaster />` and `toast.success()`. Kept for Bridge OS screens that own their timer;
21
+ * removed in 2.0.
22
+ */
23
+ declare function Toast({ open, message, variant }: ToastProps): import("react").JSX.Element;
24
+ export { Toast, Toaster, toast };
25
+ export type { ToastProps, ToastVariant };
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import { Toast as ToastPrimitive } from "@base-ui/react/toast";
5
+ import { AlertCircle, Check } from "lucide-react";
6
+ import { cn } from "@brdgapp/ui/lib/utils";
7
+ /** The app-wide toast queue. Needs one `<Toaster />` mounted. */
8
+ const manager = ToastPrimitive.createToastManager();
9
+ const toast = {
10
+ /** A past-tense confirmation: "Intro sent". */
11
+ success: (message) => manager.add({ title: message, type: "success" }),
12
+ /** Something that failed out of sight: "Couldn't copy the link". Errors people must fix stay on the page. */
13
+ error: (message) => manager.add({ title: message, type: "error", priority: "high" }),
14
+ /** Hide one toast, or all of them. */
15
+ close: (id) => manager.close(id),
16
+ };
17
+ const VIEWPORT = "fixed inset-x-0 top-[72px] z-[100] flex flex-col items-center gap-2 px-4 outline-none pointer-events-none";
18
+ function ToastList() {
19
+ const { toasts } = ToastPrimitive.useToastManager();
20
+ return toasts.map((t) => {
21
+ const Icon = t.type === "error" ? AlertCircle : Check;
22
+ return (_jsxs(ToastPrimitive.Root, { toast: t, swipeDirection: "up", "data-slot": "toast", className: cn("pointer-events-auto inline-flex items-center gap-2 rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] py-2 pr-4 pl-3 shadow-[var(--shadow-menu)]", "transition-[opacity,translate] duration-200 ease-out data-[ending-style]:duration-150 data-[starting-style]:-translate-y-2 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0"), children: [_jsx(Icon, { "aria-hidden": "true", className: cn("size-4 shrink-0", t.type === "error" ? "text-[var(--danger-text)]" : "text-[var(--success)]") }), _jsx(ToastPrimitive.Title, { className: "text-[13px] font-semibold text-[var(--text-primary)]" })] }, t.id));
23
+ });
24
+ }
25
+ /** Mount once in the app's root layout. Toasts last 3 seconds; at most 3 show at once. */
26
+ function Toaster({ timeout = 3000 }) {
27
+ return (_jsx(ToastPrimitive.Provider, { toastManager: manager, timeout: timeout, limit: 3, children: _jsx(ToastPrimitive.Portal, { children: _jsx(ToastPrimitive.Viewport, { "data-slot": "toaster", className: VIEWPORT, children: _jsx(ToastList, {}) }) }) }));
28
+ }
29
+ /**
30
+ * @deprecated Use `<Toaster />` and `toast.success()`. Kept for Bridge OS screens that own their timer;
31
+ * removed in 2.0.
32
+ */
33
+ function Toast({ open, message, variant = "success" }) {
34
+ // Its own small queue, so it works without a Toaster. Never times out: `open` decides.
35
+ const [own] = useState(() => ToastPrimitive.createToastManager());
36
+ useEffect(() => {
37
+ if (!open)
38
+ return;
39
+ const id = own.add({ title: message, type: variant, timeout: 0 });
40
+ return () => own.close(id);
41
+ }, [open, message, variant, own]);
42
+ return (_jsx(ToastPrimitive.Provider, { toastManager: own, children: _jsx(ToastPrimitive.Portal, { children: _jsx(ToastPrimitive.Viewport, { className: VIEWPORT, children: _jsx(ToastList, {}) }) }) }));
43
+ }
44
+ export { Toast, Toaster, toast };
@@ -0,0 +1,2 @@
1
+ declare function useIsMobile(): boolean;
2
+ export { useIsMobile };
@@ -0,0 +1,16 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ // True below 768px (Tailwind's `md`), the line where Bridge switches to its phone shapes: one pane at a time,
4
+ // bottom sheets instead of menus. Use it only when the structure changes; for looks, use `md:` classes,
5
+ // which also work in the server render. Returns false on the server and in the first client render, then
6
+ // the real answer, and updates when the window crosses the line (a tablet turned sideways).
7
+ const QUERY = "(max-width: 767px)";
8
+ function subscribe(onChange) {
9
+ const list = window.matchMedia(QUERY);
10
+ list.addEventListener("change", onChange);
11
+ return () => list.removeEventListener("change", onChange);
12
+ }
13
+ function useIsMobile() {
14
+ return useSyncExternalStore(subscribe, () => window.matchMedia(QUERY).matches, () => false);
15
+ }
16
+ export { useIsMobile };
@@ -0,0 +1,23 @@
1
+ type MockQueryStatus = "loading" | "error" | "success";
2
+ type MockQueryResult<T> = {
3
+ status: MockQueryStatus;
4
+ data: T | undefined;
5
+ error: Error | undefined;
6
+ /** Load again, for example from a Retry button. */
7
+ retry: () => void;
8
+ };
9
+ type MockQueryOptions = {
10
+ /** Overrides the global demo delay for this query. */
11
+ latency?: number;
12
+ /** Forces this query to fail, whatever the global setting says. */
13
+ fail?: boolean;
14
+ };
15
+ /**
16
+ * Fake data loading for handbook demos, so every loading, empty and error state can be shown live.
17
+ * Follows the global demo controls (delay, failure, replay). Demo-only — product code uses real data.
18
+ *
19
+ * const { status, data, retry } = useMockQuery("intros-table", () => SAMPLE_INTROS);
20
+ */
21
+ declare function useMockQuery<T>(key: string, getData: () => T, options?: MockQueryOptions): MockQueryResult<T>;
22
+ export { useMockQuery };
23
+ export type { MockQueryResult, MockQueryStatus, MockQueryOptions };
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { useDemoControls } from "@brdgapp/ui/lib/demo-controls";
4
+ /**
5
+ * Fake data loading for handbook demos, so every loading, empty and error state can be shown live.
6
+ * Follows the global demo controls (delay, failure, replay). Demo-only — product code uses real data.
7
+ *
8
+ * const { status, data, retry } = useMockQuery("intros-table", () => SAMPLE_INTROS);
9
+ */
10
+ function useMockQuery(key, getData, options = {}) {
11
+ const controls = useDemoControls();
12
+ const latency = options.latency ?? controls.latency;
13
+ const shouldFail = options.fail ?? controls.fail;
14
+ const [attempt, setAttempt] = useState(0);
15
+ // Every change of input is a new request. Until that request settles, the status is "loading" —
16
+ // worked out here rather than set in the effect, so nothing renders twice.
17
+ const request = `${key}|${latency}|${shouldFail}|${controls.replayKey}|${attempt}`;
18
+ const [settled, setSettled] = useState(null);
19
+ useEffect(() => {
20
+ const timer = setTimeout(() => {
21
+ setSettled(shouldFail
22
+ ? { request, error: new Error(`Couldn't load ${key}`) }
23
+ : { request, data: getData() });
24
+ }, latency);
25
+ return () => clearTimeout(timer);
26
+ // getData is read once per request on purpose; callers pass inline functions.
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ }, [request]);
29
+ const retry = useCallback(() => setAttempt((a) => a + 1), []);
30
+ if (settled?.request !== request)
31
+ return { status: "loading", data: undefined, error: undefined, retry };
32
+ if (settled.error)
33
+ return { status: "error", data: undefined, error: settled.error, retry };
34
+ return { status: "success", data: settled.data, error: undefined, retry };
35
+ }
36
+ export { useMockQuery };
@@ -0,0 +1,11 @@
1
+ import { type ResolvedTheme, type ThemePreference } from "@brdgapp/ui/lib/theme";
2
+ export type UseTheme = {
3
+ /** The choice: "light", "dark" or "system". */
4
+ theme: ThemePreference;
5
+ /** What's drawn now. */
6
+ resolvedTheme: ResolvedTheme;
7
+ /** Saves the choice for every Bridge app and applies it. */
8
+ setTheme: (theme: ThemePreference) => void;
9
+ };
10
+ /** The theme choice, what's drawn, and a way to change it. Needs `themeScript` in the root layout's <head>. */
11
+ export declare function useTheme(): UseTheme;