FN-8550: theme agent Settings controls
Apply token-driven styling and responsive behavior across Agent Settings controls. - Theme configuration inputs, runtime tabs, avatar actions, policy editor, model combobox, and skill multiselect states - Add coverage for Settings styling contracts, runtime/avatar semantics, and skill selection states - Correct the model-filter clear control sizing with a valid themed font token Files changed: .../agent-detail-settings-theme-styling.test.ts | 103 ++++++++++++ .../dashboard/app/components/AgentDetailView.css | 113 +++++++++++++ .../dashboard/app/components/AgentDetailView.tsx | 8 +- .../app/components/AgentPermissionPolicyEditor.css | 44 +++++- .../app/components/CustomModelDropdown.css | 84 +++++----- .../dashboard/app/components/SkillMultiselect.css | 174 ++++++++++++++------- .../dashboard/app/components/SkillMultiselect.tsx | 4 + .../AgentDetailView.advanced-settings.test.tsx | 36 +++++ .../components/__tests__/SkillMultiselect.test.tsx | 57 +++++++ 9 files changed, 518 insertions(+), 105 deletions(-) Fusion-Task-Id: FN-8550 Fusion-Task-Lineage: 5a55ded8-2cd7-4364-855a-3763d5d8af95 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
Agent Settings is composed from local and portaled controls, so its theme contract must guard every control family and state rather than relying on a screenshot of one populated dark-theme form. These source-level assertions keep surfaces, text, borders, focus, disabled, selected, validation, and responsive layouts token-driven across themes.
|
||||
*/
|
||||
const APP = resolve(__dirname, "..");
|
||||
const css = {
|
||||
detail: readFileSync(resolve(APP, "components/AgentDetailView.css"), "utf8"),
|
||||
model: readFileSync(resolve(APP, "components/CustomModelDropdown.css"), "utf8"),
|
||||
skills: readFileSync(resolve(APP, "components/SkillMultiselect.css"), "utf8"),
|
||||
policy: readFileSync(resolve(APP, "components/AgentPermissionPolicyEditor.css"), "utf8"),
|
||||
};
|
||||
|
||||
function block(source: string, selector: string): string {
|
||||
const start = source.indexOf(selector);
|
||||
expect(start, `Missing ${selector}`).toBeGreaterThanOrEqual(0);
|
||||
const open = source.indexOf("{", start);
|
||||
let depth = 1;
|
||||
let end = open + 1;
|
||||
while (depth > 0 && end < source.length) {
|
||||
if (source[end] === "{") depth += 1;
|
||||
if (source[end] === "}") depth -= 1;
|
||||
end += 1;
|
||||
}
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function mediaBlock(source: string, query: string): string {
|
||||
return block(source, `@media (max-width: ${query})`);
|
||||
}
|
||||
|
||||
function expectsTokenizedControl(source: string, selector: string) {
|
||||
const rule = block(source, selector);
|
||||
expect(rule).toMatch(/(?:background|color|border(?:-color)?|box-shadow):\s*(?:var\(|color-mix\()/);
|
||||
}
|
||||
|
||||
describe("agent Settings theme styling", () => {
|
||||
it("keeps each Settings control family and state theme-token driven", () => {
|
||||
const inventory: Array<[string, string]> = [
|
||||
[css.detail, ".config-section"],
|
||||
[css.detail, ".config-section .input,"],
|
||||
[css.detail, ".agent-avatar-editor-actions .agent-avatar-editor-action"],
|
||||
[css.detail, ".config-runtime-tab"],
|
||||
[css.detail, ".config-runtime-tab:focus-visible"],
|
||||
[css.detail, ".config-runtime-tab.active"],
|
||||
[css.model, ".model-combobox-trigger"],
|
||||
[css.model, ".model-combobox-dropdown"],
|
||||
[css.model, ".model-combobox-search"],
|
||||
[css.model, ".model-combobox-option--selected"],
|
||||
[css.skills, ".skill-multiselect"],
|
||||
[css.skills, ".skill-multiselect-dropdown"],
|
||||
[css.skills, ".skill-chip"],
|
||||
[css.skills, ".skill-chip-remove:focus-visible"],
|
||||
[css.skills, ".skill-multiselect-loading,"],
|
||||
[css.policy, ".agent-policy-editor"],
|
||||
[css.policy, ".agent-policy-row"],
|
||||
[css.policy, ".agent-policy-tool-row,"],
|
||||
];
|
||||
inventory.forEach(([source, selector]) => expectsTokenizedControl(source, selector));
|
||||
|
||||
expect(css.detail).toContain(".config-section .input::placeholder");
|
||||
expect(css.detail).toContain(".config-section .input:disabled");
|
||||
expect(block(css.detail, ".input--error")).toContain("var(--color-error)");
|
||||
expect(block(css.detail, ".config-saved-indicator")).toContain("var(--color-success)");
|
||||
expect(block(css.skills, ".skill-chip-remove:disabled")).toContain("var(--opacity-disabled");
|
||||
expect(block(css.model, ".model-combobox-trigger:disabled")).toContain("var(--opacity-disabled");
|
||||
});
|
||||
|
||||
it("uses no raw colors or nonzero pixel declarations in scoped Settings rules", () => {
|
||||
const scopedRules = [
|
||||
block(css.detail, ".config-section .input,"), block(css.detail, ".agent-avatar-editor-actions"), block(css.detail, ".config-runtime-tab"),
|
||||
block(css.model, ".model-combobox-trigger"), block(css.model, ".model-combobox-dropdown"), block(css.skills, ".skill-multiselect"), block(css.skills, ".skill-chip"), block(css.policy, ".agent-policy-editor"),
|
||||
].join("\n");
|
||||
expect(scopedRules).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i);
|
||||
expect(scopedRules).not.toMatch(/(?<![\w-])(?:[1-9]\d*)px\b/);
|
||||
});
|
||||
|
||||
it("keeps desktop and compact Settings controls mechanically responsive", () => {
|
||||
const detailMobile = mediaBlock(css.detail, "768px");
|
||||
[".config-tab", ".config-section", ".config-section .input", ".config-actions", ".config-actions .btn", ".agent-avatar-editor-actions", ".config-runtime-tabs", ".config-runtime-tab"].forEach((selector) => expect(detailMobile).toContain(selector));
|
||||
expect(detailMobile).toMatch(/\.config-actions\s*\{[\s\S]*flex-direction:\s*column/);
|
||||
expect(detailMobile).toMatch(/\.config-actions \.btn\s*\{[\s\S]*width:\s*100%/);
|
||||
expect(detailMobile).toMatch(/\.config-runtime-tabs\s*\{[\s\S]*grid-template-columns:\s*1fr/);
|
||||
|
||||
const compact = mediaBlock(css.detail, "480px");
|
||||
expect(compact).toContain(".config-actions");
|
||||
expect(compact).toContain(".agent-avatar-editor-actions");
|
||||
expect(compact).toContain(".config-runtime-tabs");
|
||||
expect(compact).toContain(".skill-multiselect");
|
||||
|
||||
const modelMobile = mediaBlock(css.model, "768px");
|
||||
expect(modelMobile).toContain(".model-combobox-dropdown");
|
||||
const policyMobile = mediaBlock(css.policy, "768px");
|
||||
expect(policyMobile).toContain(".agent-policy-row");
|
||||
expect(policyMobile).toContain(".agent-policy-tool-row");
|
||||
const skillsMobile = mediaBlock(css.skills, "768px");
|
||||
expect(skillsMobile).toContain(".skill-multiselect");
|
||||
expect(skillsMobile).toContain(".skill-chip");
|
||||
});
|
||||
});
|
||||
@@ -1075,16 +1075,89 @@ FNXC:AgentDetailView 2026-06-26-01:00:
|
||||
}
|
||||
|
||||
/* --- Config Tab --- */
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
Agent Settings composes native inputs, buttons, editors, and portaled controls. Scope the complete surface/state contract here so every theme receives readable foregrounds, surfaces, borders, placeholders, focus rings, and disabled treatment without changing configuration behavior.
|
||||
*/
|
||||
.config-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: calc(var(--space-md) + var(--space-sm));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-section .input,
|
||||
.config-section .select,
|
||||
.config-section textarea {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.config-section .input::placeholder,
|
||||
.config-section textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.config-section .input:hover:not(:disabled):not([readonly]),
|
||||
.config-section .select:hover:not(:disabled),
|
||||
.config-section textarea:hover:not(:disabled):not([readonly]) {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.config-section .input:focus-visible,
|
||||
.config-section .select:focus-visible,
|
||||
.config-section textarea:focus-visible,
|
||||
.config-section input[type="checkbox"]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.config-section .input:disabled,
|
||||
.config-section .select:disabled,
|
||||
.config-section textarea:disabled,
|
||||
.config-section .btn:disabled,
|
||||
.config-runtime-tab:disabled,
|
||||
.agent-avatar-editor-action:disabled {
|
||||
opacity: var(--opacity-disabled, 0.6);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.config-section .input[readonly],
|
||||
.config-section textarea[readonly] {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.config-section input[type="checkbox"] {
|
||||
accent-color: var(--todo);
|
||||
}
|
||||
|
||||
.agent-avatar-editor-actions .agent-avatar-editor-action {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.agent-avatar-editor-actions .agent-avatar-editor-action:hover:not(:disabled) {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-avatar-editor-actions .agent-avatar-editor-action:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.config-section h3 {
|
||||
@@ -1672,6 +1745,33 @@ FNXC:AgentDetailView 2026-06-26-01:00:
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-runtime-tab {
|
||||
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-avatar-editor-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.agent-avatar-editor-actions .agent-avatar-editor-action {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skill-multiselect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-multiselect-dropdown,
|
||||
.skill-multiselect-chips {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-chip {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.agent-detail-overlay {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
@@ -2053,6 +2153,19 @@ FNXC:AgentDetailView 2026-06-26-01:00:
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.config-actions,
|
||||
.agent-avatar-editor-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-runtime-tabs {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.skill-multiselect {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.agent-detail-footer .divider,
|
||||
.agent-detail-footer .text-muted,
|
||||
.agent-detail-footer .link {
|
||||
|
||||
@@ -4628,6 +4628,10 @@ function ConfigTab({
|
||||
<label>{t("agents.avatarLabel", "Avatar")}</label>
|
||||
<div className="agent-avatar-editor">
|
||||
<AgentAvatar agent={agent} size={64} className="agent-avatar-editor-preview" />
|
||||
{/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
Avatar actions retain their existing upload/remove behavior while explicit action classes let the Settings theme contract cover pending, hover, and keyboard-focus states without styling the hidden file input as a visible control.
|
||||
*/}
|
||||
<div className="agent-avatar-editor-actions">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
@@ -4645,14 +4649,14 @@ function ConfigTab({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
className="btn btn-sm agent-avatar-editor-action"
|
||||
disabled={isAvatarPending}
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
>
|
||||
{t("agents.uploadAvatar", "Upload Avatar")}
|
||||
</button>
|
||||
{agent.imageUrl ? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleAvatarDelete()} disabled={isAvatarPending}>
|
||||
<button type="button" className="btn btn-sm agent-avatar-editor-action" onClick={() => void handleAvatarDelete()} disabled={isAvatarPending}>
|
||||
{t("agents.removeAvatar", "Remove Avatar")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
The permission-policy editor nests native fields and rule actions inside Agent Settings. Keep rows, summaries, controls, disabled states, and keyboard focus on shared theme tokens so policy behavior remains unchanged while controls are legible in every theme.
|
||||
*/
|
||||
.agent-policy-editor {
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-policy-editor .input,
|
||||
.agent-policy-editor .select {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.agent-policy-editor .input:focus-visible,
|
||||
.agent-policy-editor .select:focus-visible,
|
||||
.agent-policy-editor .btn:focus-visible,
|
||||
.agent-policy-exempt summary:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.agent-policy-editor .input:disabled,
|
||||
.agent-policy-editor .select:disabled,
|
||||
.agent-policy-editor .btn:disabled {
|
||||
opacity: var(--opacity-disabled, 0.6);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-policy-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -18,7 +48,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) minmax(10rem, 14rem);
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -54,7 +84,7 @@
|
||||
}
|
||||
|
||||
.agent-policy-tool-rules {
|
||||
border: 1px solid var(--border);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
padding: var(--space-sm);
|
||||
@@ -86,6 +116,8 @@
|
||||
grid-template-columns: minmax(0, 1fr) minmax(9rem, 12rem) auto;
|
||||
gap: var(--space-sm);
|
||||
align-items: end;
|
||||
color: var(--text);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.agent-policy-tool-row label,
|
||||
@@ -102,7 +134,7 @@
|
||||
}
|
||||
|
||||
.agent-policy-exempt {
|
||||
border: 1px solid var(--border);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
padding: var(--space-sm);
|
||||
@@ -120,6 +152,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agent-policy-editor {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-policy-row,
|
||||
.agent-policy-tool-row,
|
||||
.agent-policy-tool-add {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* === Model Combobox Component === */
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
The model combobox is portaled outside Agent Settings but remains part of its runtime control. Its trigger, search, menu, options, favorites, and thinking selector must inherit dashboard tokens for dark, light, disabled, hover, and keyboard-focus states.
|
||||
*/
|
||||
|
||||
.model-combobox {
|
||||
position: relative;
|
||||
@@ -12,11 +16,11 @@
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-primary);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
@@ -26,13 +30,14 @@
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.model-combobox-trigger:focus {
|
||||
.model-combobox-trigger:focus,
|
||||
.model-combobox-trigger:focus-visible {
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.model-combobox-trigger:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: var(--opacity-disabled, 0.6);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -52,10 +57,10 @@
|
||||
}
|
||||
|
||||
.model-combobox-trigger-arrow {
|
||||
font-size: 10px;
|
||||
font-size: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
transition: transform 0.15s;
|
||||
margin-left: var(--space-sm);
|
||||
transition: transform var(--duration-fast);
|
||||
}
|
||||
|
||||
.model-combobox-trigger[aria-expanded="true"] .model-combobox-trigger-arrow {
|
||||
@@ -65,12 +70,12 @@
|
||||
.model-combobox-dropdown {
|
||||
position: fixed;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
/* Must sit above floating dashboard panels and the shared floating-window stack (10100+). */
|
||||
z-index: 11000;
|
||||
max-height: 320px;
|
||||
max-height: calc(var(--space-2xl) * 10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -80,18 +85,18 @@
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--btn-border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.model-combobox-search {
|
||||
width: 100%;
|
||||
padding: 10px 36px 10px 12px;
|
||||
padding: var(--space-sm) calc(var(--space-xl) + var(--space-md)) var(--space-sm) var(--space-md);
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -99,22 +104,23 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.model-combobox-search:focus {
|
||||
.model-combobox-search:focus,
|
||||
.model-combobox-search:focus-visible {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.model-combobox-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
right: var(--space-sm);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
padding: 0 var(--space-xs);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
@@ -124,11 +130,11 @@
|
||||
|
||||
.model-combobox-results-count {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
padding: calc(var(--space-sm) - var(--space-xs) * 0.5) var(--space-md);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--btn-border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.model-combobox-thinking {
|
||||
@@ -139,7 +145,7 @@
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--btn-border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.model-combobox-thinking-label {
|
||||
@@ -252,7 +258,7 @@ FN-8355 requires the portaled list to remain the touch-scroll owner after filter
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
transition: background var(--transition-instant);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -288,7 +294,7 @@ FN-8355 requires the portaled list to remain the touch-scroll owner after filter
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -298,10 +304,10 @@ FN-8355 requires the portaled list to remain the touch-scroll owner after filter
|
||||
}
|
||||
|
||||
.model-combobox-option-id {
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
margin-left: 8px;
|
||||
margin-left: var(--space-sm);
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -310,7 +316,7 @@ FN-8355 requires the portaled list to remain the touch-scroll owner after filter
|
||||
}
|
||||
|
||||
.model-combobox-group {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--btn-border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.model-combobox-group:last-child {
|
||||
@@ -348,8 +354,8 @@ Provider headers must remain visible while their long model groups scroll in the
|
||||
|
||||
/* Provider favorite star (in optgroup header) */
|
||||
.model-combobox-optgroup-favorite {
|
||||
padding: 2px 4px;
|
||||
font-size: 14px;
|
||||
padding: calc(var(--space-xs) * 0.5) var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--star-idle);
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -394,8 +400,8 @@ Provider headers must remain visible while their long model groups scroll in the
|
||||
|
||||
/* Model favorite star (inside option row) */
|
||||
.model-combobox-option-favorite {
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
padding: calc(var(--space-xs) * 0.5) var(--space-xs);
|
||||
font-size: var(--space-md);
|
||||
color: var(--star-idle);
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -413,20 +419,20 @@ Provider headers must remain visible while their long model groups scroll in the
|
||||
|
||||
/* Favorited model pinned row */
|
||||
.model-combobox-option--favorite {
|
||||
padding-left: 12px;
|
||||
padding-left: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.model-combobox-divider {
|
||||
height: 1px;
|
||||
height: var(--btn-border-width);
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.model-combobox-no-results {
|
||||
padding: 16px 12px;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -436,11 +442,11 @@ Provider headers must remain visible while their long model groups scroll in the
|
||||
.model-combobox-dropdown {
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh; /* Use dynamic viewport height where supported */
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
width: min(calc(var(--space-xl) * 15), calc(100vw - var(--space-2xl)));
|
||||
}
|
||||
|
||||
.model-combobox-search {
|
||||
font-size: 16px;
|
||||
font-size: var(--space-lg);
|
||||
}
|
||||
|
||||
.model-combobox-option,
|
||||
@@ -449,11 +455,11 @@ Provider headers must remain visible while their long model groups scroll in the
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.model-combobox-dropdown {
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
width: min(calc(var(--space-xl) * 15), calc(100vw - var(--space-2xl)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/* === Skill Multiselect === */
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
Skills can be empty, loading, populated, all selected, or disabled inside Agent Settings. Keep its select, chips, and removable actions on dashboard tokens so these states remain legible in every theme without changing selection or duplicate-prevention behavior.
|
||||
*/
|
||||
.skill-multiselect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.skill-multiselect-label {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -14,23 +19,24 @@
|
||||
.skill-multiselect-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.skill-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
gap: var(--space-xs);
|
||||
padding: calc(var(--space-xs) * 0.5) calc(var(--space-sm) - var(--space-xs) * 0.5);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
border: var(--btn-border-width) solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.skill-chip-name {
|
||||
max-width: 150px;
|
||||
max-width: calc(var(--space-xl) * 6 + var(--space-md));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -40,70 +46,98 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: calc(var(--space-md) + var(--space-xs));
|
||||
height: calc(var(--space-md) + var(--space-xs));
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
border: var(--btn-border-width) solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-tight);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.skill-chip-remove:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.skill-chip-remove:focus-visible {
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.skill-chip-remove:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: var(--opacity-disabled, 0.6);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skill-multiselect-add {
|
||||
min-height: 32px;
|
||||
min-height: calc(var(--space-xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.skill-multiselect-loading,
|
||||
.skill-multiselect-empty {
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.skill-multiselect-dropdown {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.skill-multiselect-dropdown:hover:not(:disabled) {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skill-multiselect-dropdown:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.skill-multiselect-dropdown:disabled {
|
||||
opacity: var(--opacity-disabled, 0.6);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-dialog-skills-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
margin-top: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* Agent detail divider */
|
||||
|
||||
/* Skill badge row for Dashboard tab */
|
||||
.skill-badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.skill-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
padding: calc(var(--space-xs) * 0.5) calc(var(--space-sm) - var(--space-xs) * 0.5);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
/* Skill badges in AgentsView */
|
||||
.badge-skill {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
@@ -112,60 +146,52 @@
|
||||
.agent-board-skills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.skill-badge-sm {
|
||||
.skill-badge-sm,
|
||||
.agent-tree__skill {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
padding: calc(var(--space-xs) * 0.25) calc(var(--space-sm) - var(--space-xs) * 0.75);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.skill-badge-extra {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Tree view skill badge */
|
||||
.agent-tree__skill {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
margin-left: 6px;
|
||||
margin-left: calc(var(--space-sm) - var(--space-xs) * 0.5);
|
||||
}
|
||||
|
||||
/* Org chart skill badge */
|
||||
.org-chart-node__skill {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
padding: calc(var(--space-xs) * 0.25) calc(var(--space-sm) - var(--space-xs) * 0.75);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
/* Agent detail text-muted */
|
||||
.text-muted {
|
||||
.text-muted,
|
||||
.text-secondary {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Agent detail link */
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
color: var(--todo);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -174,7 +200,35 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Text secondary helper */
|
||||
.text-secondary {
|
||||
color: var(--text-muted);
|
||||
@media (max-width: 768px) {
|
||||
.skill-multiselect,
|
||||
.skill-multiselect-add,
|
||||
.skill-multiselect-dropdown,
|
||||
.skill-multiselect-chips {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-multiselect-chips {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.skill-chip {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.skill-chip-remove {
|
||||
min-width: calc(var(--space-lg) + var(--space-xs));
|
||||
min-height: calc(var(--space-lg) + var(--space-xs));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.skill-multiselect {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.skill-chip {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ export function SkillMultiselect({
|
||||
// Skills available to add (not yet selected)
|
||||
const availableSkills = skills.filter((s) => !value.includes(s.id));
|
||||
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
These state-specific classes are the stable theming surface for empty, loading, populated, all-selected, and disabled skill controls. Preserve them while maintaining the existing selection and duplicate-prevention behavior.
|
||||
*/
|
||||
return (
|
||||
<div className="skill-multiselect" data-testid="skill-multiselect">
|
||||
{label && (
|
||||
|
||||
@@ -214,6 +214,42 @@ describe("Advanced Settings", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
Settings runtime tabs and avatar actions retain their semantic roles and pending-disabled behavior while scoped CSS themes their selected, hover, and focus states. Rendered coverage keeps those controls present across avatar and runtime selections.
|
||||
*/
|
||||
it("renders avatar actions for absent and existing avatars", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToSettings(user);
|
||||
expect(await screen.findByRole("button", { name: "Upload Avatar" })).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "Remove Avatar" })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ imageUrl: "https://example.test/avatar.png" }));
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToSettings(user);
|
||||
expect(await screen.findByRole("button", { name: "Upload Avatar" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Remove Avatar" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("preserves selected and unselected runtime-tab semantics", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToSettings(user);
|
||||
|
||||
const builtIn = await screen.findByRole("tab", { name: "Built-in Model" });
|
||||
const runtime = screen.getByRole("tab", { name: "Plugin Runtime" });
|
||||
expect(builtIn).toHaveAttribute("aria-selected", "true");
|
||||
expect(runtime).toHaveAttribute("aria-selected", "false");
|
||||
expect(builtIn).toHaveClass("active");
|
||||
|
||||
await user.click(runtime);
|
||||
expect(runtime).toHaveAttribute("aria-selected", "true");
|
||||
expect(builtIn).toHaveAttribute("aria-selected", "false");
|
||||
expect(runtime).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("renders advanced settings form fields on Settings tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
const { fetchDiscoveredSkills } = vi.hoisted(() => ({ fetchDiscoveredSkills: vi.fn() }));
|
||||
vi.mock("../../api", () => ({ fetchDiscoveredSkills }));
|
||||
|
||||
import { SkillMultiselect } from "../SkillMultiselect";
|
||||
|
||||
/*
|
||||
FNXC:AgentSettingsTheming 2026-07-23-13:01:
|
||||
The Settings theme contract covers real skill control states, not only CSS text. Keep loading, empty, populated, removal, duplicate prevention, and disabled controls rendered so their stable classes and accessibility semantics cannot regress while themes evolve.
|
||||
*/
|
||||
describe("SkillMultiselect", () => {
|
||||
const skills = [
|
||||
{ id: "skill-1", name: "Skill One" },
|
||||
{ id: "skill-2", name: "Skill Two" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
fetchDiscoveredSkills.mockReset();
|
||||
});
|
||||
|
||||
it("renders loading then empty state", async () => {
|
||||
let resolveSkills!: (value: typeof skills) => void;
|
||||
fetchDiscoveredSkills.mockReturnValue(new Promise((resolve) => { resolveSkills = resolve; }));
|
||||
render(<SkillMultiselect value={[]} onChange={vi.fn()} id="skills" />);
|
||||
|
||||
expect(screen.getByTestId("skills-loading")).toHaveClass("skill-multiselect-loading");
|
||||
resolveSkills([]);
|
||||
expect(await screen.findByTestId("skills-empty")).toHaveTextContent("No skills discovered");
|
||||
});
|
||||
|
||||
it("adds available skills once and renders removable populated chips", async () => {
|
||||
fetchDiscoveredSkills.mockResolvedValue(skills);
|
||||
const onChange = vi.fn();
|
||||
render(<SkillMultiselect value={["skill-1"]} onChange={onChange} id="skills" />);
|
||||
|
||||
expect(await screen.findByTestId("skill-chip-skill-1")).toHaveTextContent("Skill One");
|
||||
const dropdown = screen.getByTestId("skill-dropdown") as HTMLSelectElement;
|
||||
expect(Array.from(dropdown.options).map((option) => option.value)).not.toContain("skill-1");
|
||||
fireEvent.change(dropdown, { target: { value: "skill-2" } });
|
||||
expect(onChange).toHaveBeenCalledWith(["skill-1", "skill-2"]);
|
||||
|
||||
fireEvent.click(screen.getByTestId("remove-skill-skill-1"));
|
||||
expect(onChange).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it("disables dropdown and removal controls without hiding populated state", async () => {
|
||||
fetchDiscoveredSkills.mockResolvedValue(skills);
|
||||
render(<SkillMultiselect value={["skill-1"]} onChange={vi.fn()} id="skills" disabled />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("skill-dropdown")).toBeDisabled());
|
||||
expect(screen.getByTestId("remove-skill-skill-1")).toBeDisabled();
|
||||
expect(screen.getByTestId("skill-chip-skill-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user