@linhey/rhine-design 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CONTRIBUTING.md +2 -0
  3. package/README.md +9 -4
  4. package/components/Actions.tsx +1 -0
  5. package/components/AdvancedForms.tsx +13 -5
  6. package/components/AppShell.tsx +3 -2
  7. package/components/COMMON_COMPONENTS.md +5 -1
  8. package/components/Chat.tsx +160 -0
  9. package/components/Forms.tsx +66 -6
  10. package/components/Navigation.tsx +15 -4
  11. package/components/Overlays.tsx +37 -7
  12. package/components/README.md +11 -1
  13. package/components/chat.css +32 -0
  14. package/components/common.css +2 -0
  15. package/components/components.tsx +19 -9
  16. package/components/dialog-settings.css +54 -1
  17. package/components/extensions.css +4 -0
  18. package/components/index.ts +8 -3
  19. package/components/patterns.css +4 -0
  20. package/components/styles.css +11 -2
  21. package/components/theme-arknights.css +4 -2
  22. package/components/useFloatingScale.ts +7 -3
  23. package/docs/AGENT_CHAT.md +76 -0
  24. package/docs/AGENT_CHAT_VERIFICATION.md +42 -0
  25. package/docs/APPLICATION_COMPONENTS.md +4 -2
  26. package/docs/BUTTON_ACTIONS.md +32 -0
  27. package/docs/BUTTON_ACTIONS_VERIFICATION.md +14 -0
  28. package/docs/COMPACT_ACCOUNT_PATTERNS.md +16 -0
  29. package/docs/COMPACT_SELECTION_VERIFICATION.md +15 -0
  30. package/docs/FLOATING_OVERLAYS.md +20 -0
  31. package/docs/INTERACTION_PATTERNS.md +4 -0
  32. package/docs/SETTINGS_COMPOSITION_VERIFICATION.md +12 -0
  33. package/docs/SETTINGS_DIALOG.md +42 -4
  34. package/docs/SIDE_NAV_ACTIONS.md +9 -0
  35. package/docs/SINGLE_SEARCH.md +19 -0
  36. package/docs/SINGLE_SEARCH_VERIFICATION.md +15 -0
  37. package/docs/SUMMARY_TABS.md +7 -1
  38. package/docs/TRAY_USAGE.md +34 -0
  39. package/docs/TRAY_USAGE_VERIFICATION.md +13 -0
  40. package/docs/assets/chat/after.png +0 -0
  41. package/docs/assets/chat/arknights.png +0 -0
  42. package/docs/assets/chat/before.png +0 -0
  43. package/docs/assets/chat/neo-brutalism.png +0 -0
  44. package/package.json +1 -1
  45. package/preview/ButtonActionsGallery.tsx +25 -0
  46. package/preview/ChatGallery.tsx +98 -0
  47. package/preview/CompactSelectionGallery.tsx +76 -0
  48. package/preview/DenseFilterControlsGallery.tsx +5 -2
  49. package/preview/GalleryShell.tsx +2 -2
  50. package/preview/NavActionsGallery.tsx +24 -0
  51. package/preview/SettingsCompositionGallery.tsx +38 -0
  52. package/preview/SettingsDialogGallery.tsx +54 -14
  53. package/preview/SingleSearchGallery.tsx +50 -0
  54. package/preview/SummaryTabsGallery.tsx +7 -3
  55. package/preview/TrayUsageGallery.tsx +82 -0
  56. package/preview/chat-gallery.css +1 -0
  57. package/preview/compact-selection.css +9 -0
  58. package/preview/main.tsx +15 -1
  59. package/preview/packageInfo.ts +1 -1
  60. package/preview/settings-dialog-gallery.css +5 -0
  61. package/preview/tray-usage.css +4 -0
  62. package/scripts/check-button-actions.js +34 -0
  63. package/scripts/check-combobox-modal-focus.js +22 -0
  64. package/scripts/check-combobox-zoom.js +109 -0
  65. package/scripts/check-compact-selection.js +40 -0
  66. package/scripts/check-dialog-scroll-boundaries.js +41 -0
  67. package/scripts/check-multiselect-completion.js +46 -0
  68. package/scripts/check-nav-actions.js +41 -0
  69. package/scripts/check-settings-bounded.js +49 -0
  70. package/scripts/check-settings-composition.js +50 -0
  71. package/scripts/check-settings-interactions.js +93 -0
  72. package/scripts/check-settings-theme.js +1 -5
  73. package/scripts/check-single-search.js +43 -0
  74. package/scripts/check-tray-usage.js +68 -0
  75. package/scripts/prepare-settings-consumer.mjs +21 -0
  76. package/skills/rhine-design/SKILL.md +2 -0
  77. package/skills/rhine-design/references/API_MAP.md +11 -0
  78. package/skills/rhine-design/references/TASK_MAP.md +1 -0
  79. package/skills/rhine-design/references/integration.md +1 -1
  80. package/tests/Chat.test.tsx +84 -0
  81. package/tests/CompactSelection.test.tsx +47 -0
  82. package/tests/FormParity.test.tsx +104 -2
  83. package/tests/NavActions.test.tsx +29 -0
  84. package/tests/SettingsDialog.test.tsx +102 -9
  85. package/tests/SingleSearch.test.tsx +42 -0
  86. package/tests/SkillInstallation.test.tsx +77 -0
  87. package/tests/TrayUsage.test.tsx +70 -0
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react';
1
+ import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactElement, type ReactNode } from 'react';
2
2
  import * as DialogBase from '@radix-ui/react-dialog';
3
3
  import * as AlertBase from '@radix-ui/react-alert-dialog';
4
4
  import * as PopoverBase from '@radix-ui/react-popover';
@@ -19,12 +19,12 @@ function useScope() {
19
19
  useLayoutEffect(() => { setContainer((ref.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
20
20
  return { ref, container };
21
21
  }
22
- export interface DialogProps { trigger: ReactElement; title: string; description: ReactNode; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; navigation?: ReactNode; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode); onCloseAutoFocus?: (event: Event) => void }
22
+ export interface DialogProps { height?: CSSProperties['height']; footer?: ReactNode; trigger: ReactElement; title: string; description?: ReactNode; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; navigation?: ReactNode; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode); onCloseAutoFocus?: (event: Event) => void }
23
23
  export function Dialog(props: DialogProps) {
24
24
  return <DialogFrame {...props}/>;
25
25
  }
26
- export function Drawer(props: Omit<DialogProps, 'sidePanel' | 'navigation'> & { side?: 'left' | 'right' }) { return <DialogFrame {...props} drawer />; }
27
- function DialogFrame({ trigger, title, description, children, open, onOpenChange, sidePanel, navigation, onCloseAutoFocus, drawer = false, side = 'right' }: DialogProps & { drawer?: boolean; side?: 'left' | 'right' }) {
26
+ export function Drawer(props: Omit<DialogProps, 'sidePanel' | 'navigation' | 'height' | 'footer'> & { side?: 'left' | 'right' }) { return <DialogFrame {...props} drawer />; }
27
+ function DialogFrame({ trigger, title, description, children, open, onOpenChange, sidePanel, navigation, onCloseAutoFocus, height, footer, drawer = false, side = 'right' }: DialogProps & { drawer?: boolean; side?: 'left' | 'right' }) {
28
28
  const { ref, container } = useScope();
29
29
  const [localOpen, setLocalOpen] = useState(false);
30
30
  const [content, setContent] = useState<HTMLDivElement | null>(null);
@@ -48,8 +48,37 @@ function DialogFrame({ trigger, title, description, children, open, onOpenChange
48
48
  const settings = navigation != null && navigation !== false;
49
49
  if (settings && sidePanel) throw new Error('Dialog navigation and sidePanel are mutually exclusive.');
50
50
  const closeButton = <DialogBase.Close asChild><IconButton label={drawer ? '关闭抽屉' : '关闭对话框'} icon="close" /></DialogBase.Close>;
51
+ const hasFooter = footer != null && footer !== false && footer !== '';
52
+ const bounded = height != null || hasFooter;
53
+ // Percent max-height cannot constrain a footer in an auto-height parent.
54
+ // Resolve the viewport limit in local CSS pixels, including ancestor CSS zoom.
55
+ useLayoutEffect(() => {
56
+ if (!content || !hasFooter || (height != null && height !== 'auto')) return;
57
+ const ancestors: Element[] = [];
58
+ for (let element = content.parentElement; element; element = element.parentElement) ancestors.push(element);
59
+ const measure = () => {
60
+ const scale = ancestors.reduce((value, element) => {
61
+ const raw = getComputedStyle(element).zoom || '1';
62
+ const zoom = raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw);
63
+ return value * (Number.isFinite(zoom) && zoom > 0 ? zoom : 1);
64
+ }, 1);
65
+ content.style.setProperty('--rhine-dialog-footer-limit', `${Math.max(0, (window.innerHeight / scale - 32) * .4)}px`);
66
+ };
67
+ measure();
68
+ const observer = new MutationObserver(measure);
69
+ ancestors.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['style','class','data-rhine-theme']}));
70
+ window.addEventListener('resize', measure);
71
+ return () => { observer.disconnect(); window.removeEventListener('resize', measure); content.style.removeProperty('--rhine-dialog-footer-limit'); };
72
+ }, [content, hasFooter, height]);
73
+ const bodyScrollRegion = bounded && !settings && !sidePanel;
74
+ const panes = <><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div>{hasDescription && <DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description>}<div className="rhine-dialog__body" tabIndex={bodyScrollRegion ? 0 : undefined} role={bodyScrollRegion ? 'region' : undefined} aria-label={bodyScrollRegion ? `${title}内容` : undefined}>{settings ? <div className="rhine-dialog__settings-grid"><div className="rhine-dialog__navigation">{navigation}</div><div className="rhine-dialog__settings-content" tabIndex={bounded ? 0 : undefined} role={bounded ? 'region' : undefined} aria-label={bounded ? `${title}内容` : undefined}>{children}</div></div> : children}</div></div>{sidePanel && <div className="rhine-dialog__side">{typeof sidePanel === 'function' ? sidePanel(closeButton) : sidePanel}</div>}</>;
51
75
  return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open ?? localOpen} onOpenChange={changeOpen}><DialogBase.Trigger asChild>{trigger}</DialogBase.Trigger><DialogBase.Portal container={container}>
52
- <DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content ref={setContent} onEscapeKeyDown={escape} onCloseAutoFocus={onCloseAutoFocus} {...(!hasDescription ? { 'aria-describedby': undefined } : {})} data-side={drawer ? side : undefined} className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${settings ? ' rhine-dialog--settings' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}`}><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div>{hasDescription && <DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description>}<div className="rhine-dialog__body">{settings ? <div className="rhine-dialog__settings-grid"><div className="rhine-dialog__navigation">{navigation}</div><div className="rhine-dialog__settings-content">{children}</div></div> : children}</div></div>{sidePanel && <div className="rhine-dialog__side">{typeof sidePanel === 'function' ? sidePanel(closeButton) : sidePanel}</div>}</DialogBase.Content>
76
+ <DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content ref={setContent} onEscapeKeyDown={escape} onCloseAutoFocus={onCloseAutoFocus} {...(!hasDescription ? { 'aria-describedby': undefined } : {})} data-side={drawer ? side : undefined}
77
+ style={height == null ? undefined : { '--rhine-dialog-height': typeof height === 'number' ? `${height}px` : height } as CSSProperties}
78
+ className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${settings ? ' rhine-dialog--settings' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}${bounded ? ' rhine-dialog--bounded' : ''}`}>
79
+ {bounded ? <div className="rhine-dialog__scroll">{panes}</div> : panes}
80
+ {hasFooter && <div className="rhine-dialog__footer">{footer}</div>}
81
+ </DialogBase.Content>
53
82
  </DialogBase.Portal></DialogBase.Root></span>;
54
83
  }
55
84
  export function ConfirmDialog({ trigger, title, description, confirmLabel = '确认操作', danger = false, onConfirm }: { trigger: ReactElement; title: string; description: string; confirmLabel?: string; danger?: boolean; onConfirm: () => Promise<void> | void }) {
@@ -69,11 +98,12 @@ export function ConfirmDialog({ trigger, title, description, confirmLabel = '确
69
98
  </AlertBase.Content></AlertBase.Portal></AlertBase.Root></span>;
70
99
  }
71
100
  interface OverlayControl { open?: boolean; onOpenChange?: (open: boolean) => void; side?: 'top' | 'right' | 'bottom' | 'left' }
72
- export function Popover({ trigger, title, children, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; title: string; children: ReactNode }) {
101
+ export interface PopoverProps extends OverlayControl { trigger: ReactElement; title: string; children: ReactNode; showHeader?: boolean }
102
+ export function Popover({ trigger, title, children, open, onOpenChange, side, showHeader = true }: PopoverProps) {
73
103
  const { ref, container } = useScope();
74
104
  const floatingRef = useFloatingScale(undefined, 'popover');
75
105
  const floatingSide = useFloatingSide(ref, side, 300);
76
- return <span className="rhine-overlay-anchor" ref={ref}><PopoverBase.Root open={open} onOpenChange={onOpenChange}><PopoverBase.Trigger asChild>{trigger}</PopoverBase.Trigger><PopoverBase.Portal container={container}><PopoverBase.Content ref={floatingRef} className="rhine-popover" aria-label={title} side={floatingSide} sideOffset={8} collisionPadding={12}><div className="rhine-dialog__head"><strong>{title}</strong><PopoverBase.Close asChild><IconButton label="关闭详情" icon="close" /></PopoverBase.Close></div>{children}</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root></span>;
106
+ return <span className="rhine-overlay-anchor" ref={ref}><PopoverBase.Root open={open} onOpenChange={onOpenChange}><PopoverBase.Trigger asChild>{trigger}</PopoverBase.Trigger><PopoverBase.Portal container={container}><PopoverBase.Content ref={floatingRef} className="rhine-popover" aria-label={title} side={floatingSide} sideOffset={8} collisionPadding={12}>{showHeader && <div className="rhine-dialog__head"><strong>{title}</strong><PopoverBase.Close asChild><IconButton label="关闭详情" icon="close" /></PopoverBase.Close></div>}{children}</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root></span>;
77
107
  }
78
108
  export interface MenuItem { id: string; label: string; disabled?: boolean; danger?: boolean; onSelect: () => void }
79
109
  export function Menu({ trigger, label, items, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; label: string; items: MenuItem[] }) {
@@ -63,7 +63,7 @@ export function Example() {
63
63
  | Surface | padding=default/none;HTML div 属性 | 默认24px内距;none仅取消容器padding,适合纯表格;静态分区,无点击语义;padding自0.9.1提供,见[#24](https://github.com/linhay/rhine-design/issues/24) |
64
64
  | Background(0.5.0) | roleTone=canvas/content/sidebar/surface/overlay/media;decoration=auto/theme/none;appearance={color,textColor,texture,textureSize};HTML div 属性 | 默认正文纯色;仅画布默认使用主题纹理;局部覆盖不创建主题根或定位上下文;完整配置见[场景背景](../docs/THEMING.md#场景背景050) |
65
65
  | SelectionCard | label;selected;onSelectedChange;disabled;showIndicator=true;children | 原生 checkbox 可取消;是否单选由上层控制;showIndicator=false 仅视觉隐藏勾选框,保留键盘与读屏,卡片边界表达选中;内部不嵌套按钮/链接/其他输入 |
66
- | TextField | label;hint;error;原生 input 属性 | label 与 id 自动关联;错误与提示组成 aria-describedby;输入值由调用方管理 |
66
+ | TextField | 非空 label;labelVisibility="visible"(默认)/"hidden";hint;error;原生 input 属性 | label 与 id 自动关联;隐藏只影响视觉;错误与提示组成 aria-describedby;输入值由调用方管理;[单搜索契约](../docs/SINGLE_SEARCH.md)(显隐能力未发布) |
67
67
  | Tabs | label;items[{value,label,content,disabled?,description?,status?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End,跳过禁用项;隐藏面板保留挂载但退出可访问树;description/status自0.11.0新增,npm 0.10.0 不含,见下文 |
68
68
  | SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange;fullWidth?;size=default/compact | compact为卡片局部操作的32px视觉/44px命中(自 0.11.0,npm 0.10.0 不含);原生 radio,独立 name 防多组干扰;方向键和禁用行为由浏览器提供;fullWidth 为整行等宽选项,标签和选中底色撑满且文字居中 |
69
69
  | Notice | tone;title;children;action | error 使用 alert,其余 status;调用方决定显示/移除,不自动倒计时关闭错误 |
@@ -187,3 +187,13 @@ export function Example() {
187
187
  - [浮层缩放与边界](../docs/FLOATING_OVERLAYS.md):Popover、Menu、Tooltip 与时间轴共享 CSS zoom 修正。
188
188
  - [设置 Dialog](../docs/SETTINGS_DIALOG.md):`navigation` 插槽及窄窗/缩放重排。
189
189
  - [主题选择](../docs/THEME_PICKER.md):`ThemePicker` / `ThemePreview`,自 0.8.0 提供,当时组件总数增至 66;0.9.0 新增 ListFilterBar 后为 67。
190
+
191
+ ## 聊天组合(源码候选)
192
+
193
+ `ChatThread`、`ChatCard` 提供成员分组、摘要/详情、状态呈现、只读动作约束与历史锚点。契约及公开接入见 [Agent Chat](../docs/AGENT_CHAT.md),展台 `#agent-chat`;不在 npm 0.11.0 内。
194
+
195
+ #41 未发布:Button `contentAlign="center" | "between"`,Tabs `layout="content" | "fill"`及TabsProps,Dialog `height`/`footer`;见[紧凑选择组合](../docs/COMPACT_ACCOUNT_PATTERNS.md)与[稳定弹窗](../docs/SETTINGS_DIALOG.md)。
196
+
197
+ Button.size="standard" | "large"(未发布),标准14/22、44px最小高度,大尺寸16/24、52px最小高度;[动作语义与尺寸](../docs/BUTTON_ACTIONS.md)。
198
+
199
+ #18 未发布:Dialog `description?`、settings CSS次序独立、`height/footer/navigation` 固定头尾与正文/长导航独立滚动;短屏右对齐动作及真实消费验证见 `docs/SETTINGS_DIALOG.md`,展台 `#settings-dialog`。
@@ -0,0 +1,32 @@
1
+ .rhine-chat { min-width:0; color:var(--rhine-ink); background:var(--rhine-surface); border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-card); }
2
+ .rhine-chat-toolbar,.rhine-chat-footer { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:var(--rhine-space-2); padding:var(--rhine-space-2) var(--rhine-space-4); }
3
+ .rhine-chat-toolbar { border-bottom:1px solid var(--rhine-line); }
4
+ .rhine-chat-footer { border-top:1px solid var(--rhine-line); color:var(--rhine-muted); font:var(--rhine-weight-regular) var(--rhine-text-caption)/var(--rhine-leading-caption) var(--rhine-font); }
5
+ .rhine-chat-viewport { position:relative; height:32rem; overflow:auto; overflow-anchor:none; scroll-behavior:auto; overscroll-behavior:contain; padding:var(--rhine-space-4); }
6
+ .rhine-chat-list { list-style:none; padding:0; margin:0 auto; max-width:72ch; }
7
+ .rhine-chat-entry { margin-top:var(--rhine-space-6); min-width:0; overflow-wrap:anywhere; }
8
+ .rhine-chat-entry:first-child { margin-top:0; }
9
+ .rhine-chat-entry[data-continuation=true] { margin-top:var(--rhine-space-2); }
10
+ .rhine-chat-day { display:flex; align-items:center; gap:var(--rhine-space-3); margin-bottom:var(--rhine-space-6); color:var(--rhine-muted); font:var(--rhine-weight-regular) var(--rhine-text-caption)/var(--rhine-leading-caption) var(--rhine-font); }
11
+ .rhine-chat-day::after { content:''; height:1px; flex:1; background:var(--rhine-line); }
12
+ .rhine-chat-meta { display:flex; flex-wrap:wrap; gap:var(--rhine-space-2); align-items:baseline; margin-bottom:var(--rhine-space-2); font:var(--rhine-weight-regular) var(--rhine-text-caption)/var(--rhine-leading-caption) var(--rhine-font); }
13
+ .rhine-chat-meta strong { color:var(--rhine-ink); font-weight:var(--rhine-weight-label); }
14
+ .rhine-chat-meta time { color:var(--rhine-muted); }
15
+ .rhine-chat-avatar { align-self:center; }
16
+ .rhine-chat-body { font:var(--rhine-weight-regular) var(--rhine-text-body)/var(--rhine-leading-body) var(--rhine-font); }
17
+ .rhine-chat-body :is(p,ul,ol,pre,blockquote) { margin:0 0 var(--rhine-space-3); }
18
+ .rhine-chat-body > :last-child { margin-bottom:0; }
19
+ .rhine-chat-body pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:var(--rhine-space-3); background:var(--rhine-selected); font:var(--rhine-weight-regular) var(--rhine-text-body)/var(--rhine-leading-body) ui-monospace, SFMono-Regular, Consolas, monospace; }
20
+ .rhine-chat-body blockquote { border-left:2px solid var(--rhine-control); padding-left:var(--rhine-space-3); color:var(--rhine-muted); }
21
+ .rhine-chat-body a { color:var(--rhine-green); text-decoration:underline; }
22
+ .rhine-chat-body img { max-width:100%; height:auto; }
23
+ .rhine-chat-event,.rhine-chat-empty { color:var(--rhine-muted); font:var(--rhine-weight-regular) var(--rhine-text-caption)/var(--rhine-leading-caption) var(--rhine-font); }
24
+ .rhine-chat-card.rhine-surface { padding:var(--rhine-space-3); margin-block:var(--rhine-space-2); max-width:64ch; box-shadow:none; }
25
+ .rhine-chat-card__heading { display:flex; flex-wrap:wrap; align-items:baseline; gap:var(--rhine-space-2); margin-bottom:var(--rhine-space-2); }
26
+ .rhine-chat-card__heading > strong { font:var(--rhine-weight-label) var(--rhine-text-body)/var(--rhine-leading-body) var(--rhine-font); }
27
+ .rhine-chat-card__summary { overflow-wrap:anywhere; }
28
+ .rhine-chat-card .rhine-disclosure { margin-top:var(--rhine-space-2); }
29
+ .rhine-chat-card .rhine-disclosure summary { padding:var(--rhine-space-2) 0; min-height:44px; }
30
+ .rhine-chat-card .rhine-disclosure__content { padding:var(--rhine-space-2) 0; }
31
+ .rhine-chat-actions { display:flex; flex-wrap:wrap; gap:var(--rhine-space-2); margin-top:var(--rhine-space-2); }
32
+ .rhine-chat-history-error { margin:var(--rhine-space-3) var(--rhine-space-4); color:var(--rhine-error); overflow-wrap:anywhere; }
@@ -47,6 +47,8 @@
47
47
  .rhine-combobox__list [role=option],.rhine-combobox__empty { padding:12px; min-height:44px; cursor:pointer; overflow-wrap:anywhere; }
48
48
  .rhine-combobox__list [role=option][data-active=true],.rhine-combobox__list [role=option][aria-selected=true] { background:var(--rhine-selected); }
49
49
  .rhine-combobox__list [role=option][aria-disabled=true],.rhine-combobox__empty { color:var(--rhine-muted); cursor:default; }
50
+ .rhine-combobox__check { display:inline-block; width:1.5em; font-weight:600; }
51
+ .rhine-combobox__completion { position:sticky; bottom:0; display:flex; justify-content:flex-end; padding:var(--rhine-space-1) var(--rhine-space-2); border-top:1px solid var(--rhine-control); background:var(--rhine-overlay-bg); }
50
52
  .rhine-overlay-anchor { display:inline-block; vertical-align:middle; max-width:100%; }
51
53
  .rhine-overlay { position:fixed; inset:0; background:var(--rhine-backdrop-bg); z-index:40; }
52
54
  .rhine-dialog { position:fixed; z-index:41; top:50%; left:50%; transform:translate(-50%,-50%); width:min(480px,calc(100% - 32px)); max-height:calc(100% - 32px); overflow:auto; padding:24px; border:1px solid var(--rhine-line); border-radius:4px; background:var(--rhine-overlay-bg); color:var(--rhine-overlay-text); box-shadow:0 16px 48px #242b2826; }
@@ -5,13 +5,15 @@ const classes = (...parts: (string | false | undefined)[]) => parts.filter(Boole
5
5
 
6
6
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
7
7
  variant?: 'primary' | 'secondary' | 'quiet' | 'danger';
8
+ size?: 'standard' | 'large';
9
+ contentAlign?: 'center' | 'between';
8
10
  busy?: boolean;
9
11
  busyLabel?: string;
10
12
  }
11
- export function Button({ variant = 'primary', busy = false, busyLabel = '处理中', disabled, className, children, onClick, type = 'button', ...props }: ButtonProps) {
12
- return <button {...props} type={type} disabled={disabled} aria-disabled={busy || disabled || undefined}
13
- aria-busy={busy || undefined} className={classes('rhine-button', `rhine-button--${variant}`, className)}
14
- onClick={event => { if (busy) { event.preventDefault(); return; } onClick?.(event); }}>
13
+ export function Button({ variant = 'primary', size = 'standard', contentAlign = 'center', busy = false, busyLabel = '处理中', disabled, className, children, onClick, type = 'button', ...props }: ButtonProps) {
14
+ return <button {...props} type={type} disabled={disabled} aria-disabled={busy || disabled || props['aria-disabled'] || undefined}
15
+ aria-busy={busy || undefined} className={classes('rhine-button', `rhine-button--${variant}`, size === 'large' && 'rhine-button--large', contentAlign === 'between' && 'rhine-button--between', className)}
16
+ onClick={event => { if (busy || props['aria-disabled'] === true || props['aria-disabled'] === 'true') { event.preventDefault(); return; } onClick?.(event); }}>
15
17
  <span className={busy ? 'rhine-button__ghost' : undefined}>{children}</span>
16
18
  {busy && <span className="rhine-button__busy"><span className="rhine-spinner" aria-hidden="true" /><span className="rhine-sr-only">{busyLabel}</span></span>}
17
19
  </button>;
@@ -33,24 +35,32 @@ export function SelectionCard({ label, selected, onSelectedChange, disabled = fa
33
35
  <span className="rhine-selection__content">{children}</span>
34
36
  </label>;
35
37
  }
36
- export function TextField({ label, hint, error, id: providedId, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: string; error?: string }) {
38
+ export interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
39
+ label: string;
40
+ labelVisibility?: 'visible' | 'hidden';
41
+ hint?: string;
42
+ error?: string;
43
+ }
44
+ export function TextField({ label, labelVisibility = 'visible', hint, error, id: providedId, className, ...props }: TextFieldProps) {
37
45
  const generatedId = useId();
38
46
  const id = providedId || generatedId;
47
+ if (!label.trim()) throw new Error('TextField requires a non-empty label.');
39
48
  const describedBy = [props['aria-describedby'], hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(' ');
40
49
  return <div className={classes('rhine-field', className)}>
41
- <label htmlFor={id}>{label}</label>
42
- <input {...props} id={id} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={describedBy || undefined} />
50
+ <label htmlFor={id} className={labelVisibility === 'hidden' ? 'rhine-sr-only' : undefined}>{label}</label>
51
+ <input {...props} aria-label={props['aria-label']?.trim() || undefined} aria-labelledby={props['aria-labelledby']?.trim() || undefined} id={id} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={describedBy || undefined} />
43
52
  {hint && <span id={`${id}-hint`} className="rhine-field__hint">{hint}</span>}
44
53
  {error && <span id={`${id}-error`} className="rhine-field__error" role="alert">{error}</span>}
45
54
  </div>;
46
55
  }
47
56
  export interface Choice { value: string; label: string; disabled?: boolean }
48
57
  export interface TabItem extends Choice { content: ReactNode; description?: string; status?: ReactNode }
49
- export function Tabs({ label, items, value, onValueChange }: { label: string; items: TabItem[]; value: string; onValueChange: (value: string) => void }) {
58
+ export interface TabsProps { label: string; items: TabItem[]; value: string; onValueChange: (value: string) => void; layout?: 'content' | 'fill' }
59
+ export function Tabs({ label, items, value, onValueChange, layout = 'content' }: TabsProps) {
50
60
  const id = useId();
51
61
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
52
62
  return <div className="rhine-tabs">
53
- <div role="tablist" aria-label={label} className={classes('rhine-tabs__list', items.some(item => item.description || item.status) && 'rhine-tabs__list--detailed')} onKeyDown={event => {
63
+ <div role="tablist" aria-label={label} className={classes('rhine-tabs__list', `rhine-tabs__list--${layout}`, items.some(item => item.description || item.status) && 'rhine-tabs__list--detailed')} onKeyDown={event => {
54
64
  if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
55
65
  const available = items.map((item, index) => ({ ...item, index })).filter(item => !item.disabled);
56
66
  if (!available.length) return;
@@ -1,4 +1,4 @@
1
- .rhine-dialog--settings { width:min(960px,calc(100% - 32px)); max-height:calc(100% - 32px); container-type:inline-size; }
1
+ .rhine-dialog.rhine-dialog--settings { width:min(960px,calc(100% - 32px)); max-height:calc(100% - 32px); container-type:inline-size; }
2
2
  .rhine-dialog__settings-grid { display:grid; grid-template-columns:minmax(0,1fr); gap:24px; }
3
3
  .rhine-dialog__navigation { min-width:0; padding-bottom:16px; border-bottom:1px solid var(--rhine-line); }
4
4
  .rhine-dialog__settings-content { min-width:0; overflow-wrap:anywhere; }
@@ -7,3 +7,56 @@
7
7
  .rhine-dialog__settings-grid { grid-template-columns:minmax(160px,1fr) minmax(0,3fr); }
8
8
  .rhine-dialog__navigation { padding:0 24px 0 0; border-bottom:0; border-right:1px solid var(--rhine-line); }
9
9
  }
10
+
11
+ /* Opt-in stable outer frame; footer never participates in content scrolling. */
12
+ .rhine-dialog.rhine-dialog--bounded { display:flex; flex-direction:column; gap:0; height:var(--rhine-dialog-height,auto); max-height:calc(100% - 32px); overflow:hidden; }
13
+ .rhine-dialog.rhine-dialog--bounded.rhine-dialog--split { width:min(960px,calc(100% - 32px)); }
14
+ .rhine-dialog__scroll { min-height:0; min-width:0; overflow:auto; flex:1 1 auto; display:grid; grid-template-columns:minmax(0,1fr); }
15
+ .rhine-dialog--bounded .rhine-dialog__main { min-height:0; }
16
+ .rhine-dialog--bounded .rhine-dialog__head { position:sticky; top:0; z-index:1; background:var(--rhine-overlay-bg); }
17
+ .rhine-dialog--bounded .rhine-dialog__head>.rhine-icon-button { position:static; flex:none; }
18
+ .rhine-dialog--bounded .rhine-dialog__side { padding-top:0; }
19
+ .rhine-dialog__footer { flex:none; min-width:0; padding-top:var(--rhine-space-4); margin-top:var(--rhine-space-4); border-top:1px solid var(--rhine-line); }
20
+ @media(min-width:721px) {
21
+ .rhine-dialog--bounded.rhine-dialog--split .rhine-dialog__scroll { grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr); gap:24px; overflow:hidden; }
22
+ .rhine-dialog--bounded.rhine-dialog--split .rhine-dialog__main,.rhine-dialog--bounded.rhine-dialog--split .rhine-dialog__side { overflow:auto; min-height:0; }
23
+ }
24
+
25
+ /* A bounded single-pane/settings dialog keeps its head outside both scroll areas.
26
+ Keep the legacy auxiliary sidePanel layout independent. */
27
+ .rhine-dialog--bounded:not(.rhine-dialog--split) .rhine-dialog__scroll { overflow:hidden; }
28
+ .rhine-dialog--bounded:not(.rhine-dialog--split) .rhine-dialog__main { display:flex; flex-direction:column; overflow:hidden; }
29
+ .rhine-dialog--bounded:not(.rhine-dialog--split) .rhine-dialog__head { position:static; flex:none; }
30
+ .rhine-dialog--bounded:not(.rhine-dialog--split) .rhine-dialog__main > p { flex:none; }
31
+ .rhine-dialog--bounded:not(.rhine-dialog--split) .rhine-dialog__body { min-height:0; overflow:auto; overscroll-behavior:contain; }
32
+ .rhine-dialog--bounded.rhine-dialog--settings .rhine-dialog__body { display:flex; flex:1; overflow:hidden; }
33
+ .rhine-dialog--bounded .rhine-dialog__settings-grid { flex:1; min-height:0; grid-template-rows:minmax(64px,1fr) minmax(0,3fr); gap:12px; }
34
+ .rhine-dialog--bounded .rhine-dialog__navigation { min-height:0; max-height:112px; overflow:auto; overscroll-behavior:contain; padding-bottom:0; }
35
+ .rhine-dialog--bounded .rhine-dialog__settings-content { min-height:0; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable; }
36
+ .rhine-dialog__footer { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:8px; max-height:var(--rhine-dialog-footer-limit,40%); overflow:auto; overscroll-behavior:contain; }
37
+ .rhine-dialog__footer > * { max-width:100%; }
38
+ @container (min-width:560px) {
39
+ .rhine-dialog--bounded .rhine-dialog__settings-grid { grid-template-rows:minmax(0,1fr); gap:24px; }
40
+ .rhine-dialog--bounded .rhine-dialog__navigation { max-height:none; padding-bottom:0; }
41
+ }
42
+ @container (max-width:559px) {
43
+ .rhine-dialog--bounded .rhine-dialog__body { margin-top:8px; }
44
+ .rhine-dialog--bounded .rhine-dialog__navigation { max-height:64px; }
45
+ .rhine-dialog--bounded .rhine-dialog__settings-grid { gap:8px; }
46
+ .rhine-dialog__footer { padding-top:8px; margin-top:8px; }
47
+ }
48
+ /* Root padding follows the available viewport; descendants use the real container. */
49
+ @media (max-width:560px) {
50
+ .rhine-dialog.rhine-dialog--bounded { padding:12px; }
51
+ }
52
+ @container (max-width:300px) {
53
+ .rhine-dialog__footer { gap:2px; }
54
+ .rhine-dialog__footer > .rhine-button { padding-inline:2px; }
55
+ }
56
+
57
+ .rhine-dialog.rhine-dialog--settings.rhine-dialog--bounded { padding-block:12px; }
58
+ /* Keep theme focus rings and offset shadows inside the footer's scroll bounds. */
59
+ .rhine-dialog__footer { padding-inline:4px; padding-bottom:4px; }
60
+ @media (max-width:560px) {
61
+ .rhine-dialog.rhine-dialog--settings.rhine-dialog--bounded { padding-inline:8px; }
62
+ }
@@ -54,3 +54,7 @@
54
54
  .rhine-table__depth { display:block; white-space:nowrap; color:var(--rhine-muted); font-size:12px; }
55
55
  .rhine-table .rhine-check label { min-height:44px; }
56
56
  .rhine-pagination .rhine-select { width:auto; }
57
+
58
+ /* Direct action slots: keep hidden restore controls mounted without an empty bar. */
59
+ .rhine-shell__content-header[data-collapse-empty]:empty,
60
+ .rhine-shell__content-header[data-collapse-empty]:has(> [hidden]):not(:has(> :not([hidden]))) { display:none; }
@@ -37,14 +37,19 @@ export type { DataColumn, DataTableProps, TableSort, PaginationProps } from './D
37
37
  export { Checkbox, RadioGroup, Switch, Textarea, SearchField, Combobox } from './Forms';
38
38
  export type { ComboboxProps } from './Forms';
39
39
  export { Dialog, Drawer, ConfirmDialog, Popover, Menu, Tooltip } from './Overlays';
40
- export type { DialogProps, MenuItem } from './Overlays';
40
+ export type { DialogProps, PopoverProps, MenuItem } from './Overlays';
41
41
  export { Toast } from './Toast';
42
42
  export { SideNav, Breadcrumbs, Steps } from './Navigation';
43
- export type { NavItem, NavGroup, SideNavProps, BreadcrumbItem, BreadcrumbsProps, StepItem, StepsProps } from './Navigation';
43
+ export type { NavAction, NavItem, NavGroup, SideNavProps, BreadcrumbItem, BreadcrumbsProps, StepItem, StepsProps } from './Navigation';
44
44
  export { Fieldset, ErrorSummary } from './FormWorkflow';
45
45
  export type { FieldsetProps, FieldError, ErrorSummaryProps } from './FormWorkflow';
46
46
 
47
47
  export { Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState } from './components';
48
- export type { ButtonProps, SurfaceProps, Tone, Choice, TabItem } from './components';
48
+ export type { ButtonProps, TabsProps, SurfaceProps, Tone, Choice, TabItem } from './components';
49
49
 
50
50
  export { ThemePicker, ThemePreview, type ThemePickerProps, type ThemePickerOption, type ThemePreviewProps } from './ThemePicker';
51
+
52
+ export { ChatThread, ChatCard } from './Chat';
53
+ export type { ChatThreadProps, ChatCardProps, ChatEntry, ChatMessage, ChatEvent, ChatMember, ChatStatus } from './Chat';
54
+
55
+ export type { TextFieldProps } from './components';
@@ -37,3 +37,7 @@
37
37
  .rhine-dialog.rhine-drawer { inset-block:0; inset-inline-end:0; inset-inline-start:auto; width:min(440px,100%); max-height:100dvh; margin:0; transform:none; border-radius:0; animation:rhine-drawer-enter var(--rhine-duration-overlay) var(--rhine-ease-standard); }
38
38
  @keyframes rhine-drawer-enter { from { opacity:0; transform:translateX(3px); } to { opacity:1; transform:translateX(0); } }
39
39
  @keyframes rhine-pattern-enter { from { opacity:.65; transform:translateY(3px); } to { opacity:1; transform:translateY(0); } }
40
+
41
+ .rhine-side-nav__row--actions { display:flex; align-items:center; gap:var(--rhine-space-1); min-width:0; }
42
+ .rhine-side-nav__row--actions>a { flex:1; min-width:0; }
43
+ .rhine-side-nav__row--actions>.rhine-overlay-anchor { flex:none; }
@@ -8,6 +8,7 @@
8
8
  @import './orbit.css';
9
9
  @import './neutron.css';
10
10
  @import './charts.css';
11
+ @import './chat.css';
11
12
 
12
13
  .rhine-theme { color:var(--rhine-ink); background:var(--rhine-bg); font:var(--rhine-text-body)/var(--rhine-leading-body) var(--rhine-font); font-synthesis:none; }
13
14
  .rhine-theme[data-rhine-theme="rhine"] { color-scheme:light; }
@@ -15,7 +16,7 @@
15
16
  .rhine-theme [hidden] { display:none!important; }
16
17
  .rhine-theme :is(button,input,select,textarea,summary,a,[tabindex]):focus-visible { outline:2px solid var(--rhine-green); outline-offset:3px; }
17
18
  .rhine-sr-only { position:absolute!important; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
18
- .rhine-button { position:relative; display:inline-flex; align-items:center; justify-content:center; min-height:44px; min-width:44px; padding:10px 16px; border:1px solid var(--rhine-ink); border-radius:var(--rhine-radius-control); font:var(--rhine-weight-label) var(--rhine-text-body)/var(--rhine-leading-body) var(--rhine-font); background:var(--rhine-ink); color:var(--rhine-surface); cursor:pointer; overflow-wrap:anywhere; transition:background-color var(--rhine-duration-hover) var(--rhine-ease-standard),border-color var(--rhine-duration-hover) var(--rhine-ease-standard),transform var(--rhine-duration-press) var(--rhine-ease-standard); }
19
+ .rhine-button { max-width:100%; position:relative; display:inline-flex; align-items:center; justify-content:center; min-height:44px; min-width:44px; padding:10px 16px; border:1px solid var(--rhine-ink); border-radius:var(--rhine-radius-control); font:var(--rhine-weight-label) var(--rhine-text-body)/var(--rhine-leading-body) var(--rhine-font); background:var(--rhine-ink); color:var(--rhine-surface); cursor:pointer; overflow-wrap:anywhere; transition:background-color var(--rhine-duration-hover) var(--rhine-ease-standard),border-color var(--rhine-duration-hover) var(--rhine-ease-standard),transform var(--rhine-duration-press) var(--rhine-ease-standard); }
19
20
  .rhine-button:hover:not(:disabled):not([aria-disabled=true]) { background:var(--rhine-green); border-color:var(--rhine-green); }
20
21
  .rhine-button:active:not(:disabled):not([aria-disabled=true]) { transform:translateY(1px); }
21
22
  .rhine-button--secondary { background:var(--rhine-surface); color:var(--rhine-ink); border-color:var(--rhine-control); }
@@ -61,7 +62,7 @@
61
62
  .rhine-tabs__list button[aria-selected=true]::after { transform:scaleX(1); }
62
63
  .rhine-tabs__list button:disabled { color:var(--rhine-muted); opacity:.6; cursor:default; }
63
64
  .rhine-tabs__list--detailed { align-items:stretch; gap:var(--rhine-space-4); }
64
- .rhine-tabs__list--detailed>button { display:flex; flex-direction:column; align-items:stretch; flex:1 1 0; min-width:0; padding-block:var(--rhine-space-3); text-align:start; overflow-wrap:anywhere; }
65
+ .rhine-tabs__list--detailed>button { display:flex; flex-direction:column; align-items:stretch; min-width:0; padding-block:var(--rhine-space-3); text-align:start; overflow-wrap:anywhere; }
65
66
  .rhine-tabs__list--detailed>button:hover:not(:disabled) { color:var(--rhine-ink); }
66
67
  .rhine-tabs__title { display:flex; align-items:center; flex-wrap:wrap; gap:var(--rhine-space-2); font-weight:var(--rhine-weight-heading); }
67
68
  .rhine-tabs__status { display:inline-flex; }
@@ -133,3 +134,11 @@
133
134
  .rhine-theme *, .rhine-theme *::before, .rhine-theme *::after { animation:none!important; transition:none!important; }
134
135
  .rhine-theme .rhine-button:active { transform:none; }
135
136
  }
137
+
138
+ .rhine-button--between>span:first-child { display:flex; align-items:center; justify-content:space-between; gap:var(--rhine-space-2); width:100%; min-width:0; overflow-wrap:anywhere; text-align:start; }
139
+ .rhine-button--between>span:first-child>.rhine-icon { flex:none; }
140
+ .rhine-tabs__list--content { width:max-content; max-width:100%; }
141
+ .rhine-tabs__list--content>button { flex:0 1 auto; min-width:0; max-width:100%; overflow-wrap:anywhere; }
142
+ .rhine-tabs__list--fill>button { flex:1 1 0; min-width:0; overflow-wrap:anywhere; }
143
+
144
+ .rhine-button--large { font-size:var(--rhine-text-heading); line-height:var(--rhine-leading-heading); min-height:52px; padding:12px 24px; max-width:100%; }
@@ -130,8 +130,8 @@
130
130
  outline: none;
131
131
  box-shadow: inset 0 0 0 2px var(--rhine-ink);
132
132
  }
133
- .rhine-theme[data-rhine-theme="arknights"] .rhine-button--danger { background: var(--rhine-error); border-color: var(--rhine-error); color: var(--rhine-ink); }
134
- .rhine-theme[data-rhine-theme="arknights"] .rhine-button--danger:hover:not(:disabled):not([aria-disabled=true]) { background: #c93a32; border-color: #c93a32; }
133
+ .rhine-theme[data-rhine-theme="arknights"] .rhine-button--danger { background: var(--rhine-error); border-color: var(--rhine-error); color: var(--ak-accent-ink); }
134
+ .rhine-theme[data-rhine-theme="arknights"] .rhine-button--danger:hover:not(:disabled):not([aria-disabled=true]) { background: #e74b40; border-color: #e74b40; }
135
135
  .rhine-theme[data-rhine-theme="arknights"] .rhine-button:active:not(:disabled):not([aria-disabled=true]) { transform:none; }
136
136
 
137
137
  /* 图标按钮保持方形小切角 */
@@ -304,3 +304,5 @@
304
304
  .rhine-theme[data-rhine-theme="arknights"][data-rhine-motion=reduced] .rhine-button {
305
305
  transition: none;
306
306
  }
307
+
308
+ .rhine-theme[data-rhine-theme="arknights"] .rhine-button:focus-visible:not(.rhine-button--secondary):not(.rhine-button--quiet) { box-shadow:inset 0 0 0 2px var(--ak-accent-ink); }
@@ -29,7 +29,7 @@ export function useFloatingSide(anchor: RefObject<HTMLSpanElement | null>, side:
29
29
  }
30
30
 
31
31
  /** Keep Radix's viewport coordinates unscaled while preserving the user's content zoom. */
32
- export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind = 'popover') {
32
+ export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind = 'popover', maxHeight?: number) {
33
33
  const [content, setContent] = useState<HTMLDivElement | null>(null);
34
34
  const attach = useCallback((element: HTMLDivElement | null) => {
35
35
  if (ref) ref.current = element;
@@ -62,7 +62,11 @@ export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind =
62
62
  content.style.zoom = String(scale);
63
63
  const width = `calc((100vw - 24px) / ${scale})`;
64
64
  content.style.maxWidth = kind === 'tooltip' ? `min(280px, ${width})` : width;
65
- content.style.maxHeight = `calc(var(--radix-${kind}-content-available-height) / ${scale})`;
65
+ // Bounded suggestion lists retain their own height cap. Feeding Radix's
66
+ // available-height variable back into their size can cause resize loops.
67
+ content.style.maxHeight = maxHeight === undefined
68
+ ? `calc(var(--radix-${kind}-content-available-height) / ${scale})`
69
+ : `min(${maxHeight}px, calc((100vh - 24px) / ${scale}))`;
66
70
  if (kind === 'dropdown-menu') content.style.minWidth = `min(180px, ${width})`;
67
71
  constrain();
68
72
  };
@@ -91,6 +95,6 @@ export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind =
91
95
  content.style.maxHeight = original.maxHeight;
92
96
  content.style.minWidth = original.minWidth;
93
97
  };
94
- }, [content, kind]);
98
+ }, [content, kind, maxHeight]);
95
99
  return attach;
96
100
  }
@@ -0,0 +1,76 @@
1
+ # Agent Chat:消息、卡片与历史
2
+
3
+ 源码候选,关联 #43;不属于 npm 0.11.0。以包含本规范的精确源码或后续已验证版本接入。入口:`ChatThread`、`ChatCard`,展台 `#agent-chat`。不引入模型、聊天运行时、Markdown 解析器或业务流程。
4
+
5
+ ## 参考与采用决策(2026-10-27)
6
+
7
+ 本轮实际读取官方文档、代码示例、网页可访问性结构及可见演示。日期固定为 2026-10-27(Asia/Shanghai),这些页面没有统一发布版本;LibreChat 标注 Latest (dev)。以下是当日文档/演示证据,不声称审计安装后的完整产品。没有安装三个项目的依赖。
8
+
9
+ | 问题 | 官方证据与界面观察 | Rhine 决定与原因 |
10
+ | --- | --- | --- |
11
+ | 流式更新打断历史阅读 | [assistant-ui Thread](https://www.assistant-ui.com/docs/primitives/thread) 的 Viewport & Auto-Scroll 明确上翻暂停、展开 disclosure 暂停;LoadEarlier 保留可见消息和按钮焦点。示例展示消息视口与独立 composer。 | 采用锚点与暂停;用稳定消息 ID 保留位置。进一步要求显式「回到最新并跟随」才恢复,展开/新运行均不擅自恢复。无需运行时 Provider。 |
12
+ | 正文与卡片抢层级 | 旧 [Message](https://elements.ai-sdk.dev/components/message) 演示正文、代码及紧随内容的动作;[Tool](https://elements.ai-sdk.dev/components/tool) 演示折叠行及 Pending / Running / Error 等文字状态。本轮点击 Error 行确认展开状态变化,状态仍在摘要行。 | 采用正文无外框、结构化卡片摘要优先;错误和未知解释始终置于摘要。拒绝固定 user/assistant 左右角色。 |
13
+ | 依赖即将迁移 | 旧文档横幅写明 2026 年 11 月底弃用;实际跟随 [shadcn/ui 迁移入口](https://ui.shadcn.com/docs/ai-elements#migrating-from-ai-sdk-elements),Conversation 对应 Chat,Tool 对应 Tool/Trace,既有复制代码仍可用。[新 Chat](https://ui.shadcn.com/docs/ai-elements/base/chat) 采用 Message Scroller;[新 Tool](https://ui.shadcn.com/docs/ai-elements/base/tool) 保持状态与详情分层。 | 参考行为与组合,不锁定旧 AI Elements 包。没有复制其代码或迁移消费项目。新 Chat 的 person turn 顶部锚点适合问答;Rhine 多成员记录采用时间顺序和当前可见消息锚点。 |
14
+ | 完整产物挤占对话 | [LibreChat Artifacts](https://www.librechat.ai/docs/features/artifacts) 描述独立交互视图;Preview and Export 说明窄屏不展示独立窗口操作,并描述紧凑 Mermaid 内嵌卡片进入产物面板。页面有嵌入演示视频入口。 | 聊天只承载摘要、依据与必要详情。复杂产物由宿主提供独立详情入口,不把可执行 HTML 或完整编辑器塞入消息;本轮没有运行其模型、视频中的产品或产物执行器。 |
15
+
16
+ 本地采样目录为 `output/playwright/`:reference-assistant / reference-migration / reference-tool / reference-librechat。官方站点的第三方加载错误单列为外站环境现象,不据此推断组件失败。Rhine 前后对照使用同一组合成内容;「问题结构复现」重建 issue 中的重复元信息与大卡片结构,不冒充消费产品截图。
17
+
18
+ ## 消息骨架与密度
19
+
20
+ - 时间升序;`id` 唯一稳定。宿主提供成员 ID/名称、可选装饰头像、ISO 时间(含时区)及本地化时间/日期。头像不重复朗读姓名。
21
+ - 同成员、同日期、间隔不超过 5 分钟且中间无事件,合并可见元信息;每条仍保留可访问作者/时间。换成员、跨日、系统事件、较长间隔均重新开始组。无右侧「当前用户」特权。
22
+ - 正文用 body 14/22 与主题 regular 字重,不缩字;作者与时间 caption 12/18,作者用 label 字重。行宽上限 72ch;卡片 64ch,窄屏不设最小宽度。
23
+ - 组间 24px、同组消息 8px、元信息到正文 8px、段间 12px。日期以细线区隔,事件用次级文字,不伪装成员气泡。
24
+ - 普通正文无卡片外框。结构化内容复用 Surface、Badge、Disclosure,卡片内距 12px,标题/状态间距 8px。动作靠近对应正文或卡片尾部,使用 Button quiet/secondary,自然宽度并换行;保留 44px 命中范围。
25
+ - 文本、列表、代码、引用均完整换行。代码保留空白并可在长标识符处换行;复制由宿主使用原始数据。引用使用 blockquote + 可键盘访问的来源链接,宿主校验 URL 并提供可读名称。大型表格/产物另开详情,不默认裁切正文。
26
+
27
+ ## 卡片与状态所有权
28
+
29
+ `ChatCard({title, status, summary, children?, actions?, readOnly?})`:标题和状态同层,摘要始终显示;children 是可开合详情,错误原因/恢复建议不得只放在 children。支持请求、工具过程、等待依据、结果/产物与来源卡片,无业务种类枚举。详情不默认展开。
30
+
31
+ | status | 含义 |
32
+ | --- | --- |
33
+ | pending / accepted / declined | 待回应 / 已接受(未必运行)/ 已拒绝 |
34
+ | blocked / running | 等待依赖(无无限 spinner)/ 正在运行 |
35
+ | partial / succeeded | 部分结果(未完成)/ 宿主已确认完成 |
36
+ | failed / cancelled / expired / unknown | 失败 / 已取消 / 已过期 / 未知,分别保留事实与后续说明 |
37
+
38
+ 状态只有呈现映射,没有自动转换、重试、超时或权限推断。缺失/异常外部状态先由宿主规范化为 unknown;运行时兜底也显示未知,不显示成功。停止流、网络断开或动画结束都不能推导 succeeded。宿主取消请求、屏蔽迟到回调、卸载清理;部分结果继续可读。
39
+
40
+ `ChatThread readOnly` 通过上下文隐藏消息和 ChatCard 的 actions;卡片自身也可 readOnly。展开、来源访问、历史加载和恢复跟随属于阅读动作,仍可用。宿主不得将发送/接受/拒绝等操作藏进任意 content/summary/children 插槽;readOnly 不是权限沙箱。不发送已读回执、不登记观察者身份、不自动改变阅读状态。首版无输入框。
41
+
42
+ ## 滚动、焦点与历史
43
+
44
+ `ChatThread({label, entries, readOnly?, hasEarlier?, loadingEarlier?, historyError?, onLoadEarlier?, announcement?})`。
45
+
46
+ - 初次显示定位最新。跟随时新增消息、流式增长与图片尺寸变化保持底部;ResizeObserver 观察内容和视口,卸载 disconnect。
47
+ - 上翻、PageUp/Home、进入消息内控件或开合详情会暂停。暂停后新增/流式内容不夺取阅读位置或焦点;提供常驻恢复按钮。不会因新 run 或滚回底部意外恢复。
48
+ - 加载更早由宿主发起;宿主原样保留已有 ID 并 prepend。根据首个可见消息 ID 与相对视口偏移恢复,而非简单比较总 scrollHeight(其间可能还在流式增长)。日期/组边界变化也纳入定位。加载按钮在忙碌/无更多记录时保持 DOM 与焦点,用 aria-disabled 阻止重复调用;失败文字常显且可重试。
49
+ - 本地详情展开保留当前行锚点,使用既有 Disclosure 键盘/焦点和减少动态实现;不会主动移焦到新消息。历史定位由宿主先加载所需记录,再聚焦该内容中带稳定 DOM id 的链接/控件;focus capture 会暂停跟随。切换整个对话使用 `key={conversationId}` 重新挂载,重置阅读状态。
50
+ - 非虚拟列表适用于有界记录;大量历史的窗口化不在首版。移除当前锚点、任意重排或更换稳定 ID 不承诺保持阅读位置。异步媒体应声明尺寸,避免单条超长消息内部重排。
51
+ - 消息区是有名称、可聚焦 region + 有序列表,不把历史和每个流式字元放入 live log。announcement 仅供宿主低频播报「接收中/已取消/失败」等;不重复播报全部正文。
52
+ - 跟随/锚点调整均即时,无平滑位移。系统/手动减少动态继续保留明确文字状态。CSS zoom 验证与浏览器原生缩放分别记录,前者不替代后者。
53
+
54
+ ## 公开接入
55
+
56
+ 下面只用公开 API。分页、权限、格式化与内容安全由宿主负责。已发布版本是否包含 API 需核对实际导出。
57
+
58
+ ```tsx
59
+ import { ChatThread, ChatCard, Button, type ChatEntry } from '@linhey/rhine-design';
60
+
61
+ const entries: ChatEntry[] = [{
62
+ id: 'sample-request', kind: 'message', day: '2026 年 10 月 27 日',
63
+ member: { id: 'member-a', name: '成员 A' },
64
+ dateTime: '2026-10-27T10:23:00+08:00', time: '10:23',
65
+ content: <ChatCard title="资料核对" status="blocked"
66
+ summary="等待公开来源补全;当前没有运行中的任务。">
67
+ <p>保留已有依据,未知项尚待核对。</p>
68
+ </ChatCard>,
69
+ actions: <Button variant="quiet" onClick={() => { /* 宿主允许的操作 */ }}>查看来源目录</Button>,
70
+ }];
71
+ export function Observation() {
72
+ return <ChatThread label="资料核对记录" entries={entries} readOnly />;
73
+ }
74
+ ```
75
+
76
+ 完整可运行公开导出消费样例:`preview/ChatGallery.tsx`。三场景全部合成:两成员连续消息、三成员和事件、只读状态分支记录;演示控制在消息区之外,不执行模型或真实业务接口。`排版对照` 提供改造前后相同内容。验证结果见 [Agent Chat 验证](AGENT_CHAT_VERIFICATION.md)。
@@ -0,0 +1,42 @@
1
+ # Agent Chat 验证记录
2
+
3
+ 关联 #43;2026-10-27,Asia/Shanghai。纯合成数据,没有业务 API、模型调用或消费迁移。源码候选不包含于冻结的 0.11.0 发布制品;合并不代表 npm 发布。
4
+
5
+ ## 视觉对照
6
+
7
+ 同一合成资料核对对话,520px 容器。之前复现 issue 的重复作者/时间、标题和状态分层及宽动作;之后合并连续作者,正文轻量,状态紧随卡片标题,动作左对齐。截图显示各自开头,长记录通过滚动阅读;不是消费产品截图。
8
+
9
+ | 问题结构复现 | 规范组合 |
10
+ | --- | --- |
11
+ | ![改造前](assets/chat/before.png) | ![改造后](assets/chat/after.png) |
12
+
13
+ | 战术主题 · 多成员事件 | 橙趣主题 · 只读记录 |
14
+ | --- | --- |
15
+ | ![战术主题](assets/chat/arknights.png) | ![橙趣主题](assets/chat/neo-brutalism.png) |
16
+
17
+ ## 证据与范围
18
+
19
+ 运行实现修订 `e7806fcfb646309cc3f70a9d0670bbc4f5668737`(基线 `4d70e73`),Node 24.21.0、React 19.3.1、Vite 8.3.4;Chromium 155 / macOS 的独立 Playwright 会话。随后 rebase 到 `5d9eec1` 保留导航行操作,聊天运行文件内容不变;证据文档/截图提交不改变运行实现;最终 head、制品摘要和 CI 在 PR #51 中绑定。原始本地证据 `output/playwright/` 不直接公开(可能含本机工具路径);此文与 PR 仅保留脱敏摘要。
20
+
21
+ - PASS:Node 24 下 `npm run check`,302 项测试通过,包含 Vitest、类型、生产构建、CSS/SVG 产物检查、两个评审示例构建和包清单检查。
22
+ - PASS:三主题 × 400/520/880 容器设置 × 三场景 × 100%/200% **CSS zoom**,54 组量测与截图;另检查 400/520/880 浏览器视口。正文 14px / 22px,卡片内距 12px,正文无横向溢出。放大时容器按可用空间缩窄,不假称仍有 880px 可用宽度。
23
+ - PASS:普通/系统减少动态/手动减少动态,三主题下 200% CSS zoom 键盘 Enter 开合、焦点保留、完整详情可读。
24
+ - PASS:上翻后到达消息不跳底,加载历史保留同一消息和相对偏移(误差阈值 2px),加载按钮焦点保留;显式恢复跟随回到底部。真实可见流式正文增长时保持跟随;PageUp 暂停,流式增长同时 prepend 保留锚点。
25
+ - PASS:观察模式无接受/拒绝/发送/重试;错误和未知文字在折叠外可见;历史失败保留并可重试;取消/连接失败保留内容且无迟到更新;自然结束为部分结果。卸载断开 ResizeObserver、清理流式与历史计时器。
26
+ - PASS:公开包入口独立安装消费与生产构建、真实浏览器操作;268 个安装文件逐一核对相同;示例仅登记成员、内容、状态与允许动作。
27
+ - SKIPPED:浏览器原生网页缩放、屏幕阅读器真人朗读、其他浏览器引擎、超大记录虚拟化;不以 CSS zoom、DOM 语义或 Chromium 结果外推。#43 要求的 CSS zoom 与键盘矩阵已单列。
28
+ - N/A:业务权限、真实模型、后端分页、iOS、消费产品升级、npm 发布。
29
+
30
+ ## 保留的失败与修正
31
+
32
+ 1. 首次全量检查的测试辅助函数返回宽联合类型,导致 TypeScript 不接受消息 actions;收窄为 ChatMessage 后通过。单元测试通过不等于类型检查通过。
33
+ 2. 首次视觉检查发现 Surface 的无内距修饰覆盖 ChatCard 内距;提高 ChatCard 规则具体性后,54 组实际计算值均为 12px。
34
+ 3. 首次浏览器行为脚本用完整文本精确定位 summary,但其中还有装饰符号,导致 30 秒超时。改为有作用域的 summary 定位,保留原失败;没有将定位器问题记成组件修复。
35
+ 4. 实际流式交互发现仅比较末条 ID 会漏报插在流式末条之前的新消息。改为比较稳定 ID 集合、排除前插历史,补契约回归并重新验证。
36
+ 5. 官方参考页有第三方资源报错;本轮只把可见页面、实际展开和官方文档作为证据,没有宣称完整参考产品运行通过。
37
+
38
+ 6. 动作焦点回归发现 Button 覆盖宿主传入的 aria-disabled。补齐原生属性透传语义并阻止点击,保留忙碌/不可操作控件焦点;历史加载使用已有 busy API。
39
+
40
+ ## 新增可复用结论
41
+
42
+ 新消息不一定改变列表尾部 ID(例如消息插入到已有的流式记录之前)。新消息提示应比较稳定 ID,单独排除 prepend 的历史;滚动恢复也应绑定原消息与偏移,而非总高度差。该结论已通过组件回归与真实流式同时加载历史验证,不推断任意重排/虚拟化行为。
@@ -56,6 +56,8 @@ import { ThemeProvider } from '@linhey/rhine-design/theme';
56
56
  </AppShell>
57
57
  ```
58
58
 
59
+ 仅含动作的顶部可选 `collapseEmptyContentHeader`(未发布,默认false),直传恢复控件与条件动作,按hidden可见性折叠;具体结构限制和设置页组合见[设置页组合](SETTINGS_DIALOG.md#设置页组合53未发布)。
60
+
59
61
  ### 工作区导航栏(0.8.0)
60
62
 
61
63
  参考 [Ant Design Layout](https://ant.design/components/layout/) 的 Header / Sider / Content 分工与 [MUI App Bar](https://mui.com/material-ui/react-app-bar/) 的标题、导航和操作组合,工作区顶部承载当前位置与导航入口。用可选 `contentHeader` 将导航栏放在正文滚动区外,与侧栏并列;它不会随正文滚走,不需要宿主另写 sticky。既有 `header` 仍位于整个外壳顶部,不传新插槽时保持原有行为。
@@ -108,7 +110,7 @@ SideNav 继续负责当前项、长名称和 aria-current;Drawer 继续负责
108
110
  | DateRangePicker | 上述范围 + `value={start,end}`、`presets=[{label,value}]`、`compact?` | 两个原生日期输入,自定义范围、受控预设、清空;compact 合并为一体区间框;结束日期下界取 start/min 较晚者;反向范围显示错误,不静默调换 |
109
111
  | NumberField | `value: number\|null`、`onValueChange/min/max/step/precision/disabled/error` | 编辑中保存字符串;失焦/Enter 提交;无效及越界输入保留并报错;上下键/按钮步进与钳制边界;空字符串提交 null |
110
112
  | TreeSelect | `nodes/value/onValueChange/expandedKeys/onExpandedChange/multiple`、loading/error/disabled | 节点为 `{id,label,disabled?,children?}`;父子独立选择,默认单选;调用方提供搜索后的树,不做全量过滤 |
111
- | RemoteMultiSelect | `query/onQueryChange/value/onValueChange/options`、loading/error/disabled、`maxVisibleTags?` | value 是完整 `{value,label,disabled?}[]`,已选标签不依赖本次结果;不重复选中;清空保留禁选标签;maxVisibleTags 限制常显数量并提供展开/收起,展开区域有界滚动 |
113
+ | RemoteMultiSelect | `query/onQueryChange/value/onValueChange/options`、loading/error/disabled、`maxVisibleTags?` | value 是完整 `{value,label,disabled?}[]`,已选标签不依赖本次结果;再次点击已选候选取消;清空保留禁选标签;maxVisibleTags 限制常显数量并提供展开/收起,展开区域有界滚动 |
112
114
 
113
115
  日期 min/max 约束原生日历和表单有效性;手动编辑、业务校验及最终提交由宿主验证。原生日期面板位于浏览器 UI,不放在受裁剪的自定义浮层。日历外观、屏幕阅读器和移动 WebView 行为由具体浏览器决定,不声称提供自绘日历。预设必须使用宿主计算好的日期;不得以本机时区代替产品时区。没有“今天”隐式计算。
114
116
 
@@ -126,7 +128,7 @@ TreeSelect 使用 tree/treeitem/group,Tab 进入一个可见节点,上下/Ho
126
128
 
127
129
  首次进入优先已选可见节点;字符前缀定位不改变选择,`*` 展开同层分支,Ctrl/⌘+A 仅切换可见可选节点。外部收起时焦点回可见祖先。节点 `selectable=false` 用于结构分组;readOnly 允许浏览展开而禁止选择。`name` 为选中 key 生成同名 hidden inputs,disabled 不提交。
128
130
 
129
- RemoteMultiSelect 复用 Combobox 的方向键、Enter、Escape 和 IME 防误提交。加载或失败时禁止选择旧候选,已选项仍显示且可移除;默认选中后保持候选列表打开,`closeOnSelect=true` 恢复选完关闭。`clearQueryOnSelect` 默认 false;新增 readOnly/clearable/placeholder/hint/name。空查询 Backspace 删除最后一个可移除标签,标签删除/清空后回输入框;锁定标签保留。name 以同名 hidden inputs 提交受控 keys。单选 Combobox 的默认关闭行为不变。异步取消、序列检查、服务端查询和时区解释全部归宿主。展台有可取消的本地延迟结果演示,但不是生产数据层。
131
+ RemoteMultiSelect 复用 Combobox 的方向键、Enter、Escape 和 IME 防误提交。加载或失败时禁止选择旧候选,已选项仍显示且可移除;默认选中后保持候选列表打开,可再次点击或方向键+Enter取消已选候选;真正禁用/锁定项不可更改。打开多选候选时,输入框Tab进入“完成选择”,Shift+Tab返回输入框;从完成按钮继续Tab关闭候选并沿输入框后的正常控件前进。面板底部“完成选择”只收起候选并归焦输入框,保留查询和值,不提交宿主表单;最终保存由宿主负责。Escape或点击外部也保留草稿。`closeOnSelect=true` 恢复选完关闭。`clearQueryOnSelect` 默认 false;新增 readOnly/clearable/placeholder/hint/name。空查询 Backspace 删除最后一个可移除标签,标签×移除与清空统一归焦输入框并收起候选;输入框内空查询Backspace删除则保留原展开/收起状态,不因归焦改变候选状态。仅重新点击、输入或方向键才展开。没有可移除项(含0项或只有锁定项)时隐藏“清空选择”;锁定标签保留。name 以同名 hidden inputs 提交受控 keys。单选 Combobox 的默认关闭行为不变。异步取消、序列检查、服务端查询和时区解释全部归宿主。展台有可取消的本地延迟结果演示,但不是生产数据层。
130
132
 
131
133
  分组底色 `--rhine-field-group-bg`;树缩进 `--rhine-tree-indent`;其余输入、选择、错误和焦点继续用既有语义 tokens,字号不低于 12px。
132
134