@qoretechnologies/reqraft 0.10.65 → 0.10.66-pr.145.g124fc92

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 (161) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +130 -25
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  5. package/dist/components/form/engine/CompactToolbar.js +78 -15
  6. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  7. package/dist/components/form/engine/FormEngine.d.ts +48 -1
  8. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  9. package/dist/components/form/engine/FormEngine.js +260 -99
  10. package/dist/components/form/engine/FormEngine.js.map +1 -1
  11. package/dist/components/form/engine/OptionFieldMessages.d.ts +18 -2
  12. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  13. package/dist/components/form/engine/OptionFieldMessages.js +22 -3
  14. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  15. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  16. package/dist/components/form/engine/_structuredData/SchemaDataView.js +27 -1
  17. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  18. package/dist/components/form/engine/compactRowContext.d.ts +5 -0
  19. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  20. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  21. package/dist/components/form/engine/compactRowStyles.d.ts +12 -0
  22. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  23. package/dist/components/form/engine/compactRowStyles.js +72 -39
  24. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  25. package/dist/components/form/engine/compactToolbarContext.d.ts +3 -0
  26. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  28. package/dist/components/form/engine/formAppearance.d.ts +48 -0
  29. package/dist/components/form/engine/formAppearance.d.ts.map +1 -0
  30. package/dist/components/form/engine/formAppearance.js +50 -0
  31. package/dist/components/form/engine/formAppearance.js.map +1 -0
  32. package/dist/components/form/engine/readFirst.d.ts +14 -2
  33. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  34. package/dist/components/form/engine/readFirst.js +6 -2
  35. package/dist/components/form/engine/readFirst.js.map +1 -1
  36. package/dist/components/form/engine/rowFocus.d.ts +9 -1
  37. package/dist/components/form/engine/rowFocus.d.ts.map +1 -1
  38. package/dist/components/form/engine/rowFocus.js +17 -5
  39. package/dist/components/form/engine/rowFocus.js.map +1 -1
  40. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  41. package/dist/components/form/expressions/renderExpressionToText.js +21 -0
  42. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  43. package/dist/components/form/fields/ChoiceGroupHeading.d.ts +21 -0
  44. package/dist/components/form/fields/ChoiceGroupHeading.d.ts.map +1 -0
  45. package/dist/components/form/fields/ChoiceGroupHeading.js +33 -0
  46. package/dist/components/form/fields/ChoiceGroupHeading.js.map +1 -0
  47. package/dist/components/form/fields/Field.d.ts.map +1 -1
  48. package/dist/components/form/fields/Field.js +13 -11
  49. package/dist/components/form/fields/Field.js.map +1 -1
  50. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  51. package/dist/components/form/fields/allowed-values/AllowedValues.js +20 -7
  52. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  53. package/dist/components/form/fields/allowed-values/savedValues.d.ts +30 -0
  54. package/dist/components/form/fields/allowed-values/savedValues.d.ts.map +1 -0
  55. package/dist/components/form/fields/allowed-values/savedValues.js +18 -0
  56. package/dist/components/form/fields/allowed-values/savedValues.js.map +1 -0
  57. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  58. package/dist/components/form/fields/auto/AutoFormField.js +18 -8
  59. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  60. package/dist/components/form/fields/long-string/LongString.d.ts +7 -1
  61. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  62. package/dist/components/form/fields/long-string/LongString.js +9 -5
  63. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  64. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  65. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +6 -5
  66. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  67. package/dist/components/form/fields/number/Number.d.ts +4 -1
  68. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  69. package/dist/components/form/fields/number/Number.js +13 -5
  70. package/dist/components/form/fields/number/Number.js.map +1 -1
  71. package/dist/components/form/fields/radio-group/RadioGroup.d.ts.map +1 -1
  72. package/dist/components/form/fields/radio-group/RadioGroup.js +13 -7
  73. package/dist/components/form/fields/radio-group/RadioGroup.js.map +1 -1
  74. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  75. package/dist/components/form/fields/template/TemplateField.js +71 -42
  76. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  77. package/dist/components/form/index.d.ts +2 -0
  78. package/dist/components/form/index.d.ts.map +1 -1
  79. package/dist/components/form/index.js +2 -0
  80. package/dist/components/form/index.js.map +1 -1
  81. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -1
  82. package/dist/components/imageLightbox/ImageLightbox.js +2 -0
  83. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -1
  84. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +3 -2
  85. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  86. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  87. package/dist/components/supportTicket/ReferencePicker.d.ts +2 -1
  88. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  89. package/dist/components/supportTicket/ReferencePicker.js +19 -13
  90. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  91. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -1
  92. package/dist/components/supportTicket/TicketHeader.js +4 -1
  93. package/dist/components/supportTicket/TicketHeader.js.map +1 -1
  94. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -1
  95. package/dist/components/supportTicket/TicketReplyBox.js +29 -10
  96. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -1
  97. package/dist/components/supportTicket/useLoadedImages.d.ts +10 -0
  98. package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -1
  99. package/dist/components/supportTicket/useLoadedImages.js +63 -1
  100. package/dist/components/supportTicket/useLoadedImages.js.map +1 -1
  101. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  102. package/dist/components/ticketReferences/TicketReferences.js +6 -2
  103. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  104. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  105. package/dist/components/ticketThread/TicketThread.js +10 -16
  106. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  107. package/dist/helpers/allowedValues.d.ts +12 -0
  108. package/dist/helpers/allowedValues.d.ts.map +1 -1
  109. package/dist/helpers/allowedValues.js +20 -1
  110. package/dist/helpers/allowedValues.js.map +1 -1
  111. package/dist/helpers/fieldDescription.d.ts +16 -0
  112. package/dist/helpers/fieldDescription.d.ts.map +1 -0
  113. package/dist/helpers/fieldDescription.js +40 -0
  114. package/dist/helpers/fieldDescription.js.map +1 -0
  115. package/dist/helpers/singleLineString.d.ts.map +1 -1
  116. package/dist/helpers/singleLineString.js +1 -0
  117. package/dist/helpers/singleLineString.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/components/form/engine/CompactRow.tsx +143 -23
  120. package/src/components/form/engine/CompactToolbar.tsx +85 -16
  121. package/src/components/form/engine/FormEngine.stories.tsx +25 -13
  122. package/src/components/form/engine/FormEngine.tsx +367 -81
  123. package/src/components/form/engine/FormEngineAppearance.stories.tsx +540 -0
  124. package/src/components/form/engine/FormEngineStickyHeaders.stories.tsx +5 -1
  125. package/src/components/form/engine/OptionFieldMessages.tsx +73 -2
  126. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +17 -4
  127. package/src/components/form/engine/compactRowContext.ts +5 -0
  128. package/src/components/form/engine/compactRowStyles.ts +218 -38
  129. package/src/components/form/engine/compactToolbarContext.ts +3 -0
  130. package/src/components/form/engine/formAppearance.ts +78 -0
  131. package/src/components/form/engine/readFirst.ts +16 -3
  132. package/src/components/form/engine/rowFocus.ts +17 -6
  133. package/src/components/form/expressions/renderExpressionToText.ts +15 -0
  134. package/src/components/form/fields/ChoiceGroupHeading.tsx +38 -0
  135. package/src/components/form/fields/Field.tsx +11 -9
  136. package/src/components/form/fields/allowed-values/AllowedValues.tsx +21 -9
  137. package/src/components/form/fields/allowed-values/savedValues.ts +41 -0
  138. package/src/components/form/fields/auto/AutoFormField.tsx +17 -4
  139. package/src/components/form/fields/long-string/LongString.stories.tsx +27 -0
  140. package/src/components/form/fields/long-string/LongString.tsx +14 -3
  141. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +5 -4
  142. package/src/components/form/fields/number/Number.tsx +19 -4
  143. package/src/components/form/fields/radio-group/RadioGroup.tsx +13 -7
  144. package/src/components/form/fields/template/TemplateField.tsx +32 -4
  145. package/src/components/form/index.tsx +2 -0
  146. package/src/components/imageLightbox/ImageLightbox.tsx +2 -0
  147. package/src/components/supportTicket/InterfaceReferenceTags.tsx +3 -2
  148. package/src/components/supportTicket/ReferencePicker.stories.tsx +0 -4
  149. package/src/components/supportTicket/ReferencePicker.tsx +22 -12
  150. package/src/components/supportTicket/TicketConversation.stories.tsx +0 -1
  151. package/src/components/supportTicket/TicketHeader.tsx +2 -1
  152. package/src/components/supportTicket/TicketReplyBox.stories.tsx +24 -21
  153. package/src/components/supportTicket/TicketReplyBox.tsx +27 -5
  154. package/src/components/supportTicket/useLoadedImages.ts +20 -0
  155. package/src/components/ticketReferences/TicketReferences.stories.tsx +26 -0
  156. package/src/components/ticketReferences/TicketReferences.tsx +12 -4
  157. package/src/components/ticketThread/TicketThread.stories.tsx +71 -2
  158. package/src/components/ticketThread/TicketThread.tsx +15 -28
  159. package/src/helpers/allowedValues.ts +19 -0
  160. package/src/helpers/fieldDescription.ts +39 -0
  161. package/src/helpers/singleLineString.ts +1 -0
@@ -4,8 +4,11 @@ import {
4
4
  ReqoreMenuItem,
5
5
  ReqoreModal,
6
6
  ReqoreSpinner,
7
+ useReqoreTheme,
7
8
  } from '@qoretechnologies/reqore';
8
9
  import { TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button';
10
+ import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors';
11
+ import { rgba } from 'polished';
9
12
  import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
10
13
  import styled from 'styled-components';
11
14
  import { thinScrollbar } from '../../helpers/scrollbar';
@@ -128,21 +131,22 @@ const Panes = styled.div`
128
131
  * instead keeps a focus cue without the box. The selector is scoped under this wrapper,
129
132
  * so it outranks Reqore's without `!important`.
130
133
  *
131
- * `$active` (the field has a query) holds the underline at full accent, so a filtered
132
- * list is legible as filtered at a glance — the same signal `intent='info'` carries on
133
- * a plain filter control, spoken in this surface's own accent.
134
+ * The line is the theme's neutral hairline, a step brighter on focus. Only `$active` (the
135
+ * field has a query) colours it, in `$accent` — blue by default, the same signal
136
+ * `intent='info'` carries on a plain filter control: blue says a filter is narrowing the
137
+ * list, and nothing else (instruction-files decision 001). It is never the product's
138
+ * purple, which a support surface does not wear (decision 002).
134
139
  */
135
- const Search = styled.div<{ $accent: string; $active?: boolean }>`
140
+ const Search = styled.div<{ $accent: string; $line: string; $focus: string; $active?: boolean }>`
136
141
  padding: 6px 4px 4px;
137
- border-bottom: 1px solid
138
- ${({ $accent, $active }) => ($active ? $accent : `${$accent}80`)};
142
+ border-bottom: 1px solid ${({ $accent, $line, $active }) => ($active ? $accent : $line)};
139
143
  transition: border-color 0.15s ease-out;
140
144
 
141
145
  .reqore-control-wrapper:focus-within {
142
146
  outline: none;
143
147
  }
144
148
  &:focus-within {
145
- border-bottom-color: ${({ $accent }) => $accent};
149
+ border-bottom-color: ${({ $accent, $focus, $active }) => ($active ? $accent : $focus)};
146
150
  }
147
151
  `;
148
152
 
@@ -152,8 +156,6 @@ const Note = styled.div`
152
156
  font-size: 12px;
153
157
  `;
154
158
 
155
- const DEFAULT_ACCENT = '#762f7e';
156
-
157
159
  /*
158
160
  * How a picked row reads: `info`, tinted rather than filled.
159
161
  *
@@ -241,7 +243,8 @@ export interface IReferencePickerProps {
241
243
  * vocabulary.
242
244
  */
243
245
  resolveKindLabel?: (kind: string) => string;
244
- /** the underline / selected-kind colour. Defaults to the support surfaces' violet. */
246
+ /** the search underline's colour while the list is filtered. Defaults to the theme's
247
+ * `info` blue — the colour that says a filter is narrowing a list. */
245
248
  accent?: string;
246
249
  /** shown above the panes — the composer's picked-reference chips, typically */
247
250
  header?: ReactNode;
@@ -303,13 +306,15 @@ export const ReferencePicker = ({
303
306
  disabled,
304
307
  resolveInterfaceIcon = defaultInterfaceIcon,
305
308
  resolveKindLabel = defaultInterfaceKindLabel,
306
- accent = DEFAULT_ACCENT,
309
+ accent,
307
310
  header,
308
311
  onClose,
309
312
  title = 'Reference interfaces',
310
313
  layout = 'auto',
311
314
  presentation = 'auto',
312
315
  }: IReferencePickerProps) => {
316
+ const theme = useReqoreTheme();
317
+ const readable = getReadableColor(theme);
313
318
  const [query, setQuery] = useState<string>('');
314
319
  const needle = query.trim().toLowerCase();
315
320
 
@@ -510,7 +515,12 @@ export const ReferencePicker = ({
510
515
  const body = (
511
516
  <Frame ref={frameRef} $bare={asModal}>
512
517
  {header}
513
- <Search $accent={accent} $active={!!needle}>
518
+ <Search
519
+ $accent={accent ?? theme.intents?.info ?? readable}
520
+ $line={rgba(readable, 0.2)}
521
+ $focus={rgba(readable, 0.5)}
522
+ $active={!!needle}
523
+ >
514
524
  <ReqoreInput
515
525
  value={query}
516
526
  onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
@@ -53,7 +53,6 @@ const themed = (node: ReactNode) => (
53
53
 
54
54
  const refs = (
55
55
  <InterfaceReferenceTags
56
- customTheme={{ main: 'custom1' }}
57
56
  references={[{ interface_kind: 'connection', interface_name: 'sftp-partner' }]}
58
57
  />
59
58
  );
@@ -201,7 +201,8 @@ export const TicketHeader = ({
201
201
  and the strip would no longer line up with them. */
202
202
  rowPadding='tiny'
203
203
  labelWidth={LABEL_WIDTH}
204
- uppercaseLabels
204
+ /* The labels' case is the theme's (`theme.uppercaseLabels`): an app that sets its
205
+ labels in sentence case gets "Opened by", not "OPENED BY". */
205
206
  flat
206
207
  transparent
207
208
  padded={false}
@@ -16,6 +16,19 @@ const meta = {
16
16
  export default meta;
17
17
  type Story = StoryObj<typeof meta>;
18
18
 
19
+ /**
20
+ * The reply is sent with the white primary, never the Qonsole purple: a ticket reply is not a
21
+ * hand-off to Qonsole (instruction-files decision 002). Read from the drawn button, so a theme
22
+ * or a default that brings the purple back fails here.
23
+ */
24
+ const expectWhiteSend = async (canvasElement: HTMLElement) => {
25
+ const send = within(canvasElement).getByText('Send reply').closest('button')!;
26
+ await waitFor(() => {
27
+ const [r, g, b] = getComputedStyle(send).backgroundColor.match(/\d+/g)!.map(Number);
28
+ expect(Math.min(r, g, b)).toBeGreaterThan(200);
29
+ });
30
+ };
31
+
19
32
  /**
20
33
  * The customer composer, plain-editor variant (used here for a deterministic
21
34
  * type→send test): an attach button (no extra menu → a plain button, not a
@@ -27,7 +40,7 @@ export const Default: Story = {
27
40
  docs: {
28
41
  description: {
29
42
  story:
30
- "Renders the customer composer's plain-editor variant — an attach button and Send, no internal-note toggle; typing a reply enables Send, which calls onSend with the body and internal: false.",
43
+ "Renders the customer composer's plain-editor variant — an attach button and Send, no internal-note toggle; typing a reply enables Send — the white primary, not the Qonsole purple — which calls onSend with the body and internal: false.",
31
44
  },
32
45
  },
33
46
  },
@@ -39,6 +52,7 @@ export const Default: Story = {
39
52
  await expect(canvas.queryByText('Internal note')).not.toBeInTheDocument();
40
53
 
41
54
  await userEvent.type(textarea!, 'Here is my reply');
55
+ await expectWhiteSend(canvasElement);
42
56
  await userEvent.click(canvas.getByText('Send reply'));
43
57
  await waitFor(() =>
44
58
  expect(args.onSend).toHaveBeenCalledWith(
@@ -59,7 +73,7 @@ export const StaffWithInternalNote: Story = {
59
73
  docs: {
60
74
  description: {
61
75
  story:
62
- "Renders the staff composer with a split send button — the primary action posts a public reply, while the caret opens 'Add internal note', which sends with internal: true.",
76
+ "Renders the staff composer with a split send button — the primary action (white, not the Qonsole purple) posts a public reply, while the caret opens 'Add internal note', which sends with internal: true.",
63
77
  },
64
78
  },
65
79
  },
@@ -72,6 +86,7 @@ export const StaffWithInternalNote: Story = {
72
86
 
73
87
  const textarea = canvasElement.querySelector('textarea');
74
88
  await userEvent.type(textarea!, 'internal context');
89
+ await expectWhiteSend(canvasElement);
75
90
 
76
91
  // "Add internal note" lives behind the split-send caret
77
92
  const caret = canvasElement.querySelector('.ticket-send-more');
@@ -122,8 +137,7 @@ export const RichHelpdeskReply: Story = {
122
137
  },
123
138
  },
124
139
  },
125
- // Rendered under the Qorus theme (as in qorus-ide / admin-portal) so the
126
- // `custom1` accent shows as the Qorus purple — a bare reqore theme greys it out.
140
+ // Rendered under the Qorus theme, as in qorus-ide / admin-portal.
127
141
  decorators: [
128
142
  (Story) => (
129
143
  <ReqoreUIProvider theme={{ main: '#121212', intents: { custom1: '#762f7e' } }}>
@@ -141,23 +155,15 @@ export const RichHelpdeskReply: Story = {
141
155
  { label: 'Take screenshot', icon: 'CameraAiLine', onClick: fn() },
142
156
  { label: 'Capture area', icon: 'CropLine', onClick: fn() },
143
157
  ],
144
- // the reference toggle, styled to the same custom1 accent as the send
158
+ // the reference toggle: a quiet icon button, like the attach button beside it
145
159
  footerActions: (
146
- <ReqoreButton
147
- icon='LinksLine'
148
- minimal
149
- flat
150
- badge={1}
151
- customTheme={{ main: 'custom1' }}
152
- tooltip='Reference interfaces'
153
- />
160
+ <ReqoreButton icon='LinksLine' minimal flat badge={1} tooltip='Reference interfaces' />
154
161
  ),
155
- // references on top of the editor (inside the bar), tinted to the accent — no
156
- // "References" label, the chips read as the referenced interfaces on their own
162
+ // references on top of the editor (inside the bar), as quiet tags with the kind's
163
+ // icon — no "References" label, the chips read as the referenced interfaces on their own
157
164
  aboveInput: (
158
165
  <InterfaceReferenceTags
159
166
  references={[{ interface_kind: 'connection', interface_name: 'sftp-partner' }]}
160
- customTheme={{ main: 'custom1' }}
161
167
  />
162
168
  ),
163
169
  },
@@ -176,7 +182,7 @@ export const RichHelpdeskReply: Story = {
176
182
 
177
183
  /**
178
184
  * Every referenceable interface kind on one message, so the per-kind icons +
179
- * accent chip styling can be reviewed together: workflow, service, job, fsm,
185
+ * chip styling can be reviewed together: workflow, service, job, fsm,
180
186
  * connection, mapper, class, value-map, qog, ai-collection, ai-endpoint,
181
187
  * ai-guardrail. (`qog` has no built-in icon → the generic fallback.)
182
188
  */
@@ -185,7 +191,7 @@ export const AllReferenceTypes: Story = {
185
191
  docs: {
186
192
  description: {
187
193
  story:
188
- 'Renders a reply carrying every referenceable interface kind at once, so the per-kind icons and accent chip styling can be reviewed together (qog falls back to the generic glyph).',
194
+ 'Renders a reply carrying every referenceable interface kind at once, so the per-kind icons and chip styling can be reviewed together (qog falls back to the generic glyph).',
189
195
  },
190
196
  },
191
197
  },
@@ -202,7 +208,6 @@ export const AllReferenceTypes: Story = {
202
208
  defaultText: 'This one references every interface kind.',
203
209
  aboveInput: (
204
210
  <InterfaceReferenceTags
205
- customTheme={{ main: 'custom1' }}
206
211
  references={[
207
212
  { interface_kind: 'workflow', interface_name: 'order-sync:1.2' },
208
213
  { interface_kind: 'service', interface_name: 'notifier' },
@@ -267,7 +272,6 @@ export const QogTriggerAppLogo: Story = {
267
272
  defaultText: 'These qogs are referenced by their trigger apps.',
268
273
  aboveInput: (
269
274
  <InterfaceReferenceTags
270
- customTheme={{ main: 'custom1' }}
271
275
  references={[
272
276
  // resolved live from trigger_app (the customer/IDE path)
273
277
  { interface_kind: 'qog', interface_name: 'telegram-intake', trigger_app: 'Telegram' },
@@ -337,7 +341,6 @@ export const WithScreenshotAndReferences: Story = {
337
341
  defaultFiles: [screenshotFile()],
338
342
  aboveInput: (
339
343
  <InterfaceReferenceTags
340
- customTheme={{ main: 'custom1' }}
341
344
  references={[
342
345
  { interface_kind: 'connection', interface_name: 'sftp-partner' },
343
346
  { interface_kind: 'qog', interface_name: 'telegram-intake', logo: appLogo('#2AABEE', 'T') },
@@ -1,9 +1,29 @@
1
1
  import type { TReqoreDropdownItems } from '@qoretechnologies/reqore/dist/components/Dropdown/list';
2
2
  import { ReactNode } from 'react';
3
- import { Composer, IComposerSendAction, TComposerEditor } from '../composer/Composer';
3
+ import {
4
+ Composer,
5
+ IComposerProps,
6
+ IComposerSendAction,
7
+ TComposerEditor,
8
+ } from '../composer/Composer';
4
9
  import { fileToAttachment as defaultFileToAttachment } from './fileToAttachment';
5
10
  import { IAttachmentUpload } from './meta';
6
11
 
12
+ /**
13
+ * A ticket reply is not a hand-off to Qonsole, so it does not wear Qonsole's look: no purple
14
+ * bar, no purple send (instruction-files decision 002 — a purple button means "this hands the
15
+ * work to Qonsole / AI" and nothing else). The bar keeps the composer's shape with the theme's
16
+ * own neutral edge (its `main`, a step lighter — how Reqore draws its dividers), and sending
17
+ * the reply is the surface's one primary action: white.
18
+ */
19
+ const REPLY_BOX_EFFECT: IComposerProps['effect'] = {
20
+ gradient: { direction: 'to bottom right', colors: 'main:lighten:3' },
21
+ };
22
+ const REPLY_SEND_STYLE: Pick<IComposerSendAction, 'intent' | 'minimal'> = {
23
+ intent: 'white',
24
+ minimal: false,
25
+ };
26
+
7
27
  /**
8
28
  * The `items` shape ReqoreDropdown accepts. Named, not derived: `ReqoreDropdown`
9
29
  * is a generic component, and `ComponentProps` of a generic resolves to `{}`
@@ -96,16 +116,15 @@ export const TicketReplyBox = ({
96
116
  : 'Send reply';
97
117
 
98
118
  // Staff surface splits the send button; customer surface is a single "Send reply".
99
- // Both use the Qonsole-style minimal `custom1` accent (the Composer default); the
100
- // internal note stays amber (`warning`) so it can't be mistaken for a public reply.
119
+ // Both send the reply as the white primary (`REPLY_SEND_STYLE`); the internal note
120
+ // stays amber (`warning`) so it can't be mistaken for a public reply.
101
121
  const sendActions: IComposerSendAction[] | undefined = allowInternalNote
102
122
  ? [
103
123
  {
104
124
  id: 'reply',
105
125
  label: labelFor(false),
106
126
  icon: 'SendPlane2Line',
107
- minimal: true,
108
- customTheme: { main: 'custom1' },
127
+ ...REPLY_SEND_STYLE,
109
128
  },
110
129
  {
111
130
  id: 'internal',
@@ -139,7 +158,10 @@ export const TicketReplyBox = ({
139
158
  disabledReason={disabledReason}
140
159
  onSend={handleSend}
141
160
  sendLabel={labelFor(false)}
161
+ sendIntent={REPLY_SEND_STYLE.intent}
162
+ sendMinimal={REPLY_SEND_STYLE.minimal}
142
163
  sendActions={sendActions}
164
+ effect={REPLY_BOX_EFFECT}
143
165
  sendMenuClassName='ticket-send-more'
144
166
  attachMenuItems={attachMenuItems}
145
167
  footerActions={footerActions}
@@ -66,6 +66,26 @@ export const useLoadedImages = (sources: Array<string | undefined>): ((src?: str
66
66
  export const whenImageSettled = (src: string): Promise<void> =>
67
67
  imageStatus.has(src) ? Promise.resolve() : loadImage(src);
68
68
 
69
+ /**
70
+ * Resolves with `src` once it has loaded as an image, and rejects when it does not.
71
+ *
72
+ * A fetched attachment URL can resolve and still not be a picture — the server
73
+ * answered with an error page or an empty body — and an `<img>` handed it draws a
74
+ * broken picture. Chained after the fetch, this puts that case on the same path as
75
+ * a failed fetch, so every preview falls back to the file's name or its download
76
+ * chip. A rejected object URL is revoked here, since the caller never receives it.
77
+ */
78
+ export const loadedImageUrl = async (src: string): Promise<string> => {
79
+ await whenImageSettled(src);
80
+ if (imageStatus.get(src) !== 'loaded') {
81
+ if (src.startsWith('blob:')) {
82
+ URL.revokeObjectURL(src);
83
+ }
84
+ throw new Error('The attachment is not an image that can be shown');
85
+ }
86
+ return src;
87
+ };
88
+
69
89
  /** Forget every image's status. For tests only. */
70
90
  export const resetLoadedImagesForTests = (): void => {
71
91
  imageStatus.clear();
@@ -2,6 +2,7 @@ import { StoryObj } from '@storybook/react-vite';
2
2
  import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3
3
 
4
4
  import { StoryMeta } from '../../types';
5
+ import { STORY_BROKEN_IMAGE } from '../supportTicket/__fixtures__/kindMarks';
5
6
  import { IInterfaceReference } from '../supportTicket/meta';
6
7
  import { ITicketReferenceAttachment, TicketReferences } from './TicketReferences';
7
8
 
@@ -215,6 +216,31 @@ export const NoPreview: Story = {
215
216
  },
216
217
  };
217
218
 
219
+ /** A fetch that answers with bytes that are not a picture reads like a failed fetch. */
220
+ export const PreviewThatDoesNotLoad: Story = {
221
+ parameters: {
222
+ docs: {
223
+ description: {
224
+ story:
225
+ 'Renders the references view where one screenshot\'s bytes are not an image the browser can draw: that tile shows its file name, like a screenshot with no preview, while the others keep their thumbnails.',
226
+ },
227
+ },
228
+ },
229
+ args: {
230
+ fetchAttachmentUrl: (id: string) =>
231
+ Promise.resolve(id === 'img-1' ? STORY_BROKEN_IMAGE : IMG_URLS[id] ?? svgUrl(id, '#555555')),
232
+ },
233
+ play: async ({ canvasElement }) => {
234
+ const tile = (name: string) =>
235
+ canvasElement.querySelector<HTMLElement>(`button[title="${name}"]`);
236
+ // the screenshots that load are thumbnails …
237
+ await waitFor(() => expect(tile('pool-metrics.png')?.querySelector('img')).not.toBeNull());
238
+ // … and the one that does not is its file name, never a broken picture
239
+ await waitFor(() => expect(tile('error-screenshot.png')?.textContent).not.toContain('…'));
240
+ await expect(tile('error-screenshot.png')?.querySelector('img')).toBeNull();
241
+ },
242
+ };
243
+
218
244
  /** Nothing attached or linked. */
219
245
  export const Empty: Story = {
220
246
  parameters: {
@@ -27,7 +27,7 @@ import {
27
27
  TResolveInterfaceIcon,
28
28
  TTicketViewerRole,
29
29
  } from '../supportTicket/meta';
30
- import { useLoadedImages } from '../supportTicket/useLoadedImages';
30
+ import { loadedImageUrl, useLoadedImages } from '../supportTicket/useLoadedImages';
31
31
  import type { ITicketThreadAttachment, ITicketThreadMessage } from '../ticketThread/TicketThread';
32
32
 
33
33
  /*
@@ -241,7 +241,13 @@ const useImageUrls = (
241
241
  }
242
242
  let active = true;
243
243
  const created: string[] = [];
244
- Promise.allSettled(ids.map((id) => fetchAttachmentUrl(id).then((url) => ({ id, url })))).then(
244
+ Promise.allSettled(
245
+ ids.map((id) =>
246
+ fetchAttachmentUrl(id)
247
+ .then(loadedImageUrl)
248
+ .then((url) => ({ id, url }))
249
+ )
250
+ ).then(
245
251
  (results) => {
246
252
  if (!active) {
247
253
  return;
@@ -324,11 +330,13 @@ const Thumb = styled.button`
324
330
  justify-content: center;
325
331
  position: relative;
326
332
  transition: border-color 0.15s;
333
+ /* A screenshot is something to read, not a hand-off to Qonsole, so its hover and focus
334
+ brighten the tile's own edge instead of wearing the product's purple (decision 002). */
327
335
  &:hover {
328
- border-color: rgba(169, 116, 255, 0.6);
336
+ border-color: rgba(255, 255, 255, 0.35);
329
337
  }
330
338
  &:focus-visible {
331
- outline: 2px solid #a974ff;
339
+ outline: 2px solid rgba(255, 255, 255, 0.6);
332
340
  outline-offset: 2px;
333
341
  }
334
342
  img {
@@ -4,6 +4,7 @@ import styled from 'styled-components';
4
4
 
5
5
  import { thinScrollbar } from '../../helpers/scrollbar';
6
6
  import { StoryMeta } from '../../types';
7
+ import { STORY_BROKEN_IMAGE } from '../supportTicket/__fixtures__/kindMarks';
7
8
  import type { IInterfaceReference, TTicketMessageAuthor } from '../supportTicket/meta';
8
9
  import { ITicketThreadMessage, TicketThread } from './TicketThread';
9
10
 
@@ -159,6 +160,30 @@ const meta = {
159
160
  export default meta;
160
161
  type Story = StoryObj<typeof meta>;
161
162
 
163
+ /**
164
+ * How coloured a message's card is: the saturation (0–1) of its border, the one place
165
+ * ReqoreBubble draws its tint. A neutral card is near 0; amber or purple is well above.
166
+ */
167
+ const cardSaturation = (messageId: string): number => {
168
+ const bubble = document.getElementById(`ticket-message-${messageId}`);
169
+ if (!bubble) throw new Error(`message ${messageId} not rendered`);
170
+ const bordered = [bubble, ...Array.from(bubble.querySelectorAll<HTMLElement>('*'))].find((el) => {
171
+ const style = getComputedStyle(el);
172
+ return parseFloat(style.borderTopWidth) > 0 && style.borderTopStyle !== 'none';
173
+ });
174
+ if (!bordered) throw new Error(`message ${messageId} has no card border`);
175
+ const [r, g, b] = getComputedStyle(bordered).borderTopColor.match(/[\d.]+/g)!.map(Number);
176
+ const max = Math.max(r, g, b);
177
+ return max === 0 ? 0 : (max - Math.min(r, g, b)) / max;
178
+ };
179
+
180
+ /** Neither side of a conversation is tinted (no purple, no blue); only an internal note is. */
181
+ const expectNeutralCards = async (ids: string[]) => {
182
+ for (const id of ids) {
183
+ await expect(cardSaturation(id)).toBeLessThan(0.2);
184
+ }
185
+ };
186
+
162
187
  /**
163
188
  * The customer viewpoint: the customer's own messages sit on the right under
164
189
  * "You", staff appear under a masked "Qorus Support" label, and internal notes
@@ -170,7 +195,7 @@ export const CustomerView: Story = {
170
195
  docs: {
171
196
  description: {
172
197
  story:
173
- "Renders the thread from the customer's viewpoint: the customer's own messages sit on the right under 'You', staff appear under the masked 'Qorus Support' label, internal notes are filtered out, and clicking an attachment chip calls onDownloadAttachment with its id and filename.",
198
+ "Renders the thread from the customer's viewpoint: the customer's own messages sit on the right under 'You', staff appear under the masked 'Qorus Support' label, both as neutral cards (the side and the label say who spoke), internal notes are filtered out, and clicking an attachment chip calls onDownloadAttachment with its id and filename.",
174
199
  },
175
200
  },
176
201
  },
@@ -186,6 +211,8 @@ export const CustomerView: Story = {
186
211
  await expect(canvas.getByText('Qorus Support')).toBeInTheDocument();
187
212
  // internal notes are filtered out for the customer
188
213
  await expect(canvas.queryByText('Internal note')).not.toBeInTheDocument();
214
+ // the side, the avatar and the label say who spoke; no card is tinted for it
215
+ await expectNeutralCards(['m1', 'm2']);
189
216
 
190
217
  await userEvent.click(canvas.getAllByText('deploy-trace.log')[0]);
191
218
  await waitFor(() =>
@@ -306,7 +333,7 @@ export const StaffView: Story = {
306
333
  docs: {
307
334
  description: {
308
335
  story:
309
- "Renders the thread from the staff viewpoint: staff messages sit on the right, the customer on the left, and staff-only internal notes render amber and bordered under an 'Internal note' label.",
336
+ "Renders the thread from the staff viewpoint: staff messages sit on the right, the customer on the left, both as neutral cards, and staff-only internal notes render amber and bordered under an 'Internal note' label.",
310
337
  },
311
338
  },
312
339
  },
@@ -320,6 +347,9 @@ export const StaffView: Story = {
320
347
  await expect(canvas.getByText('Internal note')).toBeInTheDocument();
321
348
  await expect(canvas.getByText(/Support · nick/)).toBeInTheDocument();
322
349
  await expect(canvas.getByText(/Customer · acme/)).toBeInTheDocument();
350
+ // both sides are neutral cards; the internal note alone is amber
351
+ await expectNeutralCards(['m1', 'm2']);
352
+ await expect(cardSaturation('m3')).toBeGreaterThan(0.3);
323
353
  // the reference chip renders with the built-in default icon (no resolver passed)
324
354
  await expect(canvas.getByText('nightly-deploy:1.2')).toBeInTheDocument();
325
355
  },
@@ -749,6 +779,45 @@ export const ImagePreview: Story = {
749
779
  },
750
780
  };
751
781
 
782
+ /**
783
+ * An image attachment whose bytes are not a picture — the server answered with an error
784
+ * page or an empty body — is a download chip like any other file, never a broken image.
785
+ */
786
+ export const ImageThatDoesNotLoad: Story = {
787
+ parameters: {
788
+ docs: {
789
+ description: {
790
+ story:
791
+ 'Renders a message with two screenshots where one\'s bytes cannot be drawn: that one falls back to its download chip while the other stays a thumbnail.',
792
+ },
793
+ },
794
+ },
795
+ args: {
796
+ viewerRole: 'customer',
797
+ fetchAttachmentUrl: async (id: string) =>
798
+ id === 'img-1' ? STORY_BROKEN_IMAGE : screenshot('after', 205),
799
+ messages: [
800
+ {
801
+ message_id: 'img',
802
+ author_type: 'customer',
803
+ author_id: 'acme',
804
+ body: 'Here is the canvas before and after the upgrade:',
805
+ created: ago(6),
806
+ attachments: [
807
+ { attachment_id: 'img-1', filename: 'before.png', content_type: 'image/png', size_bytes: 512_000 },
808
+ { attachment_id: 'img-2', filename: 'after.png', content_type: 'image/png', size_bytes: 486_000 },
809
+ ],
810
+ },
811
+ ],
812
+ },
813
+ async play({ canvasElement }) {
814
+ const canvas = within(canvasElement);
815
+ await waitFor(() => expect(canvasElement.querySelector('img[alt="after.png"]')).not.toBeNull());
816
+ await waitFor(() => expect(canvas.getByRole('button', { name: /before\.png/ })).toBeInTheDocument());
817
+ await expect(canvasElement.querySelector('img[alt="before.png"]')).toBeNull();
818
+ },
819
+ };
820
+
752
821
  /**
753
822
  * One message carrying the whole stack at once — a formatted body, image thumbnails,
754
823
  * file chips, and interface references — so the vertical order and spacing of all four
@@ -13,7 +13,7 @@ import {
13
13
  import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors';
14
14
  import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
15
15
  import { rgba } from 'polished';
16
- import { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { useEffect, useMemo, useRef, useState } from 'react';
17
17
  import ReactMarkdown from 'react-markdown';
18
18
  import styled from 'styled-components';
19
19
  import { formatBytes, isImage } from '../../helpers/common';
@@ -25,6 +25,7 @@ import {
25
25
  TTicketMessageAuthor,
26
26
  TTicketViewerRole,
27
27
  } from '../supportTicket';
28
+ import { loadedImageUrl } from '../supportTicket/useLoadedImages';
28
29
 
29
30
  /*
30
31
  * Shared support-ticket conversation thread, used by every Qorus surface that
@@ -32,12 +33,11 @@ import {
32
33
  * the Qonsole `support-ticket` renderer. Presentational — data in, callbacks out
33
34
  * — so each consumer keeps its own fetch/websocket wiring.
34
35
  *
35
- * Rendered as a comment feed: an avatar beside a bordered, accent-tinted card per
36
+ * Rendered as a comment feed: an avatar beside a bordered, neutral card per
36
37
  * message, each hugging its own side. Built on Reqore's `ReqoreBubble` (which
37
- * supplies the avatar, the bold author label, the alignment and the tint), so the
38
- * accent follows whatever the host app themes `custom1` to rather than a colour
39
- * baked in here. Interface references render via the shared
40
- * `InterfaceReferenceTags`; the status/priority/category vocabularies live in
38
+ * supplies the avatar, the bold author label and the alignment). Interface
39
+ * references render via the shared `InterfaceReferenceTags`, as quiet tags with
40
+ * the kind's icon; the status/priority/category vocabularies live in
41
41
  * `../supportTicket/meta`.
42
42
  *
43
43
  * Runs of consecutive messages are wrapped in a `ReqoreBubbleGroup`, which prints
@@ -114,10 +114,11 @@ const AUTHOR_META: Record<TTicketMessageAuthor, IAuthorMeta> = {
114
114
  system: { icon: 'InformationLine' },
115
115
  };
116
116
 
117
- // Both sides share one accent: the side, the avatar and the author label already
118
- // carry who is speaking, which frees colour to mean "internal note" and nothing
119
- // else. `custom1` is a theme slot, so each host app supplies its own hue.
120
- const ACCENT: ComponentProps<typeof ReqoreBubble>['customTheme'] = { main: 'custom1' };
117
+ // No side is tinted: the side, the avatar and the author label ("You", "Qorus
118
+ // Support") already carry who is speaking, which leaves colour one job here —
119
+ // amber for an internal note. Never the product's purple, which a support thread
120
+ // does not wear (instruction-files decision 002: purple marks where you are, a
121
+ // chart's accent, and Qonsole).
121
122
 
122
123
  /** Avatar glyph; an internal note carries the "not visible to the customer" eye. */
123
124
  const authorIcon = (message: ITicketThreadMessage): IReqoreIconName =>
@@ -444,6 +445,7 @@ const ImageThumb = ({
444
445
  useEffect(() => {
445
446
  let alive = true;
446
447
  fetchAttachmentUrl(attachment.attachment_id)
448
+ .then(loadedImageUrl)
447
449
  .then((resolved) => alive && setUrl(resolved))
448
450
  .catch(() => alive && setFailed(true));
449
451
  return () => {
@@ -666,23 +668,9 @@ export const TicketThread = ({
666
668
  avatar={{ icon: authorIcon(message) }}
667
669
  label={authorLabel(message, viewerRole)}
668
670
  timestamp={message.created ? <ReqoreTimeAgo time={message.created} /> : undefined}
669
- // Own messages read as info-blue, the other party's as the purple
670
- // accent — so the two sides of the conversation are told apart by
671
- // colour as well as by side, and "which of these did I write?" is
672
- // answerable at a glance. Deliberately keyed off `viewerRole`, so it
673
- // stays correct on the staff surface too (staff replies are the blue
674
- // ones there). Internal notes keep their warning intent and are not
675
- // re-tinted — they are neither side of the conversation.
676
- intent={
677
- message.internal
678
- ? 'warning'
679
- : message.author_type === viewerRole
680
- ? 'info'
681
- : undefined
682
- }
683
- customTheme={
684
- message.internal || message.author_type === viewerRole ? undefined : ACCENT
685
- }
671
+ // Both sides are neutral cards; an internal note is amber, so it can
672
+ // never be mistaken for a reply the customer sees.
673
+ intent={message.internal ? 'warning' : undefined}
686
674
  flat={false}
687
675
  minimal
688
676
  >
@@ -698,7 +686,6 @@ export const TicketThread = ({
698
686
  <div style={{ marginTop: 8 }}>
699
687
  <InterfaceReferenceTags
700
688
  size='tiny'
701
- customTheme={ACCENT}
702
689
  references={refsFor(message)}
703
690
  resolveInterfaceIcon={resolveInterfaceIcon}
704
691
  preferKindImage={preferKindImage}
@@ -79,6 +79,25 @@ export const findAllowedValueOption = (
79
79
  );
80
80
  };
81
81
 
82
+ /**
83
+ * The value a choice stands for, whichever of the three ways its entry is
84
+ * written (see `findAllowedValueOption`): the envelope's `value`, a bare
85
+ * `value`, or the entry's `name`.
86
+ *
87
+ * The row that names a stored choice and the validator that accepts it have
88
+ * always read all three. The pickers that WRITE a choice read only the
89
+ * envelope, so a list written bare showed nothing selected for the stored value
90
+ * and stored `undefined` when a choice was clicked: picking a pipeline
91
+ * processor's class cleared the field instead of setting it.
92
+ */
93
+ export const allowedValueOf = (option: any): unknown => {
94
+ const value = option?.value;
95
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'value' in value) {
96
+ return value.value;
97
+ }
98
+ return value !== undefined ? value : option?.name;
99
+ };
100
+
82
101
  /**
83
102
  * A Slate rich-text document: a non-empty array of element nodes, each with
84
103
  * `children`. Told apart from a LIST value, which is an array of elements.