@jini-ai/ui 0.6.12 → 0.6.15-rc.1

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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +14 -0
  3. package/dist/features/admin-widgets/components/InfoTip.d.ts +3 -2
  4. package/dist/features/admin-widgets/components/InfoTip.hooks.d.ts +17 -2
  5. package/dist/features/admin-widgets/components/InfoTip.hooks.js +64 -14
  6. package/dist/features/admin-widgets/components/InfoTip.js +2 -2
  7. package/dist/features/admin-widgets/index.d.ts +1 -1
  8. package/dist/features/admin-widgets/index.js +1 -1
  9. package/dist/features/execution/react/components/AgentCliEnvFields.js +27 -8
  10. package/dist/features/execution/react/components/ByokProviderForm.js +16 -19
  11. package/dist/features/mcp-ui/surfaces/document.d.ts +1 -1
  12. package/dist/features/mcp-ui/surfaces/document.js +28 -0
  13. package/dist/features/mcp-ui/surfaces/fields.d.ts +28 -1
  14. package/dist/features/mcp-ui/surfaces/fields.js +5 -1
  15. package/dist/features/mcp-ui/surfaces/form.js +86 -16
  16. package/dist/features/mcp-ui/surfaces/text-input.d.ts +3 -3
  17. package/dist/features/mcp-ui/surfaces/text-input.js +26 -11
  18. package/dist/features/media-providers/catalog.d.ts +3 -0
  19. package/dist/features/media-providers/catalog.js +3 -0
  20. package/dist/features/media-providers/react/components/MediaProviderCard.js +5 -4
  21. package/dist/features/media-providers/react/components/media-providers-tab-labels.js +2 -1
  22. package/dist/features/panel-kit/fetch-query/adapter.react.d.ts +10 -5
  23. package/dist/features/panel-kit/fetch-query/adapter.react.js +9 -3
  24. package/dist/features/panel-kit/fetch-query/binding.react.d.ts +4 -0
  25. package/dist/features/panel-kit/fetch-query/binding.react.js +29 -10
  26. package/dist/features/panel-kit/fetch-query/index.d.ts +1 -1
  27. package/dist/features/panel-kit/fetch-query/index.js +1 -1
  28. package/dist/features/panel-kit/fetch-query/tanstack.d.ts +1 -1
  29. package/dist/features/panel-kit/fetch-query/tanstack.js +1 -1
  30. package/dist/features/privacy/react/components/PrivacyTab.d.ts +1 -3
  31. package/dist/features/privacy/react/components/PrivacyTab.js +5 -8
  32. package/dist/features/settings/dialog/styles/settings-dialog.css +5 -70
  33. package/dist/features/source-config-list/react/components/SourceConfigField.js +27 -26
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +1 -0
  36. package/dist/react/components/SecretInput.d.ts +47 -0
  37. package/dist/react/components/SecretInput.hooks.d.ts +26 -0
  38. package/dist/react/components/SecretInput.hooks.js +25 -0
  39. package/dist/react/components/SecretInput.js +30 -0
  40. package/dist/styles/lightbox.css +1 -1
  41. package/dist/styles/variables.css +1 -1
  42. package/package.json +9 -9
@@ -78,6 +78,7 @@ function cancelPlanOf(spec) {
78
78
  /** The inline runtime script that reads, validates and submits the fields. @complexity O(n) in the field count plus the script length. */
79
79
  function formScript(spec, text) {
80
80
  const cancelPlan = cancelPlanOf(spec);
81
+ const hasAlternatives = spec.fields.some(field => field.requiredUnless?.orPresent);
81
82
  return `(function () {
82
83
  "use strict";
83
84
  ${SURFACE_SCRIPT_PRELUDE}
@@ -124,20 +125,29 @@ ${SURFACE_SCRIPT_PRELUDE}
124
125
  control.dispatchEvent(new Event("input", { bubbles: true }));
125
126
  });
126
127
 
127
- // A reveal button only changes presentation: replacing a textarea with a password input would
128
- // discard line breaks. Listen on the form so clicks on the button's contents work too.
128
+ // The eye toggle only changes presentation. A textarea stays a textarea (a password input would
129
+ // discard line breaks) and flips its CSS masking; a single-line input flips password <-> text.
130
+ // Listen on the form so clicks on the button's icon work too.
131
+ function setRevealed(button, reveal) {
132
+ var control = document.getElementById(button.getAttribute("data-mcpui-secret-toggle"));
133
+ if (!control) return;
134
+ if (control.tagName === "TEXTAREA") control.setAttribute("style", "-webkit-text-security: " + (reveal ? "none" : "disc") + ";");
135
+ else control.type = reveal ? "text" : "password";
136
+ button.setAttribute("aria-pressed", reveal ? "true" : "false");
137
+ button.setAttribute("aria-label", button.getAttribute(reveal ? "data-mcpui-hide-secret" : "data-mcpui-show-secret"));
138
+ }
139
+ // Re-mask every secret once a save succeeds, so nothing stays readable on screen behind the result.
140
+ function concealSecrets() {
141
+ var toggles = form.querySelectorAll("button[data-mcpui-secret-toggle]");
142
+ for (var t = 0; t < toggles.length; t++) setRevealed(toggles[t], false);
143
+ }
129
144
  form.addEventListener("click", function (event) {
130
145
  if (event.isTrusted !== true) return;
131
146
  var target = event.target;
132
147
  if (!target || typeof target.closest !== "function") return;
133
148
  var button = target.closest("button[data-mcpui-secret-toggle]");
134
149
  if (!button || button.disabled) return;
135
- var control = document.getElementById(button.getAttribute("data-mcpui-secret-toggle"));
136
- if (!control) return;
137
- var reveal = button.getAttribute("aria-pressed") !== "true";
138
- control.setAttribute("style", "-webkit-text-security: " + (reveal ? "none" : "disc") + ";");
139
- button.setAttribute("aria-pressed", reveal ? "true" : "false");
140
- button.textContent = button.getAttribute(reveal ? "data-mcpui-hide-secret" : "data-mcpui-show-secret");
150
+ setRevealed(button, button.getAttribute("aria-pressed") !== "true");
141
151
  });
142
152
 
143
153
  // form.elements[name] yields a RadioNodeList when several controls share a name and the BARE
@@ -172,6 +182,69 @@ ${SURFACE_SCRIPT_PRELUDE}
172
182
  return value === "";
173
183
  }
174
184
 
185
+ // Presentation only; the host's save rule validates the effective settings and kept secrets.
186
+ ${hasAlternatives ? ` function hasAlternative(field) {
187
+ var name = field.requiredUnless && field.requiredUnless.orPresent;
188
+ if (!name) return false;
189
+ var control = form.elements[name];
190
+ var value = control && control.value.trim();
191
+ if (!value) return FIELDS.some(function(item) { return item.name === name && item.hasSavedValue; });
192
+ var names = field.requiredUnless.headerNames;
193
+ if (!names) return true;
194
+ var pairs = value.split(",").map(function(pair) { return /^([^=\\s][^=]*?)\\s*=\\s*(.+)$/.exec(pair.trim()); });
195
+ return pairs.every(Boolean) && pairs.some(function(pair) {
196
+ return names.some(function(name) { return name.toLowerCase() === pair[1].toLowerCase(); }) && pair[2].trim() !== "";
197
+ });
198
+ }
199
+ ` : ''} function requiredNow(field) {
200
+ var condition = field.requiredUnless;
201
+ var other = condition && form.elements[condition.field];
202
+ var when = field.requiredWhen;
203
+ var controlling = when && form.elements[when.field];
204
+ var required = when ? Boolean(controlling && when.in.indexOf(controlling.value) !== -1) : field.required;
205
+ ${hasAlternatives ? ` if (hasAlternative(field)) return false;
206
+ ` : ''} return required && (!condition || !other || other.value !== condition.equals);
207
+ }
208
+ function refreshRequirements() {
209
+ for (var i = 0; i < FIELDS.length; i++) {
210
+ var field = FIELDS[i];
211
+ if (!field.requiredUnless && !field.requiredWhen && !field.hasSavedValue) continue;
212
+ var control = form.elements[field.name];
213
+ var required = requiredNow(field);
214
+ control.required = required && !field.hasSavedValue;
215
+ control.setAttribute("aria-required", String(required && !field.hasSavedValue));
216
+ var label = control.labels && control.labels[0];
217
+ var mark = label && label.querySelector(".mcpui-required");
218
+ if (label && !mark) {
219
+ mark = document.createElement("span");
220
+ mark.className = "mcpui-required";
221
+ mark.setAttribute("aria-hidden", "true");
222
+ mark.textContent = "*";
223
+ label.appendChild(mark);
224
+ }
225
+ if (mark) mark.hidden = !required;
226
+ }
227
+ }
228
+ form.addEventListener("change", refreshRequirements);
229
+ form.addEventListener("input", refreshRequirements);
230
+ refreshRequirements();
231
+
232
+ // Collect field values and validation feedback before the submit path performs any effects.
233
+ function readFields() {
234
+ var params = {};
235
+ var missing = [];
236
+ var missingMessage;
237
+ for (var i = 0; i < FIELDS.length; i++) {
238
+ var value = readField(FIELDS[i]);
239
+ if (requiredNow(FIELDS[i]) && !FIELDS[i].hasSavedValue && isBlank(FIELDS[i], value)) {
240
+ missing.push(FIELDS[i].label);
241
+ if ((FIELDS[i].requiredUnless || FIELDS[i].requiredWhen) && FIELDS[i].hint) missingMessage = FIELDS[i].label + " is required. " + FIELDS[i].hint;
242
+ }
243
+ params[FIELDS[i].name] = value;
244
+ }
245
+ return { params: params, missing: missing, missingMessage: missingMessage };
246
+ }
247
+
175
248
  // NOT wired to the form's "submit" event. Per the WHATWG form submission algorithm, the
176
249
  // sandboxed-forms browsing-context check runs BEFORE that event is ever dispatched, so in the
177
250
  // real target sandbox (MCP_UI_VIEW_SANDBOX grants "allow-scripts" and nothing else -- no
@@ -201,14 +274,10 @@ ${SURFACE_SCRIPT_PRELUDE}
201
274
  var params = {};
202
275
  var key;
203
276
  for (key in BASE_PARAMS) if (Object.prototype.hasOwnProperty.call(BASE_PARAMS, key)) params[key] = BASE_PARAMS[key];
204
- var missing = [];
205
- for (var i = 0; i < FIELDS.length; i++) {
206
- var value = readField(FIELDS[i]);
207
- if (FIELDS[i].required && isBlank(FIELDS[i], value)) missing.push(FIELDS[i].label);
208
- params[FIELDS[i].name] = value;
209
- }
210
- if (missing.length > 0) {
211
- setStatus(TEXT.missingPrefix + missing.join(", "), "invalid");
277
+ var fields = readFields();
278
+ Object.assign(params, fields.params);
279
+ if (fields.missing.length > 0) {
280
+ setStatus(fields.missingMessage || TEXT.missingPrefix + fields.missing.join(", "), "invalid");
212
281
  return;
213
282
  }
214
283
  for (key in actionParams) if (Object.prototype.hasOwnProperty.call(actionParams, key)) params[key] = actionParams[key];
@@ -216,6 +285,7 @@ ${SURFACE_SCRIPT_PRELUDE}
216
285
  setBusy(true);
217
286
  setStatus(TEXT.working, "pending");
218
287
  api.callTool(TOOL, params).then(function () {
288
+ concealSecrets();
219
289
  setStatus(TEXT.done, "done");
220
290
  api.requestTeardown();
221
291
  }, function (error) {
@@ -1,4 +1,4 @@
1
- /** Labels the host can translate for a multiline secret's reveal toggle. */
1
+ /** Verbs the host can translate for a secret's reveal toggle; the field label is appended. */
2
2
  export interface SecretInputText {
3
3
  readonly showSecret: string;
4
4
  readonly hideSecret: string;
@@ -18,8 +18,8 @@ export interface TextInputProps extends Partial<SecretInputText> {
18
18
  readonly disabled?: boolean;
19
19
  /** `'number'` also constrains the on-screen keyboard on touch devices, which `'text'` would not. */
20
20
  readonly inputType?: 'text' | 'number';
21
- /** Renders as `<input type="password">`, or a masked textarea with a show/hide toggle when
22
- * multiline. No `<textarea type="password">` exists, so multiline uses CSS text security.
21
+ /** Renders as `<input type="password">`, or a masked textarea when multiline — either way with
22
+ * an eye show/hide toggle. No `<textarea type="password">` exists, so multiline uses CSS text security.
23
23
  * Ignored for presentation when `inputType` is `'number'` (no such thing as a masked number) —
24
24
  * same precedence `multiline`'s own doc comment already gives `inputType: 'number'`.
25
25
  * A supplied value is always rejected: masking cannot protect a secret serialized into HTML. */
@@ -29,35 +29,50 @@ function resolveInputType(props) {
29
29
  return 'number';
30
30
  return props.secret === true ? 'password' : 'text';
31
31
  }
32
- /** The reveal toggle a multiline secret carries. @complexity O(n) in the label lengths. */
32
+ /** Eye / eye-off glyphs. Both ship; CSS shows the one matching `aria-pressed`, so the inline script
33
+ * never builds markup. `currentColor` keeps them on the surface's theme tokens in light and dark. */
34
+ const EYE_ICON = '<svg class="mcpui-secret-icon-show" aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>' +
35
+ '<svg class="mcpui-secret-icon-hide" aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a17.8 17.8 0 0 1-2.16 3.19M6.6 6.6C3.9 8.4 2 12 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M14.12 14.12a3 3 0 1 1-4.24-4.24"/><path d="M2 2l20 20"/></svg>';
36
+ /**
37
+ * The eye toggle every secret control carries, inside the field's right edge. Its accessible name
38
+ * is the host's verb plus the field label ("Show Token"); the full show/hide names ride on data
39
+ * attributes so the form script swaps them without knowing the label.
40
+ * @complexity O(n) in the label lengths.
41
+ */
33
42
  function renderSecretToggle(props, id) {
34
- const showSecret = props.showSecret ?? DEFAULT_SECRET_INPUT_TEXT.showSecret;
35
- const hideSecret = props.hideSecret ?? DEFAULT_SECRET_INPUT_TEXT.hideSecret;
36
- return `<button type="button" class="mcpui-button" data-mcpui-secret-toggle="${escapeHtml(id)}" data-mcpui-show-secret="${escapeHtml(showSecret)}" data-mcpui-hide-secret="${escapeHtml(hideSecret)}" aria-controls="${escapeHtml(id)}" aria-pressed="false"${booleanAttribute('disabled', props.disabled)}>${escapeHtml(showSecret)}</button>`;
43
+ const show = `${props.showSecret ?? DEFAULT_SECRET_INPUT_TEXT.showSecret} ${props.label}`;
44
+ const hide = `${props.hideSecret ?? DEFAULT_SECRET_INPUT_TEXT.hideSecret} ${props.label}`;
45
+ return `<button type="button" class="mcpui-secret-toggle" data-mcpui-secret-toggle="${escapeHtml(id)}" data-mcpui-show-secret="${escapeHtml(show)}" data-mcpui-hide-secret="${escapeHtml(hide)}" aria-label="${escapeHtml(show)}" aria-controls="${escapeHtml(id)}" aria-pressed="false"${booleanAttribute('disabled', props.disabled)}>${EYE_ICON}</button>`;
46
+ }
47
+ /** Wraps a secret control so its toggle sits inside the field's right edge. @complexity O(n) in the rendered length. */
48
+ function withSecretToggle(control, props, id) {
49
+ return `<div class="mcpui-secret">${control}${renderSecretToggle(props, id)}</div>`;
37
50
  }
38
51
  /** The `<textarea>` control, masked with a reveal toggle when secret. @complexity O(n) in the rendered length. */
39
52
  function renderTextarea(props, required) {
40
53
  const secret = props.secret === true;
41
- return `<textarea class="mcpui-textarea"${required.common}${optionalAttribute('rows', props.rows)}` +
54
+ const control = `<textarea class="mcpui-textarea"${required.common}${optionalAttribute('rows', props.rows)}` +
42
55
  // Password inputs strip line breaks. CSS masking keeps the textarea's actual value intact,
43
56
  // and disables browser text assistance that could retain or alter a pasted credential.
44
57
  (secret ? ' data-mcpui-secret style="-webkit-text-security: disc;" autocomplete="off" spellcheck="false"' : '') +
45
- `>${props.value === undefined ? '' : escapeHtml(String(props.value))}</textarea>` +
46
- (secret ? renderSecretToggle(props, required.id) : '');
58
+ `>${props.value === undefined ? '' : escapeHtml(String(props.value))}</textarea>`;
59
+ return secret ? withSecretToggle(control, props, required.id) : control;
47
60
  }
48
- /** The single-line `<input>` control. @complexity O(n) in the rendered length. */
61
+ /** The single-line `<input>` control, with the eye toggle when masked. @complexity O(n) in the rendered length. */
49
62
  function renderInput(props, required) {
50
63
  const { type, common } = required;
51
- return `<input class="mcpui-input" type="${type}"${common}` +
64
+ const control = `<input class="mcpui-input" type="${type}"${common}` +
52
65
  optionalAttribute('value', props.value) +
53
66
  // A stored secret (e.g. a cloud provider's access key or a database password) must never be
54
67
  // autofilled with a saved website login. `new-password`, not `off`: Chrome ignores `off` on
55
68
  // password inputs by design and fills the saved login anyway.
56
- (type === 'password' ? optionalAttribute('autocomplete', 'new-password') : '') +
69
+ // spellcheck off: once revealed as `type="text"`, enhanced spellcheck would ship the secret out.
70
+ (type === 'password' ? optionalAttribute('autocomplete', 'new-password') + optionalAttribute('spellcheck', 'false') : '') +
57
71
  (type === 'number'
58
72
  ? optionalAttribute('min', props.min) + optionalAttribute('max', props.max) + optionalAttribute('step', props.step)
59
73
  : '') +
60
74
  '>';
75
+ return type === 'password' ? withSecretToggle(control, props, required.id) : control;
61
76
  }
62
77
  /**
63
78
  * Renders one free-text field: its label, hint, and control, wrapped in `.mcpui-field`.
@@ -81,7 +96,7 @@ export function renderTextInput(props) {
81
96
  const type = resolveInputType(props);
82
97
  const control = props.multiline === true && type !== 'number'
83
98
  ? renderTextarea(props, { id, common })
84
- : renderInput(props, { type, common });
99
+ : renderInput(props, { id, type, common });
85
100
  return `<div class="mcpui-field">\n${renderFieldLabel(props)}\n${control}\n</div>`;
86
101
  }
87
102
  //# sourceMappingURL=text-input.js.map
@@ -0,0 +1,3 @@
1
+ /** Example endpoint for the provider configuration UI; no provider is selected by default. */
2
+ export declare const MEDIA_PROVIDER_BASE_URL_PLACEHOLDER = "https://api.example.com";
3
+ //# sourceMappingURL=catalog.d.ts.map
@@ -0,0 +1,3 @@
1
+ /** Example endpoint for the provider configuration UI; no provider is selected by default. */
2
+ export const MEDIA_PROVIDER_BASE_URL_PLACEHOLDER = 'https://api.example.com';
3
+ //# sourceMappingURL=catalog.js.map
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment } from 'react';
2
+ import { Fragment, useId } from 'react';
3
3
  import { agentHandleProps } from '@jini-ai/agentic';
4
- import { Icon } from '../../../../react/components/Icon.js';
4
+ import { SecretInput } from '../../../../react/components/SecretInput.js';
5
5
  import { isEntryPresent, isMarkerOnlyEntry, isProviderBaseUrlInvalid, maskedKeyLabel, resolveProviderBaseUrl, } from '../../rules.js';
6
6
  /** One provider's credential card: API key, base URL, optional model, clear, and the base-URL hint. */
7
7
  export function MediaProviderCard(props) {
@@ -23,12 +23,13 @@ function CardHead({ option, labels, t, maskedLabel, pending, }) {
23
23
  _jsx("span", { className: "jini-field-status-badge jini-field-status-badge-success", children: t(labels.savedWithMaskTemplate, { mask: maskedLabel }) })) : null, !saved && pending ? _jsx("span", { className: "jini-field-status-badge", children: labels.unsavedLabel }) : null] }));
24
24
  }
25
25
  function ApiKeyField({ option, entry, labels, providerHandle, onUpdate, maskedLabel, keyVisible, onToggleKeyVisible, }) {
26
- return (_jsxs("label", { className: "jini-media-provider-field jini-media-provider-field--secret", children: [_jsx("span", { className: "jini-field-label jini-sr-only", children: labels.apiKeyLabel }), _jsx("input", { className: "jini-input", type: keyVisible ? 'text' : 'password',
26
+ const inputId = useId();
27
+ return (_jsxs("div", { className: "jini-media-provider-field jini-media-provider-field--secret", children: [_jsx("label", { htmlFor: inputId, className: "jini-field-label jini-sr-only", children: labels.apiKeyLabel }), _jsx(SecretInput, { id: inputId, label: `${option.label} ${labels.apiKeyLabel}`, revealed: keyVisible, onRevealedChange: onToggleKeyVisible, toggleLabels: { show: `${labels.showKeyLabel} ${option.label} ${labels.apiKeyLabel}`, hide: `${labels.hideKeyLabel} ${option.label} ${labels.apiKeyLabel}` }, toggleAttrs: agentHandleProps({}, { base: providerHandle, ...({ action: 'reveal-key', role: 'button', label: `${option.label} ${labels.showKeyLabel}` }) }), className: "jini-input",
27
28
  /* `new-password`, NOT `"off"` — Chrome deliberately ignores `off` on
28
29
  credential-shaped fields, which is how a saved password gets autofilled into
29
30
  an API-key box. Same defect, same fix, same reasoning as
30
31
  `ByokProviderForm`'s own API-key input; see its comment. */
31
- autoComplete: "new-password", spellCheck: false, placeholder: maskedLabel ?? labels.apiKeyPlaceholder, "aria-label": `${option.label} ${labels.apiKeyLabel}`, value: entry.apiKey ?? '', onChange: (event) => onUpdate({ apiKey: event.target.value }), ...agentHandleProps({}, { base: providerHandle, ...({ action: 'api-key', role: 'field', label: `${option.label} ${labels.apiKeyLabel}` }) }) }), _jsx("button", { type: "button", className: "jini-input-affix-btn", "aria-pressed": keyVisible, "aria-label": `${option.label} ${keyVisible ? labels.hideKeyLabel : labels.showKeyLabel}`, onClick: onToggleKeyVisible, ...agentHandleProps({}, { base: providerHandle, ...({ action: 'reveal-key', role: 'button', label: `${option.label} ${labels.showKeyLabel}` }) }), children: _jsx(Icon, { name: keyVisible ? 'eye-off' : 'eye', size: 14 }) })] }));
32
+ autoComplete: "new-password", spellCheck: false, placeholder: maskedLabel ?? labels.apiKeyPlaceholder, "aria-label": `${option.label} ${labels.apiKeyLabel}`, value: entry.apiKey ?? '', onChange: (event) => onUpdate({ apiKey: event.target.value }), ...agentHandleProps({}, { base: providerHandle, ...({ action: 'api-key', role: 'field', label: `${option.label} ${labels.apiKeyLabel}` }) }) })] }));
32
33
  }
33
34
  function BaseUrlField({ option, entry, labels, providerHandle, onUpdate }) {
34
35
  return (_jsxs("label", { className: "jini-media-provider-field", children: [_jsx("span", { className: "jini-field-label jini-sr-only", children: labels.baseUrlLabel }), _jsx("input", { className: "jini-input", type: "url", inputMode: "url", spellCheck: false, placeholder: option.defaultBaseUrl || labels.baseUrlPlaceholder, "aria-label": `${option.label} ${labels.baseUrlLabel}`, value: entry.baseUrl ?? '', "aria-invalid": isProviderBaseUrlInvalid(entry) || undefined, onChange: (event) => onUpdate({ baseUrl: event.target.value }), ...agentHandleProps({}, { base: providerHandle, ...({ action: 'base-url', role: 'field', label: `${option.label} ${labels.baseUrlLabel}` }) }) })] }));
@@ -1,3 +1,4 @@
1
+ import { MEDIA_PROVIDER_BASE_URL_PLACEHOLDER } from '../../catalog.js';
1
2
  /** English defaults, keyed exactly like `MediaProvidersTabLabels`; each is passed through `t`
2
3
  * only when the host did not supply that label (the dictionaries are keyed by this English text). */
3
4
  const DEFAULT_LABELS = {
@@ -12,7 +13,7 @@ const DEFAULT_LABELS = {
12
13
  showKeyLabel: 'Show',
13
14
  hideKeyLabel: 'Hide',
14
15
  baseUrlLabel: 'Base URL',
15
- baseUrlPlaceholder: 'https://api.example.com',
16
+ baseUrlPlaceholder: MEDIA_PROVIDER_BASE_URL_PLACEHOLDER,
16
17
  baseUrlDefaultHintTemplate: 'Uses {url} by default.',
17
18
  baseUrlInvalidLabel: 'Enter an absolute http:// or https:// URL that is not a private or internal address.',
18
19
  modelLabel: 'Model',
@@ -1,11 +1,16 @@
1
1
  import { type ReactNode } from 'react';
2
- import { type FetchQueryEnvironmentPort } from '@jini-ai/core/fetch';
2
+ import { type FetchQueryCacheOptions } from '@jini-ai/core/fetch';
3
3
  import type { CachedLoader, CachedLoaderDefaults, CachedLoaderOptions, FetchMutationOptions, FetchQueryOptions, MutationResult, QueryKey, QueryResult } from '@jini-ai/core/fetch';
4
- /** Isolated cache scope; no external provider or query-library singleton is needed. */
5
- export declare function FetchQueryProvider({ children, environment }: {
4
+ /** Isolated cache scope; no external provider or query-library singleton is needed.
5
+ * Omitted timing ports use core's real clock and native timers. Keep port identities stable:
6
+ * replacing a port creates a new cache and disposes the previous scope's timers/subscriptions.
7
+ * @param props Children and optional host environment, clock and scheduler ports.
8
+ * @returns The provider-scoped query binding; activates on mount and disposes on unmount.
9
+ * @example <FetchQueryProvider clock={clock} scheduler={scheduler}><Records /></FetchQueryProvider>
10
+ * @complexity O(1) setup time and space; disposal scales with cached entries. */
11
+ export declare function FetchQueryProvider({ children, environment, ...timing }: {
6
12
  children: ReactNode;
7
- environment?: FetchQueryEnvironmentPort;
8
- }): import("react").JSX.Element;
13
+ } & Pick<FetchQueryCacheOptions, 'environment' | 'clock' | 'scheduler'>): import("react").JSX.Element;
9
14
  /**
10
15
  * Without prior successful data, a disabled read hides cached errors so gesture-gated
11
16
  * content cannot display a failure the user has not asked to retry. With prior data, retain
@@ -4,11 +4,17 @@ import { DEFAULT_STALE_TIME, FetchQueryCache, toQueryError, withCachedLoaderFetc
4
4
  import { browserFetchQueryEnvironment } from './environment.browser.js';
5
5
  import { FetchQueryBinding } from './binding.react.js';
6
6
  const CacheContext = createContext(null);
7
- /** Isolated cache scope; no external provider or query-library singleton is needed. */
8
- export function FetchQueryProvider({ children, environment = browserFetchQueryEnvironment }) {
7
+ /** Isolated cache scope; no external provider or query-library singleton is needed.
8
+ * Omitted timing ports use core's real clock and native timers. Keep port identities stable:
9
+ * replacing a port creates a new cache and disposes the previous scope's timers/subscriptions.
10
+ * @param props Children and optional host environment, clock and scheduler ports.
11
+ * @returns The provider-scoped query binding; activates on mount and disposes on unmount.
12
+ * @example <FetchQueryProvider clock={clock} scheduler={scheduler}><Records /></FetchQueryProvider>
13
+ * @complexity O(1) setup time and space; disposal scales with cached entries. */
14
+ export function FetchQueryProvider({ children, environment = browserFetchQueryEnvironment, ...timing }) {
9
15
  // A module singleton would leak cache values between independent providers/tests,
10
16
  // producing cases that pass alone but fail together. Scope ownership to this provider.
11
- const cache = useMemo(() => new FetchQueryCache({}, { environment }), [environment]);
17
+ const cache = useMemo(() => new FetchQueryCache({}, { environment, ...timing }), [environment, timing.clock, timing.scheduler]);
12
18
  useEffect(() => {
13
19
  cache.activate();
14
20
  return () => cache.dispose();
@@ -14,4 +14,8 @@ export declare const useFetchMutation: FetchQueryAdapter['useFetchMutation'];
14
14
  export declare const useInvalidate: FetchQueryAdapter['useInvalidate'];
15
15
  /** Promise loader using the same cache as reactive reads. */
16
16
  export declare const useCachedLoader: FetchQueryAdapter['useCachedLoader'];
17
+ /** Stable API identity shared by observers of the same provider, expressed as a primitive key. */
18
+ export declare function useFetchQueryScope(required: {
19
+ owner: object;
20
+ }, _options?: Record<string, never>): number;
17
21
  //# sourceMappingURL=binding.react.d.ts.map
@@ -1,24 +1,43 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext } from 'react';
2
+ import { createContext, useContext, useMemo, useState } from 'react';
3
+ /** Allocate primitive namespace keys within one cache provider; weak keys do not retain APIs. */
4
+ function createQueryScopes(_required, _options = {}) {
5
+ const scopes = new WeakMap();
6
+ let nextScope = 0;
7
+ return ({ owner }, _optional = {}) => {
8
+ let scope = scopes.get(owner);
9
+ if (scope === undefined) {
10
+ scope = ++nextScope;
11
+ scopes.set(owner, scope);
12
+ }
13
+ return scope;
14
+ };
15
+ }
3
16
  const AdapterContext = createContext(null);
4
17
  /** Internal composition seam: hooks and imperative loaders always use the same provider.
5
18
  * Keep the adapter fixed for this provider's lifetime to preserve React hook ordering. */
6
19
  export function FetchQueryBinding({ adapter, children }) {
7
- return _jsx(AdapterContext.Provider, { value: adapter, children: children });
20
+ const [scopeFor] = useState(() => createQueryScopes({}));
21
+ const binding = useMemo(() => ({ adapter, scopeFor }), [adapter, scopeFor]);
22
+ return _jsx(AdapterContext.Provider, { value: binding, children: children });
8
23
  }
9
24
  /** Resolve the nearest provider; fail before attempting an unscoped read or write. */
10
- function useAdapter() {
11
- const adapter = useContext(AdapterContext);
12
- if (!adapter)
25
+ function useBinding() {
26
+ const binding = useContext(AdapterContext);
27
+ if (!binding)
13
28
  throw new Error('Fetch-query hooks require FetchQueryProvider');
14
- return adapter;
29
+ return binding;
15
30
  }
16
31
  /** Library-free read facade; the provider selects its implementation. */
17
- export const useFetchQuery = (required, optional = {}) => useAdapter().useFetchQuery(required, optional);
32
+ export const useFetchQuery = (required, optional = {}) => useBinding().adapter.useFetchQuery(required, optional);
18
33
  /** Library-free write facade, including safe discarded promises. */
19
- export const useFetchMutation = (required, optional = {}) => useAdapter().useFetchMutation(required, optional);
34
+ export const useFetchMutation = (required, optional = {}) => useBinding().adapter.useFetchMutation(required, optional);
20
35
  /** Prefix invalidator for the nearest provider's cache. */
21
- export const useInvalidate = () => useAdapter().useInvalidate();
36
+ export const useInvalidate = () => useBinding().adapter.useInvalidate();
22
37
  /** Promise loader using the same cache as reactive reads. */
23
- export const useCachedLoader = (required, optional = {}) => useAdapter().useCachedLoader(required, optional);
38
+ export const useCachedLoader = (required, optional = {}) => useBinding().adapter.useCachedLoader(required, optional);
39
+ /** Stable API identity shared by observers of the same provider, expressed as a primitive key. */
40
+ export function useFetchQueryScope(required, _options = {}) {
41
+ return useBinding().scopeFor(required);
42
+ }
24
43
  //# sourceMappingURL=binding.react.js.map
@@ -1,5 +1,5 @@
1
1
  export { FetchQueryProvider } from './adapter.react.js';
2
- export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
2
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate, useFetchQueryScope } from './binding.react.js';
3
3
  /** @deprecated Import the framework-neutral environment port from @jini-ai/core/fetch. */
4
4
  export type { FetchQueryEnvironmentPort } from '@jini-ai/core/fetch';
5
5
  /** @deprecated Import framework-neutral query contracts from @jini-ai/core/fetch. */
@@ -1,3 +1,3 @@
1
1
  export { FetchQueryProvider } from './adapter.react.js';
2
- export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
2
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate, useFetchQueryScope } from './binding.react.js';
3
3
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
1
  /** Optional adapter entry. Importing the default entry never loads TanStack. */
2
2
  export { FetchQueryProvider } from './adapter.tanstack.js';
3
- export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
3
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate, useFetchQueryScope } from './binding.react.js';
4
4
  /** @deprecated Import the framework-neutral environment port from @jini-ai/core/fetch. */
5
5
  export type { FetchQueryEnvironmentPort } from '@jini-ai/core/fetch';
6
6
  /** @deprecated Import framework-neutral query contracts from @jini-ai/core/fetch. */
@@ -1,4 +1,4 @@
1
1
  /** Optional adapter entry. Importing the default entry never loads TanStack. */
2
2
  export { FetchQueryProvider } from './adapter.tanstack.js';
3
- export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
3
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate, useFetchQueryScope } from './binding.react.js';
4
4
  //# sourceMappingURL=tanstack.js.map
@@ -3,8 +3,6 @@ export interface PrivacyTabLabels {
3
3
  consentKicker?: string;
4
4
  consentLead?: string;
5
5
  consentFooter?: string;
6
- declineLabel?: string;
7
- shareLabel?: string;
8
6
  metricsLabel?: string;
9
7
  metricsHint?: string;
10
8
  contentLabel?: string;
@@ -30,7 +28,7 @@ export interface PrivacyTabProps {
30
28
  agentHandle?: string;
31
29
  }
32
30
  /**
33
- * Telemetry consent card (share/decline + two per-category toggles) over a
31
+ * Telemetry consent card (two per-category toggles) over a
34
32
  * generic `{ metrics, content }` shape, plus an installation-id
35
33
  * generate/rotate ("Delete my data") flow. Origin: `PrivacySection.tsx`,
36
34
  * mounted by `SettingsDialog.tsx`'s `privacy` tab — flagged "likely
@@ -1,8 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { agentHandleProps, agentSubHandle } from '@jini-ai/agentic';
3
3
  import { useT } from '../../../i18n/index.js';
4
4
  import { Icon } from '../../../../react/components/Icon.js';
5
- import { hasMadeConsentDecision, isSharingEnabled, nextStateForDeclineAll, nextStateForDeleteMyData, nextStateForShareAll, nextStateForTelemetryPatch, } from '../../rules.js';
5
+ import { hasMadeConsentDecision, nextStateForDeleteMyData, nextStateForTelemetryPatch, } from '../../rules.js';
6
6
  function ToggleRow({ label, hint, checked, onChange, agentHandle }) {
7
7
  return (_jsxs("button", { type: "button", className: `jini-toggle-row${checked ? ' on' : ''}`, onClick: () => onChange(!checked), "aria-pressed": checked, ...agentHandleProps({}, { base: agentHandle, ...({ role: 'checkbox', label }) }), children: [_jsxs("div", { className: "jini-toggle-row-text", children: [_jsx("span", { className: "jini-toggle-row-label", children: label }), _jsx("span", { className: "jini-toggle-row-hint", children: hint })] }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": true })] }));
8
8
  }
@@ -11,8 +11,6 @@ function resolveConsentLabels({ labels }, { t }) {
11
11
  consentKicker: labels.consentKicker ?? t('Help improve this product'),
12
12
  consentLead: labels.consentLead ?? t('Choose what you share. You can change this anytime.'),
13
13
  consentFooter: labels.consentFooter ?? t('Nothing is shared unless you opt in.'),
14
- declineLabel: labels.declineLabel ?? t("Don't share"),
15
- shareLabel: labels.shareLabel ?? t('Share usage'),
16
14
  metricsLabel: labels.metricsLabel ?? t('Anonymous metrics'),
17
15
  metricsHint: labels.metricsHint ?? t('Feature usage and performance data, with no personal content.'),
18
16
  contentLabel: labels.contentLabel ?? t('Conversation and tool content'),
@@ -28,7 +26,7 @@ function resolveInstallationLabels({ labels }, { t }) {
28
26
  };
29
27
  }
30
28
  /**
31
- * Telemetry consent card (share/decline + two per-category toggles) over a
29
+ * Telemetry consent card (two per-category toggles) over a
32
30
  * generic `{ metrics, content }` shape, plus an installation-id
33
31
  * generate/rotate ("Delete my data") flow. Origin: `PrivacySection.tsx`,
34
32
  * mounted by `SettingsDialog.tsx`'s `privacy` tab — flagged "likely
@@ -39,10 +37,9 @@ function resolveInstallationLabels({ labels }, { t }) {
39
37
  export function PrivacyTab({ state, onChange, labels, now = Date.now, agentHandle }) {
40
38
  const t = useT();
41
39
  const decided = hasMadeConsentDecision(state);
42
- const sharing = decided ? isSharingEnabled(state.telemetry) : undefined;
43
40
  const labelOverrides = labels ?? {};
44
- const { consentKicker, consentLead, consentFooter, declineLabel, shareLabel, metricsLabel, metricsHint, contentLabel, contentHint, } = resolveConsentLabels({ labels: labelOverrides }, { t });
41
+ const { consentKicker, consentLead, consentFooter, metricsLabel, metricsHint, contentLabel, contentHint, } = resolveConsentLabels({ labels: labelOverrides }, { t });
45
42
  const { installationIdLabel, installationIdHint, optedOutLabel, deleteMyDataLabel, } = resolveInstallationLabels({ labels: labelOverrides }, { t });
46
- return (_jsxs("section", { className: "jini-settings-section jini-settings-privacy", children: [_jsxs("div", { className: "jini-settings-subsection", children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { children: consentKicker }), _jsx("p", { className: "jini-hint", children: consentLead })] }) }), _jsxs("dl", { className: "jini-settings-privacy-disclosure", children: [_jsxs("div", { children: [_jsx("dt", { children: metricsLabel }), _jsx("dd", { children: metricsHint })] }), _jsxs("div", { children: [_jsx("dt", { children: contentLabel }), _jsx("dd", { children: contentHint })] })] }), _jsx("p", { className: "jini-hint", children: consentFooter }), _jsxs("div", { className: "jini-privacy-consent-actions", role: "group", "aria-label": consentKicker, children: [_jsx("button", { type: "button", className: `jini-privacy-consent-action${sharing === false ? ' is-active' : ''}`, "aria-pressed": sharing === false, onClick: () => onChange(nextStateForDeclineAll(state, now())), ...agentHandleProps({}, { base: agentHandle, ...({ action: 'decline', role: 'button', label: declineLabel }) }), children: declineLabel }), _jsx("button", { type: "button", className: `jini-privacy-consent-action jini-privacy-consent-action--primary${sharing === true ? ' is-active' : ''}`, "aria-pressed": sharing === true, onClick: () => onChange(nextStateForShareAll(state, now())), ...agentHandleProps({}, { base: agentHandle, ...({ action: 'share', role: 'button', label: shareLabel }) }), children: shareLabel })] })] }), decided ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "jini-settings-privacy-toggles", children: [_jsx(ToggleRow, { label: metricsLabel, hint: metricsHint, checked: state.telemetry.metrics === true, onChange: (v) => onChange(nextStateForTelemetryPatch(state, { metrics: v }, now())), ...(agentHandle ? { agentHandle: agentSubHandle({ base: agentHandle, action: 'metrics-toggle' }) } : {}) }), _jsx(ToggleRow, { label: contentLabel, hint: contentHint, checked: state.telemetry.content === true, onChange: (v) => onChange(nextStateForTelemetryPatch(state, { content: v }, now())), ...(agentHandle ? { agentHandle: agentSubHandle({ base: agentHandle, action: 'content-toggle' }) } : {}) })] }), _jsxs("div", { className: "jini-settings-subsection", children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { children: installationIdLabel }), _jsx("p", { className: "jini-hint", children: installationIdHint })] }) }), _jsx("div", { className: "jini-settings-field", children: _jsx("input", { type: "text", readOnly: true, value: state.installationId ?? optedOutLabel, "aria-label": installationIdLabel, ...agentHandleProps({}, { base: agentHandle, ...({ action: 'installation-id', role: 'field', label: installationIdLabel }) }) }) }), _jsxs("button", { type: "button", className: "jini-button jini-button-ghost", onClick: () => onChange(nextStateForDeleteMyData(state, now())), ...agentHandleProps({}, { base: agentHandle, ...({ action: 'delete-my-data', role: 'button', label: deleteMyDataLabel }) }), children: [_jsx(Icon, { name: "trash", size: 13 }), _jsx("span", { children: deleteMyDataLabel })] })] })] })) : null] }));
43
+ return (_jsxs("section", { className: "jini-settings-section jini-settings-privacy", children: [_jsxs("div", { className: "jini-settings-subsection", children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { children: consentKicker }), _jsx("p", { className: "jini-hint", children: consentLead })] }) }), _jsxs("div", { className: "jini-settings-privacy-toggles", children: [_jsx(ToggleRow, { label: metricsLabel, hint: metricsHint, checked: state.telemetry.metrics === true, onChange: (v) => onChange(nextStateForTelemetryPatch(state, { metrics: v }, now())), ...(agentHandle ? { agentHandle: agentSubHandle({ base: agentHandle, action: 'metrics-toggle' }) } : {}) }), _jsx(ToggleRow, { label: contentLabel, hint: contentHint, checked: state.telemetry.content === true, onChange: (v) => onChange(nextStateForTelemetryPatch(state, { content: v }, now())), ...(agentHandle ? { agentHandle: agentSubHandle({ base: agentHandle, action: 'content-toggle' }) } : {}) })] }), _jsx("p", { className: "jini-hint", children: consentFooter })] }), decided ? (_jsxs("div", { className: "jini-settings-subsection", children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { children: installationIdLabel }), _jsx("p", { className: "jini-hint", children: installationIdHint })] }) }), _jsx("div", { className: "jini-settings-field", children: _jsx("input", { type: "text", readOnly: true, value: state.installationId ?? optedOutLabel, "aria-label": installationIdLabel, ...agentHandleProps({}, { base: agentHandle, ...({ action: 'installation-id', role: 'field', label: installationIdLabel }) }) }) }), _jsxs("button", { type: "button", className: "jini-button jini-button-ghost", onClick: () => onChange(nextStateForDeleteMyData(state, now())), ...agentHandleProps({}, { base: agentHandle, ...({ action: 'delete-my-data', role: 'button', label: deleteMyDataLabel }) }), children: [_jsx(Icon, { name: "trash", size: 13 }), _jsx("span", { children: deleteMyDataLabel })] })] })) : null] }));
47
44
  }
48
45
  //# sourceMappingURL=PrivacyTab.js.map
@@ -1274,16 +1274,11 @@ select.jini-input {
1274
1274
  /* ============================================================
1275
1275
  Privacy tab
1276
1276
  ============================================================
1277
- `PrivacyTab.tsx`'s disclosure list was a bare `<dl>`/`<dt>`/`<dd>` (browser
1278
- default `<dd>` indent leaking through), its consent buttons
1279
- (`jini-privacy-consent-action*`) were unstyled text, and its toggle
1280
- switches (`jini-toggle-row*`) had NO rules anywhere in this file — not
1281
- even a partial match; `grep -i "toggle|switch"` against the file returned
1282
- nothing before this. Reference: `od-settings-privacy.png` — the two
1283
- disclosure items read as plain title+description pairs, the two consent
1284
- buttons sit side by side with the primary one filled, and the granular
1285
- toggles are real switches once a consent decision exists
1286
- (`hasMadeConsentDecision`). */
1277
+ `PrivacyTab.tsx`'s toggle switches (`jini-toggle-row*`) had NO rules
1278
+ anywhere in this file — not even a partial match; `grep -i
1279
+ "toggle|switch"` against the file returned nothing before this. The two
1280
+ choices show once, as these switches (the disclosure list and
1281
+ share/decline buttons that repeated them were removed 2026-10-26). */
1287
1282
 
1288
1283
  /* `PrivacyTab.tsx` puts its two `jini-section-head` headings ("Help improve
1289
1284
  this product", "Installation ID") in a bare `<h4>` with no className of
@@ -1301,66 +1296,6 @@ select.jini-input {
1301
1296
  color: var(--jini-text-strong);
1302
1297
  }
1303
1298
 
1304
- .jini-settings-privacy-disclosure {
1305
- margin: 0;
1306
- display: flex;
1307
- flex-direction: column;
1308
- gap: 10px;
1309
- }
1310
-
1311
- .jini-settings-privacy-disclosure > div {
1312
- display: flex;
1313
- flex-direction: column;
1314
- gap: 2px;
1315
- }
1316
-
1317
- .jini-settings-privacy-disclosure dt {
1318
- margin: 0;
1319
- font-size: 13px;
1320
- font-weight: 600;
1321
- color: var(--jini-text-strong);
1322
- }
1323
-
1324
- .jini-settings-privacy-disclosure dd {
1325
- margin: 0;
1326
- font-size: 12px;
1327
- color: var(--jini-text-muted);
1328
- }
1329
-
1330
- .jini-privacy-consent-actions {
1331
- display: flex;
1332
- gap: 10px;
1333
- }
1334
-
1335
- .jini-privacy-consent-action {
1336
- flex: 1 1 0;
1337
- padding: 9px 16px;
1338
- font: inherit;
1339
- font-size: 13px;
1340
- font-weight: 600;
1341
- color: var(--jini-text);
1342
- background: var(--jini-bg-panel);
1343
- border: 1px solid var(--jini-border-strong);
1344
- border-radius: var(--jini-radius-sm);
1345
- cursor: pointer;
1346
- }
1347
-
1348
- .jini-privacy-consent-action:hover {
1349
- border-color: var(--jini-text-muted);
1350
- }
1351
-
1352
- .jini-privacy-consent-action--primary {
1353
- color: var(--jini-accent-ink);
1354
- background: var(--jini-accent);
1355
- border-color: var(--jini-accent);
1356
- }
1357
-
1358
- .jini-privacy-consent-action--primary:hover,
1359
- .jini-privacy-consent-action--primary.is-active {
1360
- background: var(--jini-accent-hover);
1361
- border-color: var(--jini-accent-hover);
1362
- }
1363
-
1364
1299
  .jini-settings-privacy-toggles {
1365
1300
  display: flex;
1366
1301
  flex-direction: column;