From 55e058d1e6716de53c755246eeb03f08f5203391 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Jun 2026 21:52:18 +0100 Subject: [PATCH] Converge API token scope pills on shared selectable primitive --- .../v6/internal/subsystems/api-contracts.md | 4 + .../subsystems/frontend-primitives.md | 11 +++ .../internal/subsystems/security-privacy.md | 7 ++ .../scripts/shared-template-registry.json | 53 +++++++++++++ .../components/Settings/APITokenManager.tsx | 22 +++--- .../__tests__/APITokenManager.test.tsx | 12 +++ .../shared/SelectablePillButton.test.tsx | 69 ++++++++++++++++ .../shared/SelectablePillButton.tsx | 34 ++++++++ .../SharedPrimitives.guardrails.test.ts | 79 +++++++++++++++++++ .../components/shared/selectablePillModel.ts | 32 ++++++++ .../release_control/subsystem_lookup_test.py | 2 +- 11 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 frontend-modern/src/components/shared/SelectablePillButton.test.tsx create mode 100644 frontend-modern/src/components/shared/SelectablePillButton.tsx create mode 100644 frontend-modern/src/components/shared/selectablePillModel.ts diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 45b52df37..590a883c0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -381,6 +381,10 @@ payload shape change when the portal presents compact client rows. Token refresh/loading state remains API contract data only while the visible spinner shell routes through frontend-primitives-owned `LoadingSpinner` instead of an API-token-local animate-spin SVG. + Token scope selector semantics stay API-contract owned, but the visible + pressed/unpressed selector pill shell routes through frontend-primitives + `SelectablePillButton` instead of API-token-local rounded-full selector + classes. 11. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `security-privacy`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary. 12. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` shared with `agent-lifecycle`: the inline node credential slot is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 13. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` shared with `agent-lifecycle`: the pure infrastructure operations inventory/install model is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 50a02598a..71476b022 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2576,6 +2576,13 @@ owns variant resolution plus disabled selection/change runtime, and variant class catalog, compact-label policy, and segmented button class selection. Future filter-button-group work should extend those owners instead of pushing label truncation or segmented variant policy back into the shell. +Pressed/unpressed selector pills follow the same primitive rule. +`frontend-modern/src/components/shared/SelectablePillButton.tsx` owns the +pressed button shell and `aria-pressed` wiring, while +`frontend-modern/src/components/shared/selectablePillModel.ts` owns the active +and inactive pill class catalog. API token scope surfaces may own the security +scope labels and click handlers, but must not recreate rounded-full selector +pill class strings locally. Filter-toolbar segmented controls must delegate to this primitive rather than calling `segmentedButtonClass` directly, and icon+text labels must render as one inline-flex button label so compact bars keep the v5 single-line control @@ -2884,6 +2891,10 @@ active-button tone, disabled-option behavior, pressed-state semantics, compact labels, and horizontal scroll treatment through the shared shell/state/model split. Settings and compact feature surfaces must compose that primitive instead of copying active-button selector styling locally. +Selectable pill buttons are registry-backed too. `SelectablePillButton` owns +rounded pressed/unpressed selector pills, including active tone, disabled +treatment, focus ring, and `aria-pressed`; settings and security surfaces must +compose that primitive instead of copying rounded-full active selector styling. `ResourcePicker` report-domain filters are part of that boundary: the picker owns the reportable resource categories and labels, but the type selector shell must come from `FilterButtonGroup`. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index a9c3c9369..adb257b88 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -84,6 +84,9 @@ controls as normal product settings. negative-margin wrappers around the inventory grid. Scope-reference documentation links compose `ExternalTextLink` for shared rel/target safety and link chrome. + API token scope selectors follow the same split: security/privacy owns + the wildcard, preset, and custom scope semantics, while frontend-primitives + owns the pressed selector pill chrome through `SelectablePillButton`. 4. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `api-contracts`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary. 5. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `frontend-primitives`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary. 6. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `frontend-primitives`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary. @@ -214,6 +217,10 @@ controls as normal product settings. primitive. Security/privacy owns the token-management trust copy and refresh semantics; frontend-primitives owns spinner shell, tone, and accessible status behavior. +6b. Keep API token scope selector pills on the shared `SelectablePillButton` + primitive. Security/privacy owns scope authority, wildcard behavior, preset + membership, and custom scope toggles; frontend-primitives owns active and + inactive pill tone, focus, disabled treatment, and pressed-state wiring. 6. Keep the shared storage-directory and secure storage-file hardening helper aligned with the crypto manager plus control-plane magic-link key and store handling whenever runtime data-root ownership assumptions change. 7. Keep auth-env ingestion, hosted commercial base URL validation, and shared fingerprint-verifier TLS defaults aligned whenever runtime auth loading, diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index d3dac3aba..45aeb2a83 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -2941,6 +2941,32 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "selectable-pill-button-shell", + "category": "selectable-pill-button", + "summary": "Pressed/unpressed rounded pill selectors must compose SelectablePillButton so active tone, disabled treatment, focus ring, and aria-pressed semantics stay shared instead of being recreated inside settings surfaces.", + "canonical": { + "path": "src/components/shared/SelectablePillButton.tsx", + "export": "SelectablePillButton" + }, + "requiredConsumers": [{ "path": "src/components/Settings/APITokenManager.tsx" }], + "forbiddenPatterns": [ + { + "path": "src/components/Settings/APITokenManager.tsx", + "patterns": [ + "inline-flex min-h-10 sm:min-h-10 items-center rounded-full border px-3 py-2 text-sm font-semibold transition", + "min-h-10 sm:min-h-10 rounded-full border px-3 py-2 text-sm font-semibold transition", + "border-blue-500 bg-blue-600 text-white shadow-sm", + "hover:border-blue-400 hover:text-blue-600 dark:hover:border-blue-400 dark:hover:text-blue-200" + ] + } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/SelectablePillButton.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "chart-visibility-toggle-button", "category": "display-toggle", @@ -4854,6 +4880,33 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "selectable-pill-button-local-scope-selector-styles", + "category": "selectable-pill-button", + "summary": "Settings and feature surfaces must not copy the rounded active/inactive selector pill styling owned by SelectablePillButton.", + "canonical": { + "path": "src/components/shared/selectablePillModel.ts", + "export": "getSelectablePillButtonClass" + }, + "scopes": ["src/components/Settings", "src/features", "src/pages"], + "extensions": [".tsx"], + "allPatterns": [ + "rounded-full border px-3 py-2 text-sm font-semibold transition", + "border-blue-500 bg-blue-600 text-white shadow-sm", + "hover:border-blue-400 hover:text-blue-600" + ], + "legacyReason": "Retired migration debt. Pressed/unpressed pill selectors belong to SelectablePillButton.", + "allowedPaths": [], + "ignoredPaths": [ + "src/components/Settings/__tests__/APITokenManager.test.tsx", + "src/components/shared/SelectablePillButton.test.tsx" + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/SelectablePillButton.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "button-secondary-command-local-shell", "category": "action-button", diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index f8fc5ae90..9f16def4e 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -5,6 +5,7 @@ import { ExternalTextLink } from '@/components/shared/ExternalTextLink'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { PulseDataGrid } from '@/components/shared/PulseDataGrid'; +import { SelectablePillButton } from '@/components/shared/SelectablePillButton'; import BadgeCheck from 'lucide-solid/icons/badge-check'; import { MONITORING_READ_SCOPE } from '@/constants/apiScopes'; import { @@ -460,27 +461,25 @@ export const APITokenManager: Component = (props) => {
- + {(preset) => ( - + )}
@@ -502,15 +501,14 @@ export const APITokenManager: Component = (props) => { {(option) => { const isActive = () => selectedScopes().includes(option.value); return ( - + ); }} diff --git a/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx b/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx index ef461f04c..460e6f01b 100644 --- a/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx @@ -10,6 +10,7 @@ import { DOCKER_REPORT_SCOPE, } from '@/constants/apiScopes'; import apiAccessPanelSource from '../APIAccessPanel.tsx?raw'; +import apiTokenManagerSource from '../APITokenManager.tsx?raw'; import { APITokenManager } from '../APITokenManager'; const listTokensMock = vi.fn(); @@ -147,6 +148,17 @@ describe('APITokenManager', () => { }); it('creates scoped tokens from the canonical preset path', async () => { + expect(apiTokenManagerSource).toContain('@/components/shared/SelectablePillButton'); + expect(apiTokenManagerSource.match(/ ); await waitFor(() => { diff --git a/frontend-modern/src/components/shared/SelectablePillButton.test.tsx b/frontend-modern/src/components/shared/SelectablePillButton.test.tsx new file mode 100644 index 000000000..fe9bcc393 --- /dev/null +++ b/frontend-modern/src/components/shared/SelectablePillButton.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SelectablePillButton } from './SelectablePillButton'; +import selectablePillButtonSource from './SelectablePillButton.tsx?raw'; +import selectablePillModelSource from './selectablePillModel.ts?raw'; + +describe('SelectablePillButton', () => { + afterEach(() => { + cleanup(); + }); + + it('keeps pressed semantics and class ownership in the shared primitive', () => { + expect(selectablePillButtonSource).toContain('getSelectablePillButtonClass'); + expect(selectablePillButtonSource).toContain("aria-pressed={local.active ? 'true' : 'false'}"); + expect(selectablePillButtonSource).not.toContain('border-blue-500 bg-blue-600'); + expect(selectablePillButtonSource).not.toContain('hover:border-blue-400'); + + expect(selectablePillModelSource).toContain('SELECTABLE_PILL_BUTTON_BASE_CLASS'); + expect(selectablePillModelSource).toContain('SELECTABLE_PILL_BUTTON_ACTIVE_CLASS'); + expect(selectablePillModelSource).toContain('SELECTABLE_PILL_BUTTON_INACTIVE_CLASS'); + expect(selectablePillModelSource).toContain('getSelectablePillButtonClass'); + }); + + it('renders active and inactive pill states consistently', () => { + const onActiveClick = vi.fn(); + const onInactiveClick = vi.fn(); + + render(() => ( +
+ + Active + + + Inactive + +
+ )); + + const active = screen.getByRole('button', { name: 'Active' }); + const inactive = screen.getByRole('button', { name: 'Inactive' }); + + expect(active).toHaveAttribute('aria-pressed', 'true'); + expect(active.className).toContain('border-blue-500'); + expect(active.className).toContain('bg-blue-600'); + expect(inactive).toHaveAttribute('aria-pressed', 'false'); + expect(inactive.className).toContain('border-border'); + expect(inactive.className).toContain('bg-surface'); + + fireEvent.click(active); + fireEvent.click(inactive); + expect(onActiveClick).toHaveBeenCalledTimes(1); + expect(onInactiveClick).toHaveBeenCalledTimes(1); + }); + + it('preserves disabled behavior through the native button contract', () => { + const onClick = vi.fn(); + + render(() => ( + + Disabled + + )); + + const disabled = screen.getByRole('button', { name: 'Disabled' }); + expect(disabled).toBeDisabled(); + fireEvent.click(disabled); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend-modern/src/components/shared/SelectablePillButton.tsx b/frontend-modern/src/components/shared/SelectablePillButton.tsx new file mode 100644 index 000000000..43aaf1341 --- /dev/null +++ b/frontend-modern/src/components/shared/SelectablePillButton.tsx @@ -0,0 +1,34 @@ +import { JSX, mergeProps, splitProps } from 'solid-js'; +import { getSelectablePillButtonClass, type SelectablePillButtonSize } from './selectablePillModel'; + +export interface SelectablePillButtonProps extends Omit< + JSX.ButtonHTMLAttributes, + 'aria-pressed' +> { + active: boolean; + size?: SelectablePillButtonSize; + class?: string; + children?: JSX.Element; +} + +export function SelectablePillButton(props: SelectablePillButtonProps) { + const merged = mergeProps({ type: 'button' as const, size: 'md' as const }, props); + const [local, rest] = splitProps(merged, ['active', 'size', 'class', 'children', 'disabled']); + + return ( + + ); +} + +export default SelectablePillButton; diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 749f753e1..afb008bc8 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -30,6 +30,8 @@ import dialogSource from '@/components/shared/Dialog.tsx?raw'; import dialogModelSource from '@/components/shared/dialogModel.ts?raw'; import filterButtonGroupSource from '@/components/shared/FilterButtonGroup.tsx?raw'; import filterButtonGroupModelSource from '@/components/shared/filterButtonGroupModel.ts?raw'; +import selectablePillButtonSource from '@/components/shared/SelectablePillButton.tsx?raw'; +import selectablePillModelSource from '@/components/shared/selectablePillModel.ts?raw'; import filterToolbarSource from '@/components/shared/FilterToolbar.tsx?raw'; import filterOptionPresentationSource from '@/components/shared/filterOptionPresentation.ts?raw'; import formSelectSource from '@/components/shared/FormSelect.tsx?raw'; @@ -471,6 +473,83 @@ describe('shared primitive guardrails', () => { ); }); + it('routes selectable scope pill buttons through SelectablePillButton', () => { + const registry = JSON.parse(sharedTemplateRegistrySource) as { + rules?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + requiredConsumers?: Array<{ path?: string }>; + forbiddenPatterns?: Array<{ path?: string; patterns?: string[] }>; + }>; + patternGuards?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + allPatterns?: string[]; + scopes?: string[]; + allowedPaths?: string[]; + ignoredPaths?: string[]; + }>; + }; + const registeredRule = registry.rules?.find( + (rule) => rule.id === 'selectable-pill-button-shell', + ); + const registeredGuard = registry.patternGuards?.find( + (guard) => guard.id === 'selectable-pill-button-local-scope-selector-styles', + ); + + expect(registeredRule?.canonical?.path).toBe('src/components/shared/SelectablePillButton.tsx'); + expect(registeredRule?.canonical?.export).toBe('SelectablePillButton'); + expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ + 'src/components/Settings/APITokenManager.tsx', + ]); + expect(registeredRule?.forbiddenPatterns).toEqual([ + { + path: 'src/components/Settings/APITokenManager.tsx', + patterns: [ + 'inline-flex min-h-10 sm:min-h-10 items-center rounded-full border px-3 py-2 text-sm font-semibold transition', + 'min-h-10 sm:min-h-10 rounded-full border px-3 py-2 text-sm font-semibold transition', + 'border-blue-500 bg-blue-600 text-white shadow-sm', + 'hover:border-blue-400 hover:text-blue-600 dark:hover:border-blue-400 dark:hover:text-blue-200', + ], + }, + ]); + expect(registeredGuard?.canonical?.path).toBe('src/components/shared/selectablePillModel.ts'); + expect(registeredGuard?.canonical?.export).toBe('getSelectablePillButtonClass'); + expect(registeredGuard?.allPatterns).toEqual([ + 'rounded-full border px-3 py-2 text-sm font-semibold transition', + 'border-blue-500 bg-blue-600 text-white shadow-sm', + 'hover:border-blue-400 hover:text-blue-600', + ]); + expect(registeredGuard?.scopes).toEqual([ + 'src/components/Settings', + 'src/features', + 'src/pages', + ]); + expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0); + expect(registeredGuard?.ignoredPaths).toEqual([ + 'src/components/Settings/__tests__/APITokenManager.test.tsx', + 'src/components/shared/SelectablePillButton.test.tsx', + ]); + + expect(selectablePillButtonSource).toContain('getSelectablePillButtonClass'); + expect(selectablePillButtonSource).toContain("aria-pressed={local.active ? 'true' : 'false'}"); + expect(selectablePillButtonSource).not.toContain('border-blue-500 bg-blue-600'); + expect(selectablePillModelSource).toContain('SELECTABLE_PILL_BUTTON_BASE_CLASS'); + expect(selectablePillModelSource).toContain('SELECTABLE_PILL_BUTTON_ACTIVE_CLASS'); + expect(selectablePillModelSource).toContain('SELECTABLE_PILL_BUTTON_INACTIVE_CLASS'); + expect(selectablePillModelSource).toContain('getSelectablePillButtonClass'); + expect(apiTokenManagerSource).toContain('SelectablePillButton'); + expect(apiTokenManagerSource.match(/ { expect(aiModelPickerSource).toContain('formatAIModelRouteLabel(match)'); expect(aiModelPickerSource).toContain('formatAIModelRouteLabel(model)'); diff --git a/frontend-modern/src/components/shared/selectablePillModel.ts b/frontend-modern/src/components/shared/selectablePillModel.ts new file mode 100644 index 000000000..2ee055f1b --- /dev/null +++ b/frontend-modern/src/components/shared/selectablePillModel.ts @@ -0,0 +1,32 @@ +export type SelectablePillButtonSize = 'md'; + +export const SELECTABLE_PILL_BUTTON_BASE_CLASS = + 'inline-flex items-center justify-center rounded-full border font-semibold transition whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60'; + +export const SELECTABLE_PILL_BUTTON_SIZE_CLASSES: Record = { + md: 'min-h-10 px-3 py-2 text-sm sm:min-h-10', +}; + +export const SELECTABLE_PILL_BUTTON_ACTIVE_CLASS = + 'border-blue-500 bg-blue-600 text-white shadow-sm'; + +export const SELECTABLE_PILL_BUTTON_INACTIVE_CLASS = + 'border-border bg-surface text-base-content hover:border-blue-400 hover:text-blue-600 dark:hover:border-blue-400 dark:hover:text-blue-200'; + +export type SelectablePillButtonClassOptions = { + active?: boolean; + size?: SelectablePillButtonSize; + class?: string; +}; + +export const getSelectablePillButtonClass = ( + options: SelectablePillButtonClassOptions = {}, +): string => + [ + SELECTABLE_PILL_BUTTON_BASE_CLASS, + SELECTABLE_PILL_BUTTON_SIZE_CLASSES[options.size ?? 'md'], + options.active ? SELECTABLE_PILL_BUTTON_ACTIVE_CLASS : SELECTABLE_PILL_BUTTON_INACTIVE_CLASS, + options.class, + ] + .filter(Boolean) + .join(' '); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 9197baeb3..5773efe27 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 472, + "line": 476, "heading_line": 123, } ],