@passui/react 0.1.40 → 0.1.41

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.
@@ -1,8 +1,11 @@
1
- import type { ComponentPropsWithoutRef, PropsWithChildren, ReactNode } from "react";
1
+ import { type ComponentPropsWithoutRef, type PropsWithChildren, type ReactNode } from "react";
2
2
  export type ContentViewVariant = keyof typeof variants;
3
3
  export type ContentViewHeadingLevel = "h1" | "h2" | "h3";
4
+ export type ContentViewSize = "sm" | "md" | "lg";
4
5
  type ContentViewProps = ComponentPropsWithoutRef<"section"> & {
5
6
  variant?: ContentViewVariant;
7
+ /** Container radius, section gap and text scale. @default "md" */
8
+ size?: ContentViewSize;
6
9
  };
7
10
  type RowProps = ComponentPropsWithoutRef<"div">;
8
11
  type SectionProps = ComponentPropsWithoutRef<"section">;
@@ -24,7 +27,7 @@ type SubProps = ComponentPropsWithoutRef<"sub">;
24
27
  declare const variants: {
25
28
  readonly elevated: string;
26
29
  };
27
- export declare function ContentView({ variant, className, children, ...props }: ContentViewProps): import("react").JSX.Element;
30
+ export declare function ContentView({ variant, size, className, children, ...props }: ContentViewProps): import("react").JSX.Element;
28
31
  export declare namespace ContentView {
29
32
  export { Row };
30
33
  export { Section };
@@ -3,6 +3,7 @@ import type { MenuRect } from "./menu-reveal";
3
3
  export type ContextMenuAlignX = "start" | "end";
4
4
  export type ContextMenuAlignY = "top" | "bottom";
5
5
  export type ContextMenuEdge = "leading" | "trailing";
6
+ export type ContextMenuSize = "sm" | "md" | "lg";
6
7
  /** One row of a context menu, or a divider between groups of rows. */
7
8
  export type ContextMenuItem = {
8
9
  label: string;
@@ -23,6 +24,8 @@ export interface OpenMenu {
23
24
  items: ContextMenuItem[];
24
25
  /** Opened by long-press: bigger rows for fingers. */
25
26
  touch: boolean;
27
+ /** Surface, item and heading density. @default "md" */
28
+ size: ContextMenuSize;
26
29
  /** Which edge of the menu sits at `x`: its left (`start`) or its right (`end`). */
27
30
  alignX: ContextMenuAlignX;
28
31
  /** Which edge of the menu sits at `y`: its top (`top`) or its bottom (`bottom`). */
@@ -33,7 +36,7 @@ export interface OpenMenu {
33
36
  restoreFocus?: boolean;
34
37
  }
35
38
  /** Opens a menu at a point, such as where a pointer was pressed. */
36
- export declare function openContextMenu(x: number, y: number, items: ContextMenuItem[], touch?: boolean, anchor?: Element | null): void;
39
+ export declare function openContextMenu(x: number, y: number, items: ContextMenuItem[], touch?: boolean, anchor?: Element | null, size?: ContextMenuSize): void;
37
40
  /**
38
41
  * Opens a second menu where the last one stood, the way an iOS menu swaps
39
42
  * in a submenu: a long list (a note's fonts, its kinds) stays one row in the
@@ -46,11 +49,13 @@ export declare function openFollowUpMenu(items: ContextMenuItem[]): void;
46
49
  * trailing follow the text direction, so right-to-left languages mirror.
47
50
  * The menu's real size decides where it goes; nothing is guessed.
48
51
  */
49
- export declare function openMenu(anchor: Element, items: ContextMenuItem[], { edge, above, toolbar, }?: {
52
+ export declare function openMenu(anchor: Element, items: ContextMenuItem[], { edge, above, toolbar, size, }?: {
50
53
  edge?: ContextMenuEdge;
51
54
  above?: boolean;
52
55
  /** Replace this action group with a horizontally expanding menu surface. */
53
56
  toolbar?: HTMLElement;
57
+ /** Surface, item and heading density. @default "md" */
58
+ size?: ContextMenuSize;
54
59
  }): void;
55
60
  export declare function closeContextMenu(restoreFocus?: boolean): void;
56
61
  export declare function subscribeContextMenu(listener: () => void): () => void;
@@ -9,15 +9,18 @@ export type ConversationMessage = {
9
9
  avatar?: ReactNode;
10
10
  createdAt: string;
11
11
  };
12
+ export type ConversationSize = "sm" | "md" | "lg";
12
13
  /**
13
14
  * A thread of plain-text messages like Messages on iPhone: a day line when
14
15
  * the date changes, back-to-back messages from one side grouped under one
15
16
  * name, theirs on the left with an avatar, yours on the right.
16
17
  */
17
- declare function Messages({ messages, locale, }: {
18
+ declare function Messages({ messages, locale, size, }: {
18
19
  messages: ConversationMessage[];
19
20
  /** For day names and times; the reader's own by default. */
20
21
  locale?: string;
22
+ /** Bubble, avatar, author and time scale. @default "md" */
23
+ size?: ConversationSize;
21
24
  }): import("react").JSX.Element;
22
25
  type ComposerProps = {
23
26
  onSend: (body: string) => Promise<unknown>;
@@ -33,9 +36,11 @@ type ComposerProps = {
33
36
  * (count) => `${count} left`. Without it, just the number shows.
34
37
  */
35
38
  remaining?: (count: number) => string;
39
+ /** Surface, textarea, send button and counter scale. @default "md" */
40
+ size?: ConversationSize;
36
41
  };
37
42
  /** A reply field with a round send button; Ctrl/Cmd+Enter sends, and it clears only once the send worked. */
38
- declare function Composer({ onSend, pending, maxLength, placeholder, actions, sendLabel, remaining, }: ComposerProps): import("react").JSX.Element;
43
+ declare function Composer({ onSend, pending, maxLength, placeholder, actions, sendLabel, remaining, size, }: ComposerProps): import("react").JSX.Element;
39
44
  export declare const Conversation: {
40
45
  Messages: typeof Messages;
41
46
  Composer: typeof Composer;
@@ -3,6 +3,7 @@
3
3
  * a failure. Call `toast` from anywhere; render one `<Toaster />`.
4
4
  */
5
5
  export type ToastTone = "neutral" | "success" | "error" | "loading";
6
+ export type ToastSize = "sm" | "md" | "lg";
6
7
  export interface ToastAction {
7
8
  label: string;
8
9
  onClick: () => void;
@@ -16,11 +17,15 @@ export interface Toast {
16
17
  /** Milliseconds before it leaves on its own; `null` stays until dismissed. */
17
18
  duration: number | null;
18
19
  createdAt: number;
20
+ /** Pill size, content padding and title/description text scale. @default "md" */
21
+ size: ToastSize;
19
22
  }
20
23
  export interface ToastOptions {
21
24
  description?: string;
22
25
  action?: ToastAction;
23
26
  duration?: number | null;
27
+ /** Pill size, content padding and title/description text scale. @default "md" */
28
+ size?: ToastSize;
24
29
  }
25
30
  /** Changes a toast in place; it morphs to its new state. */
26
31
  declare function update(id: number, tone: ToastTone, title: string, options?: ToastOptions): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passui/react",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "Passui: native React controls, design tokens, presentation and appearance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,11 +1,49 @@
1
- import type { ComponentPropsWithoutRef, PropsWithChildren, ReactNode } from "react"
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ type ComponentPropsWithoutRef,
5
+ type PropsWithChildren,
6
+ type ReactNode,
7
+ } from "react"
2
8
  import { cn } from "../../lib/class-names"
3
9
 
4
10
  export type ContentViewVariant = keyof typeof variants
5
11
  export type ContentViewHeadingLevel = "h1" | "h2" | "h3"
12
+ export type ContentViewSize = "sm" | "md" | "lg"
13
+
14
+ const containerRadius: Record<ContentViewSize, string> = {
15
+ sm: "rounded-xl",
16
+ md: "rounded-2xl",
17
+ lg: "rounded-3xl",
18
+ }
19
+ const sectionGaps: Record<ContentViewSize, string> = {
20
+ sm: "gap-2",
21
+ md: "gap-3",
22
+ lg: "gap-4",
23
+ }
24
+ const spanText: Record<ContentViewSize, string> = {
25
+ sm: "text-footnote tracking-tighter font-light",
26
+ md: "text-callout tracking-tighter font-light",
27
+ lg: "text-subheadline tracking-tighter font-light",
28
+ }
29
+ const subText: Record<ContentViewSize, string> = {
30
+ sm: "text-footnote text-label",
31
+ md: "text-subheadline text-label",
32
+ lg: "text-body text-label",
33
+ }
34
+ const headerActionGaps: Record<ContentViewSize, string> = {
35
+ sm: "gap-2",
36
+ md: "gap-3",
37
+ lg: "gap-4",
38
+ }
39
+
40
+ const ContentViewSizeContext = createContext<ContentViewSize>("md")
41
+ const useContentViewSize = () => useContext(ContentViewSizeContext)
6
42
 
7
43
  type ContentViewProps = ComponentPropsWithoutRef<"section"> & {
8
44
  variant?: ContentViewVariant
45
+ /** Container radius, section gap and text scale. @default "md" */
46
+ size?: ContentViewSize
9
47
  }
10
48
 
11
49
  type RowProps = ComponentPropsWithoutRef<"div">
@@ -38,14 +76,24 @@ const variants = {
38
76
 
39
77
  export function ContentView({
40
78
  variant = "elevated",
79
+ size = "md",
41
80
  className,
42
81
  children,
43
82
  ...props
44
83
  }: ContentViewProps) {
45
84
  return (
46
- <section className={cn(variants[variant], className)} {...props}>
47
- {children}
48
- </section>
85
+ <ContentViewSizeContext value={size}>
86
+ <section
87
+ className={cn(
88
+ variants[variant],
89
+ variant === "elevated" && containerRadius[size],
90
+ className
91
+ )}
92
+ {...props}
93
+ >
94
+ {children}
95
+ </section>
96
+ </ContentViewSizeContext>
49
97
  )
50
98
  }
51
99
 
@@ -58,8 +106,9 @@ function Row({ className, children, ...props }: RowProps) {
58
106
  }
59
107
 
60
108
  function Section({ className, children, ...props }: SectionProps) {
109
+ const size = useContentViewSize()
61
110
  return (
62
- <section className={cn("flex flex-col gap-3", className)} {...props}>
111
+ <section className={cn("flex flex-col", sectionGaps[size], className)} {...props}>
63
112
  {children}
64
113
  </section>
65
114
  )
@@ -83,10 +132,11 @@ function Header({
83
132
  </Tag>
84
133
  )
85
134
 
135
+ const size = useContentViewSize()
86
136
  return (
87
137
  <div className={className}>
88
138
  {action ? (
89
- <div className={cn("flex items-center gap-3", actionClassName)}>
139
+ <div className={cn("flex items-center", headerActionGaps[size], actionClassName)}>
90
140
  {title}
91
141
  {action}
92
142
  </div>
@@ -128,16 +178,18 @@ function P({ className, children, ...props }: PProps) {
128
178
  }
129
179
 
130
180
  function Span({ className, children, ...props }: SpanProps) {
181
+ const size = useContentViewSize()
131
182
  return (
132
- <span className={cn("text-callout tracking-tighter font-light", className)} {...props}>
183
+ <span className={cn(spanText[size], className)} {...props}>
133
184
  {children}
134
185
  </span>
135
186
  )
136
187
  }
137
188
 
138
189
  function Sub({ className, children, ...props }: SubProps) {
190
+ const size = useContentViewSize()
139
191
  return (
140
- <sub className={cn("text-subheadline text-label", className)} {...props}>
192
+ <sub className={cn(subText[size], className)} {...props}>
141
193
  {children}
142
194
  </sub>
143
195
  )
@@ -13,9 +13,45 @@ import { menuSurfaceExit, menuSurfaceReveal } from "./menu-reveal"
13
13
  import {
14
14
  closeContextMenu,
15
15
  getContextMenu,
16
- type OpenMenu,
16
+ type ContextMenuItem,
17
+ type ContextMenuSize,
18
+ openContextMenu,
19
+ openFollowUpMenu,
20
+ openMenu,
17
21
  subscribeContextMenu,
18
22
  } from "./context-menu-store"
23
+ import type { OpenMenu } from "./context-menu-store"
24
+
25
+ const surfaceSizes: Record<ContextMenuSize, string> = {
26
+ sm: "min-w-44 max-w-[min(240px,calc(100vw-24px))] rounded-2xl p-1",
27
+ md: "min-w-50 max-w-[min(280px,calc(100vw-24px))] rounded-3xl p-1.5",
28
+ lg: "min-w-56 max-w-[min(320px,calc(100vw-24px))] rounded-3xl p-2",
29
+ }
30
+ const itemSizes: Record<ContextMenuSize, string> = {
31
+ sm: "flex items-center gap-2 rounded-md px-2 py-1 text-start text-caption transition-colors disabled:opacity-40",
32
+ md: "flex items-center gap-2.5 rounded-lg px-2.5 text-start text-subheadline transition-colors disabled:opacity-40",
33
+ lg: "flex items-center gap-3 rounded-lg px-3 py-2 text-start text-body transition-colors disabled:opacity-40",
34
+ }
35
+ const headingSizes: Record<ContextMenuSize, string> = {
36
+ sm: "px-2 pt-1 pb-0.5 text-caption2 font-semibold text-label-tertiary",
37
+ md: "px-2.5 pt-1.5 pb-0.5 text-caption2 font-semibold text-label-tertiary",
38
+ lg: "px-3 pt-2 pb-1 text-caption font-semibold text-label-tertiary",
39
+ }
40
+ const dividerSizes: Record<ContextMenuSize, string> = {
41
+ sm: "mx-1.5 my-0.5 h-px bg-separator/80",
42
+ md: "mx-2 my-1 h-px bg-separator/80",
43
+ lg: "mx-2.5 my-1.5 h-px bg-separator/80",
44
+ }
45
+ const iconSlotSizes: Record<ContextMenuSize, string> = {
46
+ sm: "flex w-3.5 shrink-0 justify-center text-label-secondary",
47
+ md: "flex w-4 shrink-0 justify-center text-label-secondary",
48
+ lg: "flex w-5 shrink-0 justify-center text-label-secondary",
49
+ }
50
+ const checkIconSizes: Record<ContextMenuSize, number> = {
51
+ sm: 13,
52
+ md: 15,
53
+ lg: 17,
54
+ }
19
55
 
20
56
  /** Renders the open context menu. Mount once, near the app root. */
21
57
  export function ContextMenuHost() {
@@ -251,7 +287,8 @@ function Menu({ menu }: { menu: OpenMenu }) {
251
287
  }}
252
288
  {...{ [FLOATING_LAYER]: "" }}
253
289
  className={cn(
254
- "fixed z-75 isolate flex min-w-50 max-w-[min(280px,calc(100vw-24px))] flex-col rounded-3xl p-1.5 outline-none",
290
+ "fixed z-75 isolate flex flex-col outline-none",
291
+ surfaceSizes[menu.size],
255
292
  menu.touch && "min-w-60",
256
293
  !present && "pointer-events-none"
257
294
  )}
@@ -327,19 +364,11 @@ function Menu({ menu }: { menu: OpenMenu }) {
327
364
  >
328
365
  {menu.items.map((item, index) => {
329
366
  if (item === "divider") {
330
- return (
331
- <div
332
- key={`divider-${index}`}
333
- className="mx-2 my-1 h-px bg-separator/80"
334
- />
335
- )
367
+ return <div key={`divider-${index}`} className={dividerSizes[menu.size]} />
336
368
  }
337
369
  if ("heading" in item) {
338
370
  return (
339
- <span
340
- key={`heading-${index}`}
341
- className="px-2.5 pt-1.5 pb-0.5 text-caption2 font-semibold text-label-tertiary"
342
- >
371
+ <span key={`heading-${index}`} className={headingSizes[menu.size]}>
343
372
  {item.heading}
344
373
  </span>
345
374
  )
@@ -360,7 +389,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
360
389
  item.onSelect()
361
390
  }}
362
391
  className={cn(
363
- "flex items-center gap-2.5 rounded-lg px-2.5 text-start text-subheadline transition-colors disabled:opacity-40",
392
+ itemSizes[menu.size],
364
393
  menu.touch ? "py-2.5" : "py-1.5",
365
394
  item.destructive ? "text-danger" : "text-label",
366
395
  focused === current &&
@@ -368,10 +397,10 @@ function Menu({ menu }: { menu: OpenMenu }) {
368
397
  (item.destructive ? "bg-danger/10" : "bg-fill")
369
398
  )}
370
399
  >
371
- <span className="flex w-4 shrink-0 justify-center text-label-secondary">
400
+ <span className={iconSlotSizes[menu.size]}>
372
401
  {item.checked ? (
373
402
  <CheckIcon
374
- size={15}
403
+ size={checkIconSizes[menu.size]}
375
404
  strokeWidth={2.4}
376
405
  className="text-accent-text"
377
406
  />
@@ -4,6 +4,7 @@ import type { MenuRect } from "./menu-reveal"
4
4
  export type ContextMenuAlignX = "start" | "end"
5
5
  export type ContextMenuAlignY = "top" | "bottom"
6
6
  export type ContextMenuEdge = "leading" | "trailing"
7
+ export type ContextMenuSize = "sm" | "md" | "lg"
7
8
 
8
9
  /** One row of a context menu, or a divider between groups of rows. */
9
10
  export type ContextMenuItem =
@@ -27,6 +28,8 @@ export interface OpenMenu {
27
28
  items: ContextMenuItem[]
28
29
  /** Opened by long-press: bigger rows for fingers. */
29
30
  touch: boolean
31
+ /** Surface, item and heading density. @default "md" */
32
+ size: ContextMenuSize
30
33
  /** Which edge of the menu sits at `x`: its left (`start`) or its right (`end`). */
31
34
  alignX: ContextMenuAlignX
32
35
  /** Which edge of the menu sits at `y`: its top (`top`) or its bottom (`bottom`). */
@@ -52,7 +55,8 @@ export function openContextMenu(
52
55
  y: number,
53
56
  items: ContextMenuItem[],
54
57
  touch = false,
55
- anchor: Element | null = typeof document === "undefined" ? null : document.activeElement
58
+ anchor: Element | null = typeof document === "undefined" ? null : document.activeElement,
59
+ size: ContextMenuSize = "md"
56
60
  ) {
57
61
  menu = {
58
62
  id: nextId++,
@@ -61,6 +65,7 @@ export function openContextMenu(
61
65
  y,
62
66
  items,
63
67
  touch,
68
+ size,
64
69
  alignX: "start",
65
70
  alignY: "top",
66
71
  }
@@ -93,11 +98,14 @@ export function openMenu(
93
98
  edge = "leading",
94
99
  above = false,
95
100
  toolbar,
101
+ size = "md",
96
102
  }: {
97
103
  edge?: ContextMenuEdge
98
104
  above?: boolean
99
105
  /** Replace this action group with a horizontally expanding menu surface. */
100
106
  toolbar?: HTMLElement
107
+ /** Surface, item and heading density. @default "md" */
108
+ size?: ContextMenuSize
101
109
  } = {}
102
110
  ) {
103
111
  const rect = anchor.getBoundingClientRect()
@@ -112,6 +120,7 @@ export function openMenu(
112
120
  y: toolbar ? rect.top : above ? rect.bottom : rect.top,
113
121
  items,
114
122
  touch: false,
123
+ size,
115
124
  alignX: right ? "end" : "start",
116
125
  alignY: above ? "bottom" : "top",
117
126
  toolbar,
@@ -16,12 +16,73 @@ export type ConversationMessage = {
16
16
  createdAt: string
17
17
  }
18
18
 
19
+ export type ConversationSize = "sm" | "md" | "lg"
20
+
21
+ const avatarSizes: Record<ConversationSize, string> = {
22
+ sm: "size-6",
23
+ md: "size-7",
24
+ lg: "size-8",
25
+ }
26
+ const bubbleClasses: Record<ConversationSize, string> = {
27
+ sm: "rounded-3xl px-3 py-1.5 text-footnote leading-snug",
28
+ md: "rounded-4xl px-3.5 py-2 text-subheadline leading-snug",
29
+ lg: "rounded-5xl px-4 py-2.5 text-body leading-snug",
30
+ }
31
+ const bubbleTails: Record<ConversationSize, string> = {
32
+ sm: "rounded-br-sm",
33
+ md: "rounded-br-md",
34
+ lg: "rounded-br-lg",
35
+ }
36
+ const bubbleTailsOther: Record<ConversationSize, string> = {
37
+ sm: "rounded-bl-sm",
38
+ md: "rounded-bl-md",
39
+ lg: "rounded-bl-lg",
40
+ }
41
+ const authorText: Record<ConversationSize, string> = {
42
+ sm: "px-3 text-caption2 text-label-secondary",
43
+ md: "px-3 text-caption2 text-label-secondary",
44
+ lg: "px-3.5 text-caption text-label-secondary",
45
+ }
46
+ const timeText: Record<ConversationSize, string> = {
47
+ sm: "px-3 text-caption2 text-label-secondary tabular-nums",
48
+ md: "px-3 text-caption2 text-label-secondary tabular-nums",
49
+ lg: "px-3.5 text-caption text-label-secondary tabular-nums",
50
+ }
51
+ const dayMargins: Record<ConversationSize, string> = {
52
+ sm: "my-3",
53
+ md: "my-4",
54
+ lg: "my-5",
55
+ }
56
+
57
+ // Composer size records
58
+ const composerSurfaces: Record<ConversationSize, string> = {
59
+ sm: "flex items-end gap-2 rounded-4xl border border-separator bg-elevated py-1 pr-1 pl-3 transition-colors focus-within:border-accent-text/60",
60
+ md: "flex items-end gap-2 rounded-5xl border border-separator bg-elevated py-1.5 pr-1.5 pl-4 transition-colors focus-within:border-accent-text/60",
61
+ lg: "flex items-end gap-2 rounded-5xl border border-separator bg-elevated py-2 pr-2 pl-5 transition-colors focus-within:border-accent-text/60",
62
+ }
63
+ const textareaClasses: Record<ConversationSize, string> = {
64
+ sm: "field-sizing-content max-h-48 min-h-8 flex-1 resize-none bg-transparent py-1 text-footnote text-label outline-none placeholder:text-label-secondary",
65
+ md: "field-sizing-content max-h-48 min-h-9 flex-1 resize-none bg-transparent py-1.5 text-subheadline text-label outline-none placeholder:text-label-secondary",
66
+ lg: "field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent py-2 text-body text-label outline-none placeholder:text-label-secondary",
67
+ }
68
+ const sendButtonClasses: Record<ConversationSize, string> = {
69
+ sm: "size-7 shrink-0 rounded-full",
70
+ md: "size-8 shrink-0 rounded-full",
71
+ lg: "size-9 shrink-0 rounded-full",
72
+ }
73
+ const sendIconSizes: Record<ConversationSize, number> = {
74
+ sm: 16,
75
+ md: 18,
76
+ lg: 20,
77
+ }
78
+ const counterText: Record<ConversationSize, string> = {
79
+ sm: "text-caption2 text-label-secondary tabular-nums",
80
+ md: "text-caption2 text-label-secondary tabular-nums",
81
+ lg: "text-caption text-label-secondary tabular-nums",
82
+ }
83
+
19
84
  const GROUP_GAP_MS = 5 * 60 * 1000
20
85
 
21
- /**
22
- * The day line: today and yesterday in the reader's own words, from
23
- * Intl rather than built-in English, else the full date.
24
- */
25
86
  function dayLabel(iso: string, locale?: string) {
26
87
  const date = new Date(iso)
27
88
  const today = new Date()
@@ -59,10 +120,13 @@ const clock = (iso: string, locale?: string) =>
59
120
  function Messages({
60
121
  messages,
61
122
  locale,
123
+ size = "md",
62
124
  }: {
63
125
  messages: ConversationMessage[]
64
126
  /** For day names and times; the reader's own by default. */
65
127
  locale?: string
128
+ /** Bubble, avatar, author and time scale. @default "md" */
129
+ size?: ConversationSize
66
130
  }) {
67
131
  return (
68
132
  <ol data-not-typeset className="flex flex-col">
@@ -87,10 +151,12 @@ function Messages({
87
151
  return (
88
152
  <li key={message.id} className="flex flex-col">
89
153
  {newDay && (
90
- // the reader's own day and clock, which a server can't know
91
154
  <span
92
155
  suppressHydrationWarning
93
- className="my-4 text-center text-caption2 font-medium text-label-secondary"
156
+ className={cn(
157
+ dayMargins[size],
158
+ "text-center text-caption2 font-medium text-label-secondary"
159
+ )}
94
160
  >
95
161
  {dayLabel(message.createdAt, locale)}
96
162
  </span>
@@ -103,7 +169,12 @@ function Messages({
103
169
  )}
104
170
  >
105
171
  {!message.mine && (
106
- <span className="flex size-7 shrink-0 items-center justify-center">
172
+ <span
173
+ className={cn(
174
+ "flex shrink-0 items-center justify-center",
175
+ avatarSizes[size]
176
+ )}
177
+ >
107
178
  {endsGroup && message.avatar}
108
179
  </span>
109
180
  )}
@@ -114,21 +185,21 @@ function Messages({
114
185
  )}
115
186
  >
116
187
  {startsGroup && message.author && (
117
- <span className="px-3 text-caption2 text-label-secondary">
118
- {message.author}
119
- </span>
188
+ <span className={authorText[size]}>{message.author}</span>
120
189
  )}
121
- {/* text only: a message can never become markup */}
122
190
  <p
123
191
  suppressHydrationWarning
124
192
  title={formatTime(message.createdAt)}
125
193
  className={cn(
126
- "rounded-4xl px-3.5 py-2 text-subheadline leading-snug whitespace-pre-wrap wrap-break-word",
194
+ bubbleClasses[size],
195
+ "whitespace-pre-wrap wrap-break-word",
127
196
  message.mine
128
197
  ? "bg-inverse text-on-inverse"
129
198
  : "bg-fill text-label",
130
199
  endsGroup &&
131
- (message.mine ? "rounded-br-md" : "rounded-bl-md")
200
+ (message.mine
201
+ ? bubbleTails[size]
202
+ : bubbleTailsOther[size])
132
203
  )}
133
204
  >
134
205
  {message.body}
@@ -137,7 +208,7 @@ function Messages({
137
208
  <time
138
209
  suppressHydrationWarning
139
210
  dateTime={message.createdAt}
140
- className="px-3 text-caption2 text-label-secondary tabular-nums"
211
+ className={timeText[size]}
141
212
  >
142
213
  {clock(message.createdAt, locale)}
143
214
  </time>
@@ -165,6 +236,8 @@ type ComposerProps = {
165
236
  * (count) => `${count} left`. Without it, just the number shows.
166
237
  */
167
238
  remaining?: (count: number) => string
239
+ /** Surface, textarea, send button and counter scale. @default "md" */
240
+ size?: ConversationSize
168
241
  }
169
242
 
170
243
  /** A reply field with a round send button; Ctrl/Cmd+Enter sends, and it clears only once the send worked. */
@@ -176,6 +249,7 @@ function Composer({
176
249
  actions,
177
250
  sendLabel,
178
251
  remaining,
252
+ size = "md",
179
253
  }: ComposerProps) {
180
254
  const [body, setBody] = useState("")
181
255
  const ready = body.trim().length > 0 && !pending
@@ -194,7 +268,7 @@ function Composer({
194
268
  void send()
195
269
  }}
196
270
  >
197
- <div className="flex items-end gap-2 rounded-5xl border border-separator bg-elevated py-1.5 pr-1.5 pl-4 transition-colors focus-within:border-accent-text/60">
271
+ <div className={composerSurfaces[size]}>
198
272
  <textarea
199
273
  value={body}
200
274
  onChange={(event) => setBody(event.target.value)}
@@ -207,21 +281,21 @@ function Composer({
207
281
  maxLength={maxLength}
208
282
  placeholder={placeholder}
209
283
  rows={1}
210
- className="field-sizing-content max-h-48 min-h-9 flex-1 resize-none bg-transparent py-1.5 text-subheadline text-label outline-none placeholder:text-label-secondary"
284
+ className={textareaClasses[size]}
211
285
  />
212
286
  <IconButton
213
287
  type="submit"
214
288
  size="icon-xs"
215
289
  disabled={!ready}
216
290
  label={sendLabel}
217
- className="size-8 shrink-0 rounded-full"
291
+ className={sendButtonClasses[size]}
218
292
  >
219
- <SendIcon size={18} strokeWidth={2.5} />
293
+ <SendIcon size={sendIconSizes[size]} strokeWidth={2.5} />
220
294
  </IconButton>
221
295
  </div>
222
296
  <div className="flex min-h-6 items-center gap-2 px-1">
223
297
  {actions}
224
- <span className="ml-auto text-caption2 text-label-secondary tabular-nums">
298
+ <span className={cn("ml-auto", counterText[size])}>
225
299
  {body.length > maxLength * 0.8 &&
226
300
  (remaining?.(maxLength - body.length) ?? maxLength - body.length)}
227
301
  </span>
@@ -3,6 +3,7 @@
3
3
  * a failure. Call `toast` from anywhere; render one `<Toaster />`.
4
4
  */
5
5
  export type ToastTone = "neutral" | "success" | "error" | "loading"
6
+ export type ToastSize = "sm" | "md" | "lg"
6
7
 
7
8
  export interface ToastAction {
8
9
  label: string
@@ -18,12 +19,16 @@ export interface Toast {
18
19
  /** Milliseconds before it leaves on its own; `null` stays until dismissed. */
19
20
  duration: number | null
20
21
  createdAt: number
22
+ /** Pill size, content padding and title/description text scale. @default "md" */
23
+ size: ToastSize
21
24
  }
22
25
 
23
26
  export interface ToastOptions {
24
27
  description?: string
25
28
  action?: ToastAction
26
29
  duration?: number | null
30
+ /** Pill size, content padding and title/description text scale. @default "md" */
31
+ size?: ToastSize
27
32
  }
28
33
 
29
34
  const DEFAULT_DURATION = 4200
@@ -54,6 +59,7 @@ function show(tone: ToastTone, title: string, options: ToastOptions = {}): numbe
54
59
  action: options.action,
55
60
  duration,
56
61
  createdAt: Date.now(),
62
+ size: options.size ?? "md",
57
63
  },
58
64
  ...toasts,
59
65
  ].slice(0, MAX_VISIBLE + 2)
@@ -79,6 +85,7 @@ function update(id: number, tone: ToastTone, title: string, options: ToastOption
79
85
  ? null
80
86
  : DEFAULT_DURATION,
81
87
  createdAt: Date.now(),
88
+ size: options.size ?? t.size,
82
89
  }
83
90
  : t
84
91
  )
@@ -7,12 +7,39 @@ import {
7
7
  getToasts,
8
8
  subscribeToasts,
9
9
  type Toast,
10
+ type ToastSize,
10
11
  type ToastTone,
11
12
  toast as toastApi,
12
13
  toastLimits,
13
14
  } from "./toast-store"
14
15
 
15
- const PILL = 40
16
+ const PILL: Record<ToastSize, number> = { sm: 32, md: 40, lg: 48 }
17
+
18
+ const contentClasses: Record<ToastSize, string> = {
19
+ sm: "absolute top-0 left-0 flex w-max max-w-[min(420px,calc(100vw-24px))] items-start gap-2 py-2 pr-3 pl-2 text-on-inverse",
20
+ md: "absolute top-0 left-0 flex w-max max-w-[min(420px,calc(100vw-24px))] items-start gap-2.5 py-[10px] pr-4 pl-[10px] text-on-inverse",
21
+ lg: "absolute top-0 left-0 flex w-max max-w-[min(420px,calc(100vw-24px))] items-start gap-3 py-3 pr-5 pl-3 text-on-inverse",
22
+ }
23
+ const titleClasses: Record<ToastSize, string> = {
24
+ sm: "text-footnote leading-4 font-semibold",
25
+ md: "text-subheadline leading-5 font-semibold",
26
+ lg: "text-body leading-6 font-semibold",
27
+ }
28
+ const descriptionClasses: Record<ToastSize, string> = {
29
+ sm: "text-caption2 leading-snug text-on-inverse/70",
30
+ md: "text-footnote leading-snug text-on-inverse/70",
31
+ lg: "text-subheadline leading-snug text-on-inverse/70",
32
+ }
33
+ const actionClasses: Record<ToastSize, string> = {
34
+ sm: "ml-1 shrink-0 self-center rounded-full bg-on-inverse/12 px-2.5 py-0.5 text-caption2 font-semibold text-on-inverse transition-colors hover:bg-on-inverse/20",
35
+ md: "ml-1 shrink-0 self-center rounded-full bg-on-inverse/12 px-3 py-1 text-footnote font-semibold text-on-inverse transition-colors hover:bg-on-inverse/20",
36
+ lg: "ml-1 shrink-0 self-center rounded-full bg-on-inverse/12 px-3.5 py-1.5 text-subheadline font-semibold text-on-inverse transition-colors hover:bg-on-inverse/20",
37
+ }
38
+ const toneIconClasses: Record<ToastSize, string> = {
39
+ sm: "flex size-4 shrink-0 items-center justify-center rounded-full",
40
+ md: "flex size-5 shrink-0 items-center justify-center rounded-full",
41
+ lg: "flex size-6 shrink-0 items-center justify-center rounded-full",
42
+ }
16
43
  const EMPTY: Toast[] = []
17
44
  /** A little loose, so the body overshoots and settles like a drop of honey. */
18
45
  const BODY_SPRING = {
@@ -110,10 +137,11 @@ function ToastItem({
110
137
  filterId: string
111
138
  reducedMotion: boolean
112
139
  }) {
140
+ const pill = PILL[toast.size]
113
141
  const content = useRef<HTMLDivElement>(null)
114
142
  const [size, setSize] = useState({
115
- width: PILL,
116
- height: PILL,
143
+ width: pill,
144
+ height: pill,
117
145
  })
118
146
  const [open, setOpen] = useState(false)
119
147
 
@@ -143,8 +171,8 @@ function ToastItem({
143
171
  return () => clearTimeout(timer)
144
172
  }, [toast.id, toast.duration, paused])
145
173
 
146
- const width = open ? size.width : PILL
147
- const height = open ? size.height : PILL
174
+ const width = open ? size.width : pill
175
+ const height = open ? size.height : pill
148
176
  // Older toasts tuck in behind the newest, and fan out on hover.
149
177
  const stacked = !expanded && index > 0
150
178
 
@@ -156,7 +184,7 @@ function ToastItem({
156
184
  initial={reducedMotion ? false : { opacity: 0, y: -24, scale: 0.6 }}
157
185
  animate={{
158
186
  opacity: stacked ? 1 - index * 0.25 : 1,
159
- y: stacked ? index * 9 : index * (PILL + 14) + index * 6,
187
+ y: stacked ? index * 9 : index * (pill + 14) + index * 6,
160
188
  scale: stacked ? 1 - index * 0.05 : 1,
161
189
  }}
162
190
  exit={
@@ -180,7 +208,7 @@ function ToastItem({
180
208
  <motion.div
181
209
  role="status"
182
210
  className="pointer-events-auto relative"
183
- initial={{ width: PILL, height: PILL }}
211
+ initial={{ width: pill, height: pill }}
184
212
  animate={{ width, height }}
185
213
  transition={reducedMotion ? { duration: 0 } : BODY_SPRING}
186
214
  >
@@ -194,21 +222,21 @@ function ToastItem({
194
222
  >
195
223
  <motion.span
196
224
  className="absolute top-3 left-3 rounded-full bg-inverse shadow-xl"
197
- style={{ width: PILL, height: PILL }}
225
+ style={{ width: pill, height: pill }}
198
226
  animate={{ scale: open ? 1 : 1.06 }}
199
227
  transition={reducedMotion ? { duration: 0 } : spring.bouncy}
200
228
  />
201
229
  <motion.span
202
230
  className="absolute top-3 left-3 bg-inverse"
203
231
  initial={{
204
- width: PILL,
205
- height: PILL,
206
- borderRadius: PILL / 2,
232
+ width: pill,
233
+ height: pill,
234
+ borderRadius: pill / 2,
207
235
  }}
208
236
  animate={{
209
237
  width,
210
238
  height,
211
- borderRadius: Math.min(PILL / 2, height / 2, 22),
239
+ borderRadius: Math.min(pill / 2, height / 2, 22),
212
240
  }}
213
241
  transition={reducedMotion ? { duration: 0 } : BODY_SPRING}
214
242
  />
@@ -217,8 +245,8 @@ function ToastItem({
217
245
  {/* Content, crisp above the shapes. */}
218
246
  <motion.div
219
247
  ref={content}
220
- className="absolute top-0 left-0 flex w-max max-w-[min(420px,calc(100vw-24px))] items-start gap-2.5 py-[10px] pr-4 pl-[10px] text-on-inverse"
221
- style={{ minHeight: PILL }}
248
+ className={contentClasses[toast.size]}
249
+ style={{ minHeight: pill }}
222
250
  animate={{ opacity: open ? 1 : 0 }}
223
251
  transition={
224
252
  reducedMotion
@@ -229,12 +257,12 @@ function ToastItem({
229
257
  }
230
258
  }
231
259
  >
232
- <ToneIcon tone={toast.tone} reducedMotion={reducedMotion} />
260
+ <ToneIcon tone={toast.tone} size={toast.size} reducedMotion={reducedMotion} />
233
261
  <div className="flex min-w-0 flex-col py-px">
234
262
  <AnimatePresence mode="popLayout" initial={false}>
235
263
  <motion.span
236
264
  key={toast.title}
237
- className="text-subheadline leading-5 font-semibold"
265
+ className={titleClasses[toast.size]}
238
266
  initial={
239
267
  reducedMotion
240
268
  ? false
@@ -264,7 +292,7 @@ function ToastItem({
264
292
  </motion.span>
265
293
  </AnimatePresence>
266
294
  {toast.description && (
267
- <span className="text-footnote leading-snug text-on-inverse/70">
295
+ <span className={descriptionClasses[toast.size]}>
268
296
  {toast.description}
269
297
  </span>
270
298
  )}
@@ -276,7 +304,7 @@ function ToastItem({
276
304
  toast.action?.onClick()
277
305
  toastApi.dismiss(toast.id)
278
306
  }}
279
- className="ml-1 shrink-0 self-center rounded-full bg-on-inverse/12 px-3 py-1 text-footnote font-semibold text-on-inverse transition-colors hover:bg-on-inverse/20"
307
+ className={actionClasses[toast.size]}
280
308
  >
281
309
  {toast.action.label}
282
310
  </button>
@@ -294,15 +322,20 @@ const toneColors: Record<ToastTone, string> = {
294
322
  loading: "bg-on-inverse/15 text-on-inverse",
295
323
  }
296
324
 
297
- function ToneIcon({ tone, reducedMotion }: { tone: ToastTone; reducedMotion: boolean }) {
325
+ function ToneIcon({
326
+ tone,
327
+ size,
328
+ reducedMotion,
329
+ }: {
330
+ tone: ToastTone
331
+ size: ToastSize
332
+ reducedMotion: boolean
333
+ }) {
298
334
  return (
299
335
  <AnimatePresence mode="popLayout" initial={false}>
300
336
  <motion.span
301
337
  key={tone}
302
- className={cn(
303
- "flex size-5 shrink-0 items-center justify-center rounded-full",
304
- toneColors[tone]
305
- )}
338
+ className={cn(toneIconClasses[size], toneColors[tone])}
306
339
  initial={
307
340
  reducedMotion
308
341
  ? false