From ffbda3fb5273a3a9892aa5cc3f464c0fb4473c73 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 13:38:41 -0700 Subject: [PATCH] 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) --- ...gent-detail-settings-theme-styling.test.ts | 103 +++++++++++ .../app/components/AgentDetailView.css | 113 ++++++++++++ .../app/components/AgentDetailView.tsx | 8 +- .../AgentPermissionPolicyEditor.css | 44 ++++- .../app/components/CustomModelDropdown.css | 84 +++++---- .../app/components/SkillMultiselect.css | 174 ++++++++++++------ .../app/components/SkillMultiselect.tsx | 4 + ...AgentDetailView.advanced-settings.test.tsx | 36 ++++ .../__tests__/SkillMultiselect.test.tsx | 57 ++++++ 9 files changed, 518 insertions(+), 105 deletions(-) create mode 100644 packages/dashboard/app/__tests__/agent-detail-settings-theme-styling.test.ts create mode 100644 packages/dashboard/app/components/__tests__/SkillMultiselect.test.tsx diff --git a/packages/dashboard/app/__tests__/agent-detail-settings-theme-styling.test.ts b/packages/dashboard/app/__tests__/agent-detail-settings-theme-styling.test.ts new file mode 100644 index 0000000000..af874869dc --- /dev/null +++ b/packages/dashboard/app/__tests__/agent-detail-settings-theme-styling.test.ts @@ -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(/(? { + 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"); + }); +}); diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index ac54ca90a9..356312bc7b 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -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 { diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 445eb2adf3..c6c17135e1 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -4628,6 +4628,10 @@ function ConfigTab({
+ {/* + 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. + */}
{agent.imageUrl ? ( - ) : null} diff --git a/packages/dashboard/app/components/AgentPermissionPolicyEditor.css b/packages/dashboard/app/components/AgentPermissionPolicyEditor.css index 523f4e54fc..453025075d 100644 --- a/packages/dashboard/app/components/AgentPermissionPolicyEditor.css +++ b/packages/dashboard/app/components/AgentPermissionPolicyEditor.css @@ -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 { diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css index b3159589dc..6efdacfd8f 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.css +++ b/packages/dashboard/app/components/CustomModelDropdown.css @@ -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))); } } diff --git a/packages/dashboard/app/components/SkillMultiselect.css b/packages/dashboard/app/components/SkillMultiselect.css index ca6f6e64d1..caed6bed46 100644 --- a/packages/dashboard/app/components/SkillMultiselect.css +++ b/packages/dashboard/app/components/SkillMultiselect.css @@ -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; + } } diff --git a/packages/dashboard/app/components/SkillMultiselect.tsx b/packages/dashboard/app/components/SkillMultiselect.tsx index d6761b15c1..d3dbc17b4b 100644 --- a/packages/dashboard/app/components/SkillMultiselect.tsx +++ b/packages/dashboard/app/components/SkillMultiselect.tsx @@ -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 (
{label && ( diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx index fbc3d04904..6f3992c81f 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx @@ -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(); + 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(); + 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(); + 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( diff --git a/packages/dashboard/app/components/__tests__/SkillMultiselect.test.tsx b/packages/dashboard/app/components/__tests__/SkillMultiselect.test.tsx new file mode 100644 index 0000000000..e888b8318b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SkillMultiselect.test.tsx @@ -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(); + + 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(); + + 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(); + + await waitFor(() => expect(screen.getByTestId("skill-dropdown")).toBeDisabled()); + expect(screen.getByTestId("remove-skill-skill-1")).toBeDisabled(); + expect(screen.getByTestId("skill-chip-skill-1")).toBeInTheDocument(); + }); +});