@jini-ai/ui 0.6.11 → 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.
- package/CHANGELOG.md +34 -0
- package/README.md +14 -0
- package/dist/features/admin-widgets/components/InfoTip.d.ts +3 -2
- package/dist/features/admin-widgets/components/InfoTip.hooks.d.ts +17 -2
- package/dist/features/admin-widgets/components/InfoTip.hooks.js +64 -14
- package/dist/features/admin-widgets/components/InfoTip.js +2 -2
- package/dist/features/admin-widgets/index.d.ts +1 -1
- package/dist/features/admin-widgets/index.js +1 -1
- package/dist/features/execution/react/components/AgentCliEnvFields.js +27 -8
- package/dist/features/execution/react/components/ByokProviderForm.js +16 -19
- package/dist/features/mcp-ui/surfaces/document.d.ts +1 -1
- package/dist/features/mcp-ui/surfaces/document.js +28 -0
- package/dist/features/mcp-ui/surfaces/fields.d.ts +28 -1
- package/dist/features/mcp-ui/surfaces/fields.js +5 -1
- package/dist/features/mcp-ui/surfaces/form.js +86 -16
- package/dist/features/mcp-ui/surfaces/text-input.d.ts +3 -3
- package/dist/features/mcp-ui/surfaces/text-input.js +26 -11
- package/dist/features/media-providers/catalog.d.ts +3 -0
- package/dist/features/media-providers/catalog.js +3 -0
- package/dist/features/media-providers/react/components/MediaProviderCard.js +5 -4
- package/dist/features/media-providers/react/components/media-providers-tab-labels.js +2 -1
- package/dist/features/panel-kit/fetch-query/adapter.react.d.ts +10 -5
- package/dist/features/panel-kit/fetch-query/adapter.react.js +9 -3
- package/dist/features/panel-kit/fetch-query/binding.react.d.ts +4 -0
- package/dist/features/panel-kit/fetch-query/binding.react.js +29 -10
- package/dist/features/panel-kit/fetch-query/index.d.ts +1 -1
- package/dist/features/panel-kit/fetch-query/index.js +1 -1
- package/dist/features/panel-kit/fetch-query/tanstack.d.ts +1 -1
- package/dist/features/panel-kit/fetch-query/tanstack.js +1 -1
- package/dist/features/privacy/react/components/PrivacyTab.d.ts +1 -3
- package/dist/features/privacy/react/components/PrivacyTab.js +5 -8
- package/dist/features/settings/dialog/styles/settings-dialog.css +5 -70
- package/dist/features/source-config-list/react/components/SourceConfigField.js +27 -26
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react/components/SecretInput.d.ts +47 -0
- package/dist/react/components/SecretInput.hooks.d.ts +26 -0
- package/dist/react/components/SecretInput.hooks.js +25 -0
- package/dist/react/components/SecretInput.js +30 -0
- package/dist/styles/lightbox.css +1 -1
- package/dist/styles/variables.css +1 -1
- 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
|
-
//
|
|
128
|
-
// discard line breaks
|
|
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
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
/**
|
|
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
|
|
22
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
35
|
-
const
|
|
36
|
-
return `<button type="button" class="mcpui-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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 {
|
|
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
|
-
|
|
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}` }) }) })
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
const
|
|
12
|
-
if (!
|
|
25
|
+
function useBinding() {
|
|
26
|
+
const binding = useContext(AdapterContext);
|
|
27
|
+
if (!binding)
|
|
13
28
|
throw new Error('Fetch-query hooks require FetchQueryProvider');
|
|
14
|
-
return
|
|
29
|
+
return binding;
|
|
15
30
|
}
|
|
16
31
|
/** Library-free read facade; the provider selects its implementation. */
|
|
17
|
-
export const 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 = {}) =>
|
|
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 = () =>
|
|
36
|
+
export const useInvalidate = () => useBinding().adapter.useInvalidate();
|
|
22
37
|
/** Promise loader using the same cache as reactive reads. */
|
|
23
|
-
export const 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 (
|
|
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
|
|
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,
|
|
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 (
|
|
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,
|
|
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("
|
|
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
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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;
|