@flamingo-stack/openframe-frontend-core 0.0.489 → 0.0.490-snapshot.20260728181420

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 (190) hide show
  1. package/dist/{chunk-IN4IO7BL.cjs → chunk-2RTNC3KP.cjs} +1212 -970
  2. package/dist/chunk-2RTNC3KP.cjs.map +1 -0
  3. package/dist/{chunk-5VSEMWWE.js → chunk-2YQK6LHR.js} +5 -5
  4. package/dist/{chunk-W6ZUOWSB.js → chunk-35CTCUJ7.js} +6 -6
  5. package/dist/{chunk-ZIAS5NNL.cjs → chunk-6O3YTVLG.cjs} +12 -12
  6. package/dist/{chunk-ZIAS5NNL.cjs.map → chunk-6O3YTVLG.cjs.map} +1 -1
  7. package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
  8. package/dist/{chunk-X2YF5TJS.js → chunk-6XNWT2KC.js} +3 -3
  9. package/dist/{chunk-VLOE7MI2.cjs → chunk-7NRRONK5.cjs} +32 -32
  10. package/dist/{chunk-VLOE7MI2.cjs.map → chunk-7NRRONK5.cjs.map} +1 -1
  11. package/dist/{chunk-YMJZOANX.js → chunk-CYIUPI7J.js} +6298 -6056
  12. package/dist/chunk-CYIUPI7J.js.map +1 -0
  13. package/dist/{chunk-FKPXFTZT.js → chunk-CZTKQVIQ.js} +3 -3
  14. package/dist/{chunk-5YMY4BAW.cjs → chunk-EHHX22JL.cjs} +93 -93
  15. package/dist/{chunk-5YMY4BAW.cjs.map → chunk-EHHX22JL.cjs.map} +1 -1
  16. package/dist/{chunk-LLEDO4P7.cjs → chunk-GL33BHGZ.cjs} +73 -73
  17. package/dist/{chunk-LLEDO4P7.cjs.map → chunk-GL33BHGZ.cjs.map} +1 -1
  18. package/dist/{chunk-N4MTNTSX.js → chunk-GTMW4KY7.js} +6 -6
  19. package/dist/{chunk-H3YRJ3LZ.cjs → chunk-HRMOCJKU.cjs} +12 -12
  20. package/dist/{chunk-H3YRJ3LZ.cjs.map → chunk-HRMOCJKU.cjs.map} +1 -1
  21. package/dist/{chunk-3OIB763B.cjs → chunk-ITV6LSG7.cjs} +19 -19
  22. package/dist/{chunk-3OIB763B.cjs.map → chunk-ITV6LSG7.cjs.map} +1 -1
  23. package/dist/{chunk-EU6QNO4S.js → chunk-IUGJAILN.js} +3 -3
  24. package/dist/{chunk-J7H32E3R.cjs → chunk-J2V4B6FF.cjs} +18 -18
  25. package/dist/{chunk-J7H32E3R.cjs.map → chunk-J2V4B6FF.cjs.map} +1 -1
  26. package/dist/{chunk-RO7Q56ZK.cjs → chunk-PVSXTCJY.cjs} +40 -40
  27. package/dist/{chunk-RO7Q56ZK.cjs.map → chunk-PVSXTCJY.cjs.map} +1 -1
  28. package/dist/{chunk-B4YR342L.js → chunk-QF54RPHZ.js} +6 -6
  29. package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
  30. package/dist/chunk-QKWOBV55.js.map +1 -0
  31. package/dist/{chunk-Z7Y63IFU.cjs → chunk-QNFAZIFC.cjs} +5 -5
  32. package/dist/{chunk-Z7Y63IFU.cjs.map → chunk-QNFAZIFC.cjs.map} +1 -1
  33. package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
  34. package/dist/chunk-SGL6URIJ.cjs.map +1 -0
  35. package/dist/{chunk-TWGYBXYV.js → chunk-T4BUS6JV.js} +3 -3
  36. package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
  37. package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
  38. package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
  39. package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
  40. package/dist/{chunk-BGWDDB7Y.js → chunk-U2JIGUXF.js} +4 -4
  41. package/dist/{chunk-2RNJYJWO.cjs → chunk-VD4QDSP7.cjs} +40 -40
  42. package/dist/{chunk-2RNJYJWO.cjs.map → chunk-VD4QDSP7.cjs.map} +1 -1
  43. package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
  44. package/dist/chunk-WEUNPE3S.js.map +1 -0
  45. package/dist/{chunk-2IHN7NNB.cjs → chunk-YKE2KZ2Y.cjs} +33 -33
  46. package/dist/{chunk-2IHN7NNB.cjs.map → chunk-YKE2KZ2Y.cjs.map} +1 -1
  47. package/dist/{chunk-VL4LUO32.js → chunk-ZDUJ6BDI.js} +4 -4
  48. package/dist/{chunk-K7MNWYYI.js → chunk-ZEC5U5T7.js} +2 -2
  49. package/dist/components/case-studies/index.cjs +10 -10
  50. package/dist/components/case-studies/index.js +4 -4
  51. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  52. package/dist/components/chat/guide-display.d.ts +14 -0
  53. package/dist/components/chat/guide-display.d.ts.map +1 -0
  54. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  55. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  56. package/dist/components/chat/index.cjs +6 -4
  57. package/dist/components/chat/index.cjs.map +1 -1
  58. package/dist/components/chat/index.d.ts +1 -0
  59. package/dist/components/chat/index.d.ts.map +1 -1
  60. package/dist/components/chat/index.js +5 -3
  61. package/dist/components/chat/types/component.types.d.ts +6 -0
  62. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  63. package/dist/components/chat/types/message.types.d.ts +15 -2
  64. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  65. package/dist/components/chat/types/processing.types.d.ts +3 -0
  66. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  67. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
  68. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -0
  69. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  70. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  71. package/dist/components/contact/index.cjs +5 -5
  72. package/dist/components/contact/index.js +4 -4
  73. package/dist/components/docs/index.cjs +7 -7
  74. package/dist/components/docs/index.js +6 -6
  75. package/dist/components/embeds/index.cjs +5 -5
  76. package/dist/components/embeds/index.js +4 -4
  77. package/dist/components/faq/index.cjs +6 -6
  78. package/dist/components/faq/index.js +5 -5
  79. package/dist/components/features/index.cjs +4 -4
  80. package/dist/components/features/index.js +3 -3
  81. package/dist/components/features/select-button.d.ts.map +1 -1
  82. package/dist/components/help-center-pages/index.cjs +23 -23
  83. package/dist/components/help-center-pages/index.js +14 -14
  84. package/dist/components/index.cjs +163 -161
  85. package/dist/components/index.cjs.map +1 -1
  86. package/dist/components/index.js +14 -12
  87. package/dist/components/index.js.map +1 -1
  88. package/dist/components/layout/page-layout.d.ts +7 -1
  89. package/dist/components/layout/page-layout.d.ts.map +1 -1
  90. package/dist/components/layout/title-block.d.ts +23 -1
  91. package/dist/components/layout/title-block.d.ts.map +1 -1
  92. package/dist/components/navigation/index.cjs +4 -4
  93. package/dist/components/navigation/index.js +3 -3
  94. package/dist/components/onboarding-guides/index.cjs +8 -8
  95. package/dist/components/onboarding-guides/index.js +7 -7
  96. package/dist/components/related-content/index.cjs +6 -6
  97. package/dist/components/related-content/index.js +5 -5
  98. package/dist/components/tickets/index.cjs +7 -7
  99. package/dist/components/tickets/index.js +6 -6
  100. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  101. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  102. package/dist/components/ui/date-picker.d.ts.map +1 -1
  103. package/dist/components/ui/field-wrapper.d.ts +10 -1
  104. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  105. package/dist/components/ui/file-manager/index.cjs +50 -50
  106. package/dist/components/ui/file-manager/index.js +1 -1
  107. package/dist/components/ui/floating-tooltip.d.ts +8 -1
  108. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  109. package/dist/components/ui/index.cjs +6 -4
  110. package/dist/components/ui/index.cjs.map +1 -1
  111. package/dist/components/ui/index.js +5 -3
  112. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  113. package/dist/components/ui/input.d.ts.map +1 -1
  114. package/dist/components/ui/page-actions.d.ts +7 -1
  115. package/dist/components/ui/page-actions.d.ts.map +1 -1
  116. package/dist/components/ui/radio-group.d.ts.map +1 -1
  117. package/dist/components/ui/search-input.d.ts.map +1 -1
  118. package/dist/components/ui/select.d.ts.map +1 -1
  119. package/dist/components/ui/tab-navigation.d.ts +10 -1
  120. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  121. package/dist/components/ui/tag-search-input.d.ts.map +1 -1
  122. package/dist/components/ui/textarea.d.ts.map +1 -1
  123. package/dist/components/ui/truncate-text.d.ts +2 -2
  124. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  125. package/dist/hooks/index.cjs +4 -2
  126. package/dist/hooks/index.cjs.map +1 -1
  127. package/dist/hooks/index.js +3 -1
  128. package/dist/hooks/ui/index.d.ts +1 -0
  129. package/dist/hooks/ui/index.d.ts.map +1 -1
  130. package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
  131. package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
  132. package/dist/index.cjs +8 -4
  133. package/dist/index.cjs.map +1 -1
  134. package/dist/index.js +7 -3
  135. package/dist/utils/index.cjs.map +1 -1
  136. package/dist/utils/index.js.map +1 -1
  137. package/package.json +1 -1
  138. package/src/components/chat/chat-message-enhanced.tsx +63 -52
  139. package/src/components/chat/guide-display.tsx +44 -0
  140. package/src/components/chat/hooks/use-nats-chat-adapter.ts +2 -0
  141. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +10 -0
  142. package/src/components/chat/index.ts +1 -0
  143. package/src/components/chat/types/component.types.ts +9 -0
  144. package/src/components/chat/types/message.types.ts +17 -1
  145. package/src/components/chat/types/processing.types.ts +1 -0
  146. package/src/components/chat/utils/chunk-parser.ts +6 -0
  147. package/src/components/chat/utils/message-segment-accumulator.ts +19 -0
  148. package/src/components/chat/utils/process-historical-messages.ts +6 -0
  149. package/src/components/features/select-button.tsx +4 -1
  150. package/src/components/layout/page-layout.tsx +16 -0
  151. package/src/components/layout/title-block.tsx +106 -7
  152. package/src/components/ui/.field-wrapper.md +5 -4
  153. package/src/components/ui/.tab-navigation.md +14 -1
  154. package/src/components/ui/autocomplete.tsx +13 -1
  155. package/src/components/ui/button/button.tsx +1 -1
  156. package/src/components/ui/checkbox-block.tsx +27 -3
  157. package/src/components/ui/date-picker.tsx +15 -1
  158. package/src/components/ui/field-wrapper.tsx +12 -6
  159. package/src/components/ui/floating-tooltip.tsx +9 -1
  160. package/src/components/ui/input-trigger.tsx +8 -1
  161. package/src/components/ui/input.tsx +8 -1
  162. package/src/components/ui/page-actions.tsx +142 -30
  163. package/src/components/ui/radio-group.tsx +28 -3
  164. package/src/components/ui/search-input.tsx +2 -1
  165. package/src/components/ui/select.tsx +7 -0
  166. package/src/components/ui/tab-navigation.tsx +26 -4
  167. package/src/components/ui/tag-search-input.tsx +9 -2
  168. package/src/components/ui/textarea.tsx +8 -1
  169. package/src/components/ui/truncate-text.tsx +3 -18
  170. package/src/hooks/ui/index.ts +1 -0
  171. package/src/hooks/ui/use-is-truncated.ts +66 -0
  172. package/src/stories/ChatMessageEnhanced.stories.tsx +48 -1
  173. package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
  174. package/dist/chunk-GZK7FWJJ.js.map +0 -1
  175. package/dist/chunk-IN4IO7BL.cjs.map +0 -1
  176. package/dist/chunk-PB3BTZDU.js.map +0 -1
  177. package/dist/chunk-STYA77PH.cjs.map +0 -1
  178. package/dist/chunk-YMJZOANX.js.map +0 -1
  179. /package/dist/{chunk-5VSEMWWE.js.map → chunk-2YQK6LHR.js.map} +0 -0
  180. /package/dist/{chunk-W6ZUOWSB.js.map → chunk-35CTCUJ7.js.map} +0 -0
  181. /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
  182. /package/dist/{chunk-X2YF5TJS.js.map → chunk-6XNWT2KC.js.map} +0 -0
  183. /package/dist/{chunk-FKPXFTZT.js.map → chunk-CZTKQVIQ.js.map} +0 -0
  184. /package/dist/{chunk-N4MTNTSX.js.map → chunk-GTMW4KY7.js.map} +0 -0
  185. /package/dist/{chunk-EU6QNO4S.js.map → chunk-IUGJAILN.js.map} +0 -0
  186. /package/dist/{chunk-B4YR342L.js.map → chunk-QF54RPHZ.js.map} +0 -0
  187. /package/dist/{chunk-TWGYBXYV.js.map → chunk-T4BUS6JV.js.map} +0 -0
  188. /package/dist/{chunk-BGWDDB7Y.js.map → chunk-U2JIGUXF.js.map} +0 -0
  189. /package/dist/{chunk-VL4LUO32.js.map → chunk-ZDUJ6BDI.js.map} +0 -0
  190. /package/dist/{chunk-K7MNWYYI.js.map → chunk-ZEC5U5T7.js.map} +0 -0
@@ -81,6 +81,10 @@ const RadioGroupBlock = React.forwardRef<
81
81
  <div className={cn("relative flex w-full flex-col", className)}>
82
82
  <RadioGroupPrimitive.Root
83
83
  ref={ref}
84
+ // Same marker every field exposes — it is how a form finds (and
85
+ // scrolls to) the first control that failed validation. The GROUP is
86
+ // the field here; the individual options are not separately invalid.
87
+ data-invalid={error ? true : undefined}
84
88
  className={cn(
85
89
  "w-full",
86
90
  isGrouped
@@ -106,6 +110,10 @@ const RadioGroupBlock = React.forwardRef<
106
110
  "flex w-full items-center gap-3",
107
111
  "transition-colors duration-200",
108
112
  isDisabled ? "cursor-not-allowed" : "cursor-pointer",
113
+ // Hover / active move the BACKGROUND only, like Input and
114
+ // CheckboxBlock — the border stays put so an unselected row
115
+ // never flashes "selected" under the cursor.
116
+ !isDisabled && "hover:bg-ods-bg-hover active:bg-ods-bg-active",
109
117
  isGrouped
110
118
  ? cn(
111
119
  "bg-ods-card px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs,8px)]",
@@ -114,12 +122,17 @@ const RadioGroupBlock = React.forwardRef<
114
122
  )
115
123
  : cn(
116
124
  "rounded-[6px] border bg-ods-card p-[var(--spacing-system-sf)]",
117
- !isDisabled && "hover:border-ods-accent/30",
118
125
  !isDisabled &&
119
126
  "has-[[data-state=checked]]:bg-[var(--ods-system-greys-soft-grey)]/30 has-[[data-state=checked]]:border-ods-accent/40",
120
127
  error ? "border-ods-error" : "border-ods-border"
121
128
  ),
122
- isDisabled && "opacity-50",
129
+ // Disabled matches a disabled field: flat `ods-bg` fill and
130
+ // greyed text, not a fade of the whole row. The greying lives
131
+ // on the TEXT COLUMN below, not here: as a descendant rule on
132
+ // the row it also repainted whatever `trailing` renders — a
133
+ // `Tag`'s label is a span too, and it went invisible against
134
+ // its own fill.
135
+ isDisabled && "bg-ods-bg",
123
136
  itemClassName
124
137
  )}
125
138
  >
@@ -132,7 +145,10 @@ const RadioGroupBlock = React.forwardRef<
132
145
  error ? "border-ods-error" : "border-[var(--color-border-strong)]",
133
146
  "transition-colors duration-150",
134
147
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-card",
148
+ // Disabled: the EMPTY dot dims with the text; a selected one
149
+ // keeps its accent so "on but locked" stays readable.
135
150
  "disabled:cursor-not-allowed",
151
+ "disabled:data-[state=unchecked]:bg-ods-bg disabled:data-[state=unchecked]:border-ods-border",
136
152
  "data-[state=checked]:border-ods-accent"
137
153
  )}
138
154
  >
@@ -140,7 +156,16 @@ const RadioGroupBlock = React.forwardRef<
140
156
  <span className="block h-1/2 w-1/2 rounded-full bg-ods-accent" />
141
157
  </RadioGroupPrimitive.Indicator>
142
158
  </RadioGroupPrimitive.Item>
143
- <div className="flex min-w-0 flex-1 flex-col justify-center">
159
+ <div
160
+ className={cn(
161
+ "flex min-w-0 flex-1 flex-col justify-center",
162
+ // Direct children only — these are the two component-owned
163
+ // wrappers. `option.label` / `option.description` are
164
+ // ReactNode, so a descendant rule would repaint content the
165
+ // caller styled itself.
166
+ isDisabled && "[&>span]:text-ods-text-disabled",
167
+ )}
168
+ >
144
169
  <span
145
170
  className={cn(
146
171
  "font-[family-name:var(--font-h4-family)] font-[number:var(--font-h4-weight)] text-[length:var(--font-size-h4-body)] leading-[24px]",
@@ -106,7 +106,8 @@ const innerInputStyles = cn(
106
106
  "flex-1 min-w-[60px] bg-transparent border-none outline-none",
107
107
  "text-h4",
108
108
  "text-ods-text-primary placeholder:text-ods-text-secondary",
109
- "disabled:cursor-not-allowed",
109
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
110
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
110
111
  "touch-manipulation"
111
112
  )
112
113
 
@@ -42,7 +42,14 @@ const SelectTrigger = React.forwardRef<
42
42
  "enabled:hover:bg-ods-bg-hover enabled:hover:border-ods-border-hover enabled:active:bg-ods-bg-active enabled:active:border-ods-border-active",
43
43
  !isInvalid && "data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent",
44
44
  "group",
45
+ // Disabled - match Input exactly: value greys out, placeholder dims
46
+ // further. The `data-[placeholder]` rule above is a class+attribute
47
+ // selector, so the disabled placeholder rule stacks `:disabled` on top
48
+ // of it to win on specificity rather than on source order.
45
49
  "disabled:!cursor-not-allowed disabled:bg-ods-bg",
50
+ "disabled:text-ods-text-disabled disabled:data-[placeholder]:text-ods-border",
51
+ // The chevron sets its own colour — grey it with the value.
52
+ "disabled:[&_svg]:text-ods-text-disabled",
46
53
  "transition-colors duration-200 cursor-pointer",
47
54
  "[&>span]:line-clamp-1",
48
55
  isInvalid && "border-ods-error enabled:hover:border-ods-error enabled:active:border-ods-error data-[state=open]:border-ods-error",
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
3
+ import React, { useState, useEffect, useDeferredValue, useMemo, useRef, useCallback } from 'react'
4
4
  import { useSearchParams, useRouter, usePathname } from '../../embed-shims/next-navigation'
5
5
  import { cn } from '../../utils/cn'
6
6
 
@@ -38,8 +38,14 @@ interface TabNavigationProps {
38
38
  urlSync?: boolean | TabNavigationUrlSyncOptions
39
39
  defaultTab?: string // Fallback when no valid tab in URL or initial value
40
40
 
41
- // Render prop to provide active tab to children
42
- children?: (activeTab: string) => React.ReactNode
41
+ /**
42
+ * Render prop for the tab BODY. Receives the tab to render and, additively,
43
+ * a `isStale` flag — true while the bar has already moved to a newly clicked
44
+ * tab but the body is still showing the PREVIOUS one (see `deferredActiveTab`
45
+ * below). Use it to dim the body or show a pending hint; without it a slow
46
+ * tab looks like a tab that simply did nothing.
47
+ */
48
+ children?: (activeTab: string, state: { isStale: boolean }) => React.ReactNode
43
49
  }
44
50
 
45
51
  export function TabNavigation({
@@ -119,6 +125,22 @@ export function TabNavigation({
119
125
  }
120
126
  }
121
127
 
128
+ // The tab BAR follows the click immediately; the tab BODY is deferred, which
129
+ // makes React treat swapping it as a transition. That is the whole fix for the
130
+ // flash: a tab whose data is not in the client cache yet SUSPENDS the moment it
131
+ // mounts, and outside a transition React has to show that Suspense fallback —
132
+ // so the skeleton appears for a frame before the real content lands. Inside a
133
+ // transition React keeps the previous tab on screen and swaps only once the new
134
+ // one is ready. Deferring the body rather than the whole state keeps the
135
+ // underline responsive, so the click still feels instant.
136
+ //
137
+ // The cost of that trade is that a SLOW tab leaves the previous tab's content
138
+ // under the new tab's underline with nothing saying so — worse than a skeleton,
139
+ // because stale data reads as fresh data. `isStale` is handed to the render prop
140
+ // so the body can mark itself while it waits.
141
+ const deferredActiveTab = useDeferredValue(activeTab)
142
+ const isStale = activeTab !== deferredActiveTab
143
+
122
144
  const scrollRef = useRef<HTMLDivElement>(null)
123
145
  const activeTabRef = useRef<HTMLButtonElement>(null)
124
146
  const isFirstActiveScrollRef = useRef(true)
@@ -291,7 +313,7 @@ export function TabNavigation({
291
313
  </div>
292
314
 
293
315
  {/* Render children with active tab if provided */}
294
- {children && children(activeTab)}
316
+ {children && children(deferredActiveTab, { isStale })}
295
317
  </>
296
318
  )
297
319
  }
@@ -140,7 +140,13 @@ export function TagSearchInput<T = string>({
140
140
  "has-[:focus]:border-ods-accent",
141
141
  !disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover",
142
142
  disabled && "bg-ods-bg",
143
- disabled && "cursor-not-allowed opacity-50",
143
+ disabled && "cursor-not-allowed",
144
+ // Match Input: grey the text and the icons, don't fade the whole
145
+ // control (a blanket opacity also washes out the border). Icons only —
146
+ // this container has no adornment spans of its own, so a `[&_span]`
147
+ // rule would ONLY have reached the selected `Tag` chips, whose label is
148
+ // a span, and greyed them into their own fill in the light theme.
149
+ "has-[:disabled]:[&_svg]:text-ods-text-disabled",
144
150
  className,
145
151
  )}
146
152
  onClick={() => {
@@ -199,7 +205,8 @@ export function TagSearchInput<T = string>({
199
205
  "flex-1 min-w-[60px] bg-transparent border-none outline-none",
200
206
  "text-h4",
201
207
  "text-ods-text-primary placeholder:text-ods-text-secondary",
202
- "disabled:cursor-not-allowed",
208
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
209
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
203
210
  )}
204
211
  />
205
212
  </div>
@@ -119,6 +119,12 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
119
119
  !hideBorder && cn("rounded-[6px] border bg-ods-card border-ods-border", accentBorder),
120
120
  !hideBorder && !props.disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
121
121
  !hideBorder && props.disabled && "!cursor-not-allowed bg-ods-bg",
122
+ // Start / end icons carry their own colour — grey them with the value
123
+ // so a disabled field reads as one flat colour. Scoped to the DIRECT
124
+ // span children (the two icon wrappers): the nodes inside them are
125
+ // rendered verbatim so callers can pass an interactive trigger and own
126
+ // its styling, and a descendant rule would overwrite exactly that.
127
+ "has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled",
122
128
  !hideBorder && isInvalid && cn("border-ods-error hover:border-ods-error", accentBorderError),
123
129
  hideBorder && "bg-transparent",
124
130
  )}
@@ -150,7 +156,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
150
156
  // internally instead of pushing the icon out of frame.
151
157
  "max-h-[160px] overflow-y-auto",
152
158
  "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
153
- "disabled:cursor-not-allowed disabled:placeholder:text-ods-border",
159
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
160
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
154
161
  className,
155
162
  )}
156
163
  {...props}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import React, { type ReactNode, useEffect, useRef, useState } from 'react'
3
+ import { type ReactNode } from 'react'
4
+ import { useIsTruncated } from '../../hooks/ui/use-is-truncated'
4
5
  import { cn } from '../../utils/cn'
5
6
  import { FloatingTooltip } from './floating-tooltip'
6
7
 
@@ -69,24 +70,8 @@ export function TruncateText({
69
70
  tone = 'primary',
70
71
  mono = false,
71
72
  }: TruncateTextProps) {
72
- const ref = useRef<HTMLSpanElement>(null)
73
- const [isTruncated, setIsTruncated] = useState(false)
74
73
  const isMultiLine = lines > 1
75
-
76
- useEffect(() => {
77
- const el = ref.current
78
- if (!el) return
79
- const check = () => {
80
- const overflows = isMultiLine
81
- ? el.scrollHeight > el.clientHeight + 1
82
- : el.scrollWidth > el.clientWidth + 1
83
- setIsTruncated(overflows)
84
- }
85
- check()
86
- const ro = new ResizeObserver(check)
87
- ro.observe(el)
88
- return () => ro.disconnect()
89
- }, [children, isMultiLine])
74
+ const { ref, isTruncated } = useIsTruncated<HTMLSpanElement>(children, { multiline: isMultiLine })
90
75
 
91
76
  const clampClass = isMultiLine
92
77
  ? LINE_CLAMP_CLASS[lines as Exclude<typeof lines, 1>]
@@ -6,6 +6,7 @@ export * from './use-focus-trap'
6
6
  export * from './use-header-height'
7
7
  export * from './use-horizontal-scrollbar'
8
8
  export * from './use-image-edge-color'
9
+ export * from './use-is-truncated'
9
10
  export * from './use-local-storage'
10
11
  export * from './use-marquee-engine'
11
12
  export * from './use-media-query'
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ export interface UseIsTruncatedOptions {
6
+ /** Measure vertical clipping (`line-clamp-N`) instead of the single-line ellipsis. */
7
+ multiline?: boolean
8
+ }
9
+
10
+ /**
11
+ * Tracks whether an element's text is ACTUALLY clipped — the condition for
12
+ * offering a "full value" tooltip. Text that fits needs no tooltip, and showing
13
+ * one anyway just repeats what the user is already reading.
14
+ *
15
+ * Three things can change the answer, and all three are watched: the box
16
+ * resizing (`ResizeObserver`), the text itself changing, and the measured
17
+ * ELEMENT changing. None of them implies the others — new text inside a
18
+ * width-constrained box leaves the box exactly as it was, so the observer never
19
+ * fires; and a caller that swaps between render branches (adornment vs plain)
20
+ * mounts a different node under the same text, which a `useRef` would leave
21
+ * measured against the old, detached node. Pass the rendered value as `value`.
22
+ *
23
+ * `ref` is a CALLBACK ref, not a `RefObject` — that is what makes the element
24
+ * itself a dependency. Attach it exactly like a normal ref; there is no
25
+ * `.current` to read.
26
+ *
27
+ * ```tsx
28
+ * const { ref, isTruncated } = useIsTruncated<HTMLHeadingElement>(title)
29
+ * <FloatingTooltip content={title} disabled={!isTruncated}>
30
+ * <h1 ref={ref} className="truncate">{title}</h1>
31
+ * </FloatingTooltip>
32
+ * ```
33
+ */
34
+ export function useIsTruncated<T extends HTMLElement>(value: unknown, options?: UseIsTruncatedOptions) {
35
+ const multiline = options?.multiline ?? false
36
+ const [element, setElement] = useState<T | null>(null)
37
+ const [isTruncated, setIsTruncated] = useState(false)
38
+
39
+ // Wrapped so the identity stays stable across renders: passing the raw
40
+ // `useState` setter as a ref would be stable too, but this keeps the public
41
+ // shape a plain `RefCallback<T>` instead of a state dispatcher.
42
+ const ref = useCallback((node: T | null) => setElement(node), [])
43
+
44
+ useEffect(() => {
45
+ if (!element) {
46
+ setIsTruncated(false)
47
+ return
48
+ }
49
+ // The 1px tolerance absorbs sub-pixel layout rounding, which otherwise
50
+ // reports a permanent fractional "overflow" at some zoom levels / DPRs and
51
+ // leaves a tooltip armed on text that is fully visible.
52
+ const check = () => {
53
+ setIsTruncated(
54
+ multiline
55
+ ? element.scrollHeight > element.clientHeight + 1
56
+ : element.scrollWidth > element.clientWidth + 1,
57
+ )
58
+ }
59
+ check()
60
+ const observer = new ResizeObserver(check)
61
+ observer.observe(element)
62
+ return () => observer.disconnect()
63
+ }, [element, value, multiline])
64
+
65
+ return { ref, isTruncated }
66
+ }
@@ -183,7 +183,50 @@ export const PendingApprovalSegment: Story = {
183
183
  },
184
184
  };
185
185
 
186
- /** Every remaining segment type in one message — thinking, tool execution, context compaction, error. */
186
+ /**
187
+ * `[card://]` markers inside a `guide` segment resolve exactly as they do in a
188
+ * text segment — the host's `renderEntityCard` node is hoisted out of the
189
+ * markdown as a sibling block, and an unknown id degrades to the bare id.
190
+ * Regression guard: the guide body used to skip the marker plan entirely.
191
+ */
192
+ export const GuideWithEntityCards: Story = {
193
+ args: {
194
+ role: "assistant",
195
+ name: "Mingo",
196
+ assistantType: "mingo",
197
+ timestamp: at247(),
198
+ chatRefs: {
199
+ "onboarding_guide:first-check": {
200
+ type: "onboarding_guide",
201
+ id: "first-check",
202
+ title: "Create Your First Monitoring Check",
203
+ url: "/help-center/onboarding-guides/first-check",
204
+ },
205
+ "onboarding_guide:add-devices": {
206
+ type: "onboarding_guide",
207
+ id: "add-devices",
208
+ title: "Add Devices to a Policy",
209
+ url: "/help-center/onboarding-guides/add-devices",
210
+ },
211
+ },
212
+ renderEntityCard: (ref) =>
213
+ ref.type === "onboarding_guide" ? (
214
+ <div className="flex items-center gap-[var(--spacing-system-s)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-s)]">
215
+ <span className="flex-1 text-h4 text-ods-text-primary">{ref.title}</span>
216
+ <span className="text-h5 text-ods-text-secondary">Guide</span>
217
+ </div>
218
+ ) : null,
219
+ content: [
220
+ {
221
+ type: "guide",
222
+ text: "Here's how to create a monitoring policy (check) in OpenFrame:\n\n[card://onboarding_guide:first-check]\n\nStep-by-step:\n\n1. Go to Monitoring → Policies → Add Policy\n2. Write your osquery SQL query\n3. Save the policy",
223
+ },
224
+ { type: "text", text: "A plain text segment renders its own marker identically:\n\n[card://onboarding_guide:add-devices]" },
225
+ ] satisfies MessageSegment[],
226
+ },
227
+ };
228
+
229
+ /** Every remaining segment type in one message — thinking, guide, tool execution, context compaction, error. */
187
230
  export const AllSegmentTypes: Story = {
188
231
  args: {
189
232
  role: "assistant",
@@ -195,6 +238,10 @@ export const AllSegmentTypes: Story = {
195
238
  text: "The user wants Slack installed. Homebrew is present on the endpoint, so `brew install --cask slack` is the cleanest path.",
196
239
  },
197
240
  { type: "text", text: "Installing Slack now." },
241
+ {
242
+ type: "guide",
243
+ text: "Here's how to create a monitoring policy (check) in OpenFrame:\n\nStep-by-step:\n\n1. Go to Monitoring → Policies → Add Policy\n2. Give it a Name (something descriptive)\n3. Write your osquery SQL query — the query should return rows for devices that fail the check\n4. Set the interval — how often each device runs the check\n5. Save the policy",
244
+ },
198
245
  {
199
246
  type: "tool_execution",
200
247
  data: {