@rtzll/reflow 0.5.0 → 0.6.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.
@@ -0,0 +1,116 @@
1
+ /* Avatar: initials on a soft tint of their colour, covered by an image
2
+ once it loads. Sizes follow --rf-avatar-size; the initials scale with it.
3
+ A ring in the surface colour separates overlapping avatars in a group. */
4
+ .rf-avatar {
5
+ --rf-avatar-size: 32px;
6
+ --rf-avatar-ring: var(--rf-bg);
7
+ position: relative;
8
+ display: inline-grid;
9
+ flex: none;
10
+ place-items: center;
11
+ box-sizing: border-box;
12
+ inline-size: var(--rf-avatar-size);
13
+ block-size: var(--rf-avatar-size);
14
+ border-radius: 9999px;
15
+ background: color-mix(in oklab, var(--rf-avatar-color, var(--rf-fg)) 20%, var(--rf-bg));
16
+ color: color-mix(in oklab, var(--rf-avatar-color, var(--rf-fg)) 60%, var(--rf-fg));
17
+ font: 550 calc(var(--rf-avatar-size) * 0.4) / 1 var(--rf-font);
18
+ letter-spacing: 0.01em;
19
+ user-select: none;
20
+ vertical-align: middle;
21
+ }
22
+ .rf-avatar[data-shape='square'] {
23
+ border-radius: max(4px, calc(var(--rf-avatar-size) * 0.25));
24
+ }
25
+ .rf-avatar > * {
26
+ grid-area: 1 / 1;
27
+ }
28
+ .rf-avatar-image {
29
+ inline-size: 100%;
30
+ block-size: 100%;
31
+ border-radius: inherit;
32
+ object-fit: cover;
33
+ }
34
+ .rf-avatar[data-image='error'] .rf-avatar-image {
35
+ display: none;
36
+ }
37
+
38
+ /* The status dot sits on the corner, ringed in the surface colour. */
39
+ .rf-avatar-status {
40
+ place-self: end;
41
+ inline-size: max(6px, calc(var(--rf-avatar-size) * 0.28));
42
+ block-size: max(6px, calc(var(--rf-avatar-size) * 0.28));
43
+ margin: -1px;
44
+ border-radius: 9999px;
45
+ box-shadow: 0 0 0 2px var(--rf-avatar-ring);
46
+ background: var(--rf-muted);
47
+ }
48
+ .rf-avatar-status[data-status='online'] {
49
+ background: light-dark(#22c55e, var(--rf-success));
50
+ }
51
+ .rf-avatar-status[data-status='away'] {
52
+ background: light-dark(#f59e0b, var(--rf-warning));
53
+ }
54
+ .rf-avatar-status[data-status='busy'] {
55
+ background: light-dark(#ef4444, var(--rf-danger));
56
+ }
57
+
58
+ /* AvatarGroup: avatars overlap by a quarter of their size and spread apart
59
+ on the moderate spring while pointed at. */
60
+ .rf-avatar-group {
61
+ --rf-avatar-overlap: calc(var(--rf-avatar-size) * -0.25);
62
+ display: flex;
63
+ align-items: center;
64
+ margin: 0;
65
+ padding: 0;
66
+ list-style: none;
67
+ }
68
+ .rf-avatar-group > li {
69
+ position: relative;
70
+ display: flex;
71
+ transition: margin-inline-start var(--rf-spring-moderate);
72
+ }
73
+ .rf-avatar-group > li + li {
74
+ margin-inline-start: var(--rf-avatar-overlap);
75
+ }
76
+ @media (hover: hover) {
77
+ .rf-avatar-group:hover > li + li {
78
+ margin-inline-start: 2px;
79
+ }
80
+ }
81
+ .rf-avatar-slot {
82
+ display: inline-flex;
83
+ border-radius: 9999px;
84
+ }
85
+ .rf-avatar-group .rf-avatar {
86
+ box-shadow: 0 0 0 2px var(--rf-avatar-ring);
87
+ }
88
+ .rf-avatar-more {
89
+ --rf-avatar-color: var(--rf-muted);
90
+ background: var(--rf-neutral-fill);
91
+ color: var(--rf-fg);
92
+ font-size: calc(var(--rf-avatar-size) * 0.34);
93
+ font-variant-numeric: tabular-nums;
94
+ }
95
+
96
+ @media (prefers-reduced-motion: reduce) {
97
+ .rf-avatar-group > li {
98
+ transition: none;
99
+ }
100
+ }
101
+ @media (forced-colors: active) {
102
+ .rf-avatar {
103
+ border: 1px solid CanvasText;
104
+ }
105
+ }
106
+
107
+ /* The ring takes the colour of the surface the avatars sit on. */
108
+ .rf-popover-surface .rf-avatar {
109
+ --rf-avatar-ring: var(--rf-surface-3, white);
110
+ }
111
+ .rf-dialog .rf-avatar {
112
+ --rf-avatar-ring: var(--rf-surface-5);
113
+ }
114
+ .rf-drawer .rf-avatar {
115
+ --rf-avatar-ring: var(--rf-drawer-bg);
116
+ }
@@ -0,0 +1,42 @@
1
+ import { type Handle } from 'remix/component';
2
+ import type { BadgeColor } from './basic.tsx';
3
+ export type AvatarStatus = 'online' | 'away' | 'busy' | 'offline';
4
+ export interface AvatarProps {
5
+ /** Who or what it shows: the accessible name, and the source of the initials. */
6
+ name: string;
7
+ /** An image. Until it loads, or if it fails, the initials show. */
8
+ src?: string;
9
+ /** Width and height in pixels. Default 32. */
10
+ size?: number;
11
+ shape?: 'circle' | 'square';
12
+ /** The initials' colour. Defaults to one picked from the name, the same every time. */
13
+ color?: BadgeColor;
14
+ /** A dot in the corner, named with the avatar. */
15
+ status?: AvatarStatus;
16
+ /** Hides the avatar from assistive technology, for when the name is shown beside it. */
17
+ decorative?: boolean;
18
+ class?: string;
19
+ }
20
+ /**
21
+ * A picture or the initials of a person, team or agent. The initials sit
22
+ * on a colour picked from the name, so the same name always looks the same;
23
+ * an image covers them once it loads, and they show again if it fails. A
24
+ * status dot is named with the avatar. Mark it decorative when the name is
25
+ * already written beside it.
26
+ */
27
+ export declare function Avatar(handle: Handle<AvatarProps>): () => import("remix/component").RemixElement;
28
+ export interface AvatarGroupProps {
29
+ avatars: readonly Omit<AvatarProps, 'size' | 'shape' | 'decorative' | 'class'>[];
30
+ /** How many avatars show, the count of the rest included. Default 4. */
31
+ max?: number;
32
+ size?: number;
33
+ shape?: 'circle' | 'square';
34
+ /** Names the group. Defaults to the number of people. */
35
+ label?: string;
36
+ }
37
+ /**
38
+ * A row of overlapping avatars, such as the people on a document. Each
39
+ * names itself in a tooltip; past `max`, a count stands in for the rest and
40
+ * names them. Pointing at the row spreads it apart.
41
+ */
42
+ export declare function AvatarGroup(handle: Handle<AvatarGroupProps>): () => import("remix/component").RemixElement;
package/dist/avatar.js ADDED
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
2
+ import { ref } from 'remix/component';
3
+ import { badgeColors } from './colors.js';
4
+ import { Tooltip } from './overlays.js';
5
+ // Hues far enough apart to tell neighbours in a group apart, legible on
6
+ // both themes.
7
+ const autoColors = [
8
+ 'red',
9
+ 'orange',
10
+ 'amber',
11
+ 'green',
12
+ 'teal',
13
+ 'blue',
14
+ 'violet',
15
+ 'pink',
16
+ ];
17
+ // FNV-1a, which spreads similar names evenly.
18
+ function pickColor(name) {
19
+ let hash = 0x811c9dc5;
20
+ for (const char of name.trim().toLowerCase()) {
21
+ hash ^= char.codePointAt(0);
22
+ hash = Math.imul(hash, 0x01000193) >>> 0;
23
+ }
24
+ return autoColors[hash % autoColors.length];
25
+ }
26
+ function initials(name) {
27
+ const words = name.trim().split(/\s+/);
28
+ const first = (word) => Array.from(word ?? '').find((char) => /[\p{L}\p{N}]/u.test(char)) ?? '';
29
+ const letters = first(words[0]) + (words.length > 1 ? first(words[words.length - 1]) : '');
30
+ return letters.toLocaleUpperCase();
31
+ }
32
+ /**
33
+ * A picture or the initials of a person, team or agent. The initials sit
34
+ * on a colour picked from the name, so the same name always looks the same;
35
+ * an image covers them once it loads, and they show again if it fails. A
36
+ * status dot is named with the avatar. Mark it decorative when the name is
37
+ * already written beside it.
38
+ */
39
+ export function Avatar(handle) {
40
+ // A failed image hands back to the initials, even if it failed before hydration.
41
+ const image = ref((node, signal) => {
42
+ if (!(node instanceof HTMLImageElement))
43
+ return;
44
+ const root = node.closest('.rf-avatar');
45
+ const fail = () => root?.setAttribute('data-image', 'error');
46
+ node.addEventListener('error', fail, { signal });
47
+ node.addEventListener('load', () => root?.removeAttribute('data-image'), { signal });
48
+ if (node.complete && node.naturalWidth === 0 && node.getAttribute('src'))
49
+ fail();
50
+ });
51
+ return () => {
52
+ const { name, src, size = 32, shape = 'circle', status, decorative } = handle.props;
53
+ const color = handle.props.color ?? pickColor(name);
54
+ const label = status ? `${name} (${status})` : name;
55
+ return (_jsxs("span", { class: handle.props.class ? `rf-avatar ${handle.props.class}` : 'rf-avatar', role: decorative ? undefined : 'img', "aria-label": decorative ? undefined : label, "aria-hidden": decorative || undefined, "data-shape": shape, "data-color": color, style: { '--rf-avatar-size': `${size}px`, '--rf-avatar-color': badgeColors[color] }, children: [_jsx("span", { class: "rf-avatar-initials", "aria-hidden": "true", children: initials(name) }), src && _jsx("img", { class: "rf-avatar-image", src: src, alt: "", mix: image }), status && _jsx("span", { class: "rf-avatar-status", "data-status": status, "aria-hidden": "true" })] }));
56
+ };
57
+ }
58
+ /**
59
+ * A row of overlapping avatars, such as the people on a document. Each
60
+ * names itself in a tooltip; past `max`, a count stands in for the rest and
61
+ * names them. Pointing at the row spreads it apart.
62
+ */
63
+ export function AvatarGroup(handle) {
64
+ return () => {
65
+ const { avatars, size = 32, shape } = handle.props;
66
+ const max = Math.max(1, handle.props.max ?? 4);
67
+ const overflow = avatars.length > max;
68
+ const shown = overflow ? avatars.slice(0, max - 1) : avatars;
69
+ const rest = overflow ? avatars.slice(max - 1) : [];
70
+ const label = handle.props.label ?? `${avatars.length} ${avatars.length === 1 ? 'person' : 'people'}`;
71
+ const names = rest.map((avatar) => avatar.name).join(', ');
72
+ return (_jsxs("ul", { class: "rf-avatar-group", "aria-label": label, style: { '--rf-avatar-size': `${size}px` }, children: [shown.map((avatar, index) => (_jsx("li", { style: { zIndex: shown.length - index + 1 }, children: _jsx(Tooltip, { content: avatar.name, renderTrigger: (trigger) => (_jsx("span", { class: "rf-avatar-slot", ...trigger, children: _jsx(Avatar, { ...avatar, size: size, shape: shape }) })) }) }, `${avatar.name}-${index}`))), overflow && (_jsx("li", { children: _jsx(Tooltip, { content: names, renderTrigger: (trigger) => (_jsx("span", { class: "rf-avatar-slot", ...trigger, children: _jsxs("span", { class: "rf-avatar rf-avatar-more", role: "img", "aria-label": `${rest.length} more: ${names}`, "data-shape": shape ?? 'circle', style: { '--rf-avatar-size': `${size}px` }, children: ["+", rest.length] }) })) }) }))] }));
73
+ };
74
+ }
package/dist/basic.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type IconName } from './icon.tsx';
2
2
  import type { Handle, Props, RemixNode } from 'remix/component';
3
+ import { badgeColors } from './colors.ts';
3
4
  export type ControlSize = 'default' | 'compact';
4
5
  export type ButtonSize = ControlSize | 'icon' | 'icon-compact';
5
6
  export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'destructive';
@@ -24,26 +25,6 @@ type StyledProps<Tag extends keyof JSX.IntrinsicElements> = Omit<Props<Tag>, 'cl
24
25
  class?: string;
25
26
  className?: string;
26
27
  };
27
- declare const badgeColors: {
28
- readonly accent: 'var(--rf-accent)';
29
- readonly gray: '#a3a3a3';
30
- readonly red: '#ef4444';
31
- readonly orange: '#f97316';
32
- readonly amber: '#f59e0b';
33
- readonly yellow: '#eab308';
34
- readonly lime: '#84cc16';
35
- readonly green: '#22c55e';
36
- readonly emerald: '#10b981';
37
- readonly teal: '#14b8a6';
38
- readonly cyan: '#06b6d4';
39
- readonly blue: '#3b82f6';
40
- readonly indigo: '#6366f1';
41
- readonly violet: '#8b5cf6';
42
- readonly purple: '#a855f7';
43
- readonly fuchsia: '#d946ef';
44
- readonly pink: '#ec4899';
45
- readonly rose: '#f43f5e';
46
- };
47
28
  export type BadgeColor = keyof typeof badgeColors;
48
29
  export type BadgeProps = Omit<StyledProps<'span'>, 'color' | 'style'> & {
49
30
  color?: BadgeColor;
package/dist/basic.js CHANGED
@@ -5,6 +5,7 @@ import { jsx } from 'remix/component/jsx-runtime';
5
5
  import { fluidHover } from './interactive.js';
6
6
  import { Tooltip } from './overlays.js';
7
7
  import { statusIcon } from './status-icon.js';
8
+ import { badgeColors } from './colors.js';
8
9
  export function Button(handle) {
9
10
  return () => {
10
11
  const props = handle.props;
@@ -36,26 +37,6 @@ export function Button(handle) {
36
37
  function classList(...classes) {
37
38
  return classes.filter(Boolean).join(' ');
38
39
  }
39
- const badgeColors = {
40
- accent: 'var(--rf-accent)',
41
- gray: '#a3a3a3',
42
- red: '#ef4444',
43
- orange: '#f97316',
44
- amber: '#f59e0b',
45
- yellow: '#eab308',
46
- lime: '#84cc16',
47
- green: '#22c55e',
48
- emerald: '#10b981',
49
- teal: '#14b8a6',
50
- cyan: '#06b6d4',
51
- blue: '#3b82f6',
52
- indigo: '#6366f1',
53
- violet: '#8b5cf6',
54
- purple: '#a855f7',
55
- fuchsia: '#d946ef',
56
- pink: '#ec4899',
57
- rose: '#f43f5e',
58
- };
59
40
  export function Badge(handle) {
60
41
  return () => {
61
42
  const { class: classProp, className, color = 'gray', size, variant = 'solid', style, children, ...props } = handle.props;
@@ -0,0 +1,121 @@
1
+ /* ChatLayout: header, transcript and composer stacked in a column of
2
+ readable width. The transcript takes the remaining height and scrolls;
3
+ its messages rest at the bottom while they are few, as in a chat. */
4
+ .rf-chat-layout {
5
+ --rf-chat-width: 720px;
6
+ --rf-chat-gutter: 16px;
7
+ display: grid;
8
+ grid-template-rows: auto minmax(0, 1fr) auto;
9
+ box-sizing: border-box;
10
+ inline-size: 100%;
11
+ block-size: 100%;
12
+ min-block-size: 0;
13
+ }
14
+ .rf-chat-layout-header,
15
+ .rf-chat-layout-composer {
16
+ box-sizing: border-box;
17
+ inline-size: min(100%, var(--rf-chat-width));
18
+ margin-inline: auto;
19
+ padding-inline: var(--rf-chat-gutter);
20
+ }
21
+ .rf-chat-layout-header {
22
+ grid-row: 1;
23
+ padding-block: 12px;
24
+ }
25
+ .rf-chat-layout-body {
26
+ position: relative;
27
+ grid-row: 2;
28
+ min-block-size: 0;
29
+ }
30
+ .rf-chat-layout-scroll {
31
+ block-size: 100%;
32
+ }
33
+ .rf-chat-layout-viewport > .rf-scroll-content {
34
+ display: flex;
35
+ flex-direction: column;
36
+ min-block-size: 100%;
37
+ }
38
+ .rf-chat-transcript {
39
+ display: grid;
40
+ align-content: end;
41
+ gap: 8px;
42
+ box-sizing: border-box;
43
+ flex: 1;
44
+ inline-size: min(100%, var(--rf-chat-width));
45
+ margin-inline: auto;
46
+ padding: 16px var(--rf-chat-gutter);
47
+ }
48
+ .rf-chat-layout[data-empty] .rf-chat-transcript {
49
+ flex: 0;
50
+ padding-block: 0;
51
+ }
52
+ .rf-chat-layout-empty {
53
+ display: grid;
54
+ flex: 1;
55
+ place-items: center;
56
+ padding: 24px var(--rf-chat-gutter);
57
+ color: var(--rf-muted);
58
+ text-align: center;
59
+ }
60
+ .rf-chat-layout-composer {
61
+ grid-row: 3;
62
+ padding-block: 8px 16px;
63
+ }
64
+
65
+ /* Back to the newest message: a quiet pill resting on the transcript's edge. */
66
+ .rf-chat-jump {
67
+ position: absolute;
68
+ inset-block-end: 12px;
69
+ inset-inline-start: 50%;
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: 6px;
73
+ block-size: 28px;
74
+ padding-inline: 10px 12px;
75
+ border: 0;
76
+ border-radius: 9999px;
77
+ outline: none;
78
+ background: var(--rf-surface-3, white);
79
+ box-shadow:
80
+ 0 0 0 1px light-dark(rgb(0 0 0 / 6%), rgb(255 255 255 / 8%)),
81
+ 0 4px 12px light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 30%));
82
+ color: var(--rf-fg);
83
+ font: var(--rf-text-caption) / 1 var(--rf-font);
84
+ cursor: pointer;
85
+ translate: -50% 0;
86
+ transition:
87
+ opacity var(--rf-spring-moderate),
88
+ translate var(--rf-spring-moderate),
89
+ display var(--rf-spring-moderate) allow-discrete;
90
+ }
91
+ .rf-chat-jump[hidden] {
92
+ display: none;
93
+ opacity: 0;
94
+ translate: -50% 6px;
95
+ }
96
+ @starting-style {
97
+ .rf-chat-jump:not([hidden]) {
98
+ opacity: 0;
99
+ translate: -50% 6px;
100
+ }
101
+ }
102
+ .rf-chat-jump:focus-visible {
103
+ box-shadow:
104
+ 0 0 0 2px var(--rf-accent),
105
+ 0 4px 12px light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 30%));
106
+ }
107
+ @media (hover: hover) {
108
+ .rf-chat-jump:hover {
109
+ background: color-mix(in oklab, var(--rf-surface-3, white), var(--rf-fg) 5%);
110
+ }
111
+ }
112
+ .rf-chat-jump > svg {
113
+ flex: none;
114
+ }
115
+
116
+ @media (prefers-reduced-motion: reduce) {
117
+ .rf-chat-jump {
118
+ transition: opacity var(--rf-spring-moderate);
119
+ translate: -50% 0;
120
+ }
121
+ }
@@ -0,0 +1,24 @@
1
+ import { type Handle, type RemixNode } from 'remix/component';
2
+ export interface ChatLayoutProps {
3
+ /** The messages, usually ChatMessage elements, oldest first. */
4
+ children?: RemixNode;
5
+ /** Docked below the transcript, usually an InputMessage. */
6
+ composer?: RemixNode;
7
+ /** Above the transcript, such as a title or the people in the chat. */
8
+ header?: RemixNode;
9
+ /** Shown in place of the transcript until the first message. */
10
+ empty?: RemixNode;
11
+ /** Names the transcript. Default "Conversation". */
12
+ label?: string;
13
+ class?: string;
14
+ }
15
+ /**
16
+ * The conversation screen: a transcript that fills the space, with the
17
+ * composer docked below it. The transcript opens at the newest message and
18
+ * stays there as messages arrive or a reply streams in, as long as the
19
+ * reader is there too; once they scroll back, it leaves them where they
20
+ * are and offers a button back to the newest message. Sending a message
21
+ * of their own, a ChatMessage with the user role, brings them back. The transcript is a
22
+ * labelled log in a ScrollArea, and the column keeps a readable width.
23
+ */
24
+ export declare function ChatLayout(handle: Handle<ChatLayoutProps>): () => import("remix/component").RemixElement;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
2
+ import { on, ref } from 'remix/component';
3
+ import { prefersReducedMotion } from '@remix-run/ui/animation';
4
+ import { Icon } from './icon.js';
5
+ import { ScrollArea } from './scroll-area.js';
6
+ // Within this many pixels of the end, the transcript counts as at the newest message.
7
+ const pinSlack = 24;
8
+ function isEmpty(node) {
9
+ if (node == null || node === false || node === '')
10
+ return true;
11
+ return Array.isArray(node) && node.every(isEmpty);
12
+ }
13
+ /**
14
+ * The conversation screen: a transcript that fills the space, with the
15
+ * composer docked below it. The transcript opens at the newest message and
16
+ * stays there as messages arrive or a reply streams in, as long as the
17
+ * reader is there too; once they scroll back, it leaves them where they
18
+ * are and offers a button back to the newest message. Sending a message
19
+ * of their own, a ChatMessage with the user role, brings them back. The transcript is a
20
+ * labelled log in a ScrollArea, and the column keeps a readable width.
21
+ */
22
+ export function ChatLayout(handle) {
23
+ let viewport = null;
24
+ let pinned = true;
25
+ let unseen = false;
26
+ // While a jump scrolls smoothly, its own scroll events must not unpin it.
27
+ let jumping = false;
28
+ function setState(nextPinned, nextUnseen) {
29
+ if (nextPinned === pinned && nextUnseen === unseen)
30
+ return;
31
+ pinned = nextPinned;
32
+ unseen = nextUnseen;
33
+ void handle.update();
34
+ }
35
+ // The end, from layout alone. Messages gliding to new places are moved by
36
+ // transforms, which grow scrollHeight while they play; measured that way,
37
+ // the transcript would scroll past the end and snap back as glides finish.
38
+ function endTop() {
39
+ if (!viewport)
40
+ return 0;
41
+ const content = viewport.firstElementChild;
42
+ return Math.max(0, (content?.offsetHeight ?? viewport.scrollHeight) - viewport.clientHeight);
43
+ }
44
+ function atEnd() {
45
+ if (!viewport)
46
+ return true;
47
+ return endTop() - viewport.scrollTop <= pinSlack;
48
+ }
49
+ function scrollToEnd(smooth = false) {
50
+ viewport?.scrollTo({
51
+ top: endTop(),
52
+ behavior: smooth && !prefersReducedMotion() ? 'smooth' : 'instant',
53
+ });
54
+ }
55
+ const logMix = ref((node, signal) => {
56
+ if (!(node instanceof HTMLElement))
57
+ return;
58
+ viewport = node.closest('.rf-scroll-viewport');
59
+ if (!viewport)
60
+ return;
61
+ const scroller = viewport;
62
+ scrollToEnd();
63
+ let lastHeight = node.offsetHeight;
64
+ const observer = new ResizeObserver(() => {
65
+ const grew = node.offsetHeight > lastHeight;
66
+ lastHeight = node.offsetHeight;
67
+ if (pinned)
68
+ scrollToEnd();
69
+ else if (grew)
70
+ setState(false, true);
71
+ });
72
+ observer.observe(node);
73
+ observer.observe(scroller);
74
+ // The reader's own message always brings them to the newest one.
75
+ const added = new MutationObserver((records) => {
76
+ const sent = records.some((record) => Array.from(record.addedNodes).some((child) => child instanceof Element && child.matches('[data-role="user"]')));
77
+ if (!sent)
78
+ return;
79
+ setState(true, false);
80
+ scrollToEnd();
81
+ });
82
+ added.observe(node, { childList: true });
83
+ scroller.addEventListener('scroll', () => {
84
+ if (jumping) {
85
+ if (atEnd())
86
+ jumping = false;
87
+ return;
88
+ }
89
+ const end = atEnd();
90
+ setState(end, end ? false : unseen);
91
+ }, { signal, passive: true });
92
+ signal.addEventListener('abort', () => {
93
+ observer.disconnect();
94
+ added.disconnect();
95
+ if (viewport === scroller)
96
+ viewport = null;
97
+ }, { once: true });
98
+ });
99
+ return () => {
100
+ const { children, composer, header, empty, label = 'Conversation' } = handle.props;
101
+ const blank = isEmpty(children);
102
+ return (_jsxs("div", { class: handle.props.class ? `rf-chat-layout ${handle.props.class}` : 'rf-chat-layout', "data-empty": blank || undefined, children: [header != null && _jsx("div", { class: "rf-chat-layout-header", children: header }), _jsxs("div", { class: "rf-chat-layout-body", children: [_jsxs(ScrollArea, { label: label, className: "rf-chat-layout-scroll", viewportClassName: "rf-chat-layout-viewport", children: [_jsx("div", { class: "rf-chat-transcript", role: "log", "aria-label": label, mix: logMix, children: children }), blank && empty != null && _jsx("div", { class: "rf-chat-layout-empty", children: empty })] }), _jsxs("button", { type: "button", class: "rf-chat-jump", hidden: pinned, mix: on('click', () => {
103
+ jumping = true;
104
+ setState(true, false);
105
+ scrollToEnd(true);
106
+ }), children: [_jsx(Icon, { name: "chevron-down", size: 14 }), unseen ? 'New messages' : 'Jump to latest'] })] }), composer != null && _jsx("div", { class: "rf-chat-layout-composer", children: composer })] }));
107
+ };
108
+ }
@@ -0,0 +1,119 @@
1
+ /* Citation: a small chip inline with the text it supports, on the gray
2
+ Badge's fill, sized to sit within the line without moving it. Its preview
3
+ is a HoverCard listing each source. */
4
+ .rf-citation {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ box-sizing: border-box;
9
+ min-inline-size: 18px;
10
+ block-size: 18px;
11
+ margin-inline: 2px;
12
+ padding-inline: 5px;
13
+ border-radius: 6px;
14
+ outline: none;
15
+ background: var(--rf-neutral-fill);
16
+ color: var(--rf-muted);
17
+ font: 500 11px / 1 var(--rf-font);
18
+ font-variant-numeric: tabular-nums;
19
+ text-decoration: none;
20
+ vertical-align: 0.1em;
21
+ white-space: nowrap;
22
+ transition:
23
+ color var(--rf-fast),
24
+ background-color var(--rf-fast);
25
+ }
26
+ .rf-citation[data-variant='site'] {
27
+ padding-inline: 7px;
28
+ }
29
+ .rf-citation[data-size='compact'] {
30
+ min-inline-size: 16px;
31
+ block-size: 16px;
32
+ padding-inline: 4px;
33
+ font-size: 10px;
34
+ }
35
+ @media (hover: hover) {
36
+ .rf-citation:hover {
37
+ background: color-mix(in oklab, var(--rf-neutral-fill), var(--rf-fg) 10%);
38
+ color: var(--rf-fg);
39
+ }
40
+ }
41
+ .rf-citation:focus-visible {
42
+ box-shadow: 0 0 0 2px var(--rf-accent);
43
+ color: var(--rf-fg);
44
+ }
45
+
46
+ .rf-citation-sources {
47
+ display: grid;
48
+ gap: 12px;
49
+ inline-size: min(300px, calc(100vw - 32px));
50
+ margin: 0;
51
+ padding: 0;
52
+ list-style: none;
53
+ }
54
+ .rf-citation-sources[data-density='compact'] {
55
+ gap: 10px;
56
+ }
57
+ .rf-citation-source {
58
+ display: grid;
59
+ gap: 2px;
60
+ min-inline-size: 0;
61
+ }
62
+ .rf-citation-source + .rf-citation-source {
63
+ padding-block-start: 12px;
64
+ border-block-start: 1px solid var(--rf-border);
65
+ }
66
+ .rf-citation-meta {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 6px;
70
+ color: var(--rf-muted);
71
+ font: var(--rf-text-caption) / var(--rf-leading-caption) var(--rf-font);
72
+ }
73
+ .rf-citation-icon {
74
+ display: inline-grid;
75
+ flex: none;
76
+ place-items: center;
77
+ inline-size: 14px;
78
+ block-size: 14px;
79
+ overflow: hidden;
80
+ border-radius: 3px;
81
+ }
82
+ .rf-citation-icon > :is(img, svg) {
83
+ inline-size: 100%;
84
+ block-size: 100%;
85
+ }
86
+ .rf-citation-site {
87
+ min-inline-size: 0;
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ white-space: nowrap;
91
+ }
92
+ .rf-citation-number {
93
+ margin-inline-start: auto;
94
+ font-variant-numeric: tabular-nums;
95
+ }
96
+ .rf-citation-title {
97
+ color: var(--rf-fg);
98
+ font-variation-settings: var(--rf-weight-strong);
99
+ text-decoration: none;
100
+ }
101
+ .rf-citation-title:hover,
102
+ .rf-citation-title:focus-visible {
103
+ text-decoration: underline;
104
+ text-underline-offset: 2px;
105
+ }
106
+ .rf-citation-description {
107
+ display: -webkit-box;
108
+ margin: 0;
109
+ overflow: hidden;
110
+ color: var(--rf-muted);
111
+ -webkit-box-orient: vertical;
112
+ -webkit-line-clamp: 3;
113
+ }
114
+
115
+ @media (forced-colors: active) {
116
+ .rf-citation {
117
+ border: 1px solid LinkText;
118
+ }
119
+ }