Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/dev-ui/app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ const sidebarWidth = computed(() => (isCollapsed.value ? 'w-16' : 'w-64'))
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<button
class="flex w-full items-center justify-center rounded-md bg-sidebar-accent p-2 transition-colors hover:bg-sidebar-accent/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
class="flex w-full items-center justify-center rounded-md bg-sidebar-accent p-2 transition-colors hover:bg-sidebar-accent/80 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-sidebar-ring/50"
:aria-label="tenantAriaLabel"
>
<Building2 class="size-4 shrink-0 text-sidebar-primary" aria-hidden="true" />
Expand All @@ -443,7 +443,7 @@ const sidebarWidth = computed(() => (isCollapsed.value ? 'w-16' : 'w-64'))
</Tooltip>
<DropdownMenuTrigger v-else as-child>
<button
class="flex w-full items-center gap-2 rounded-md bg-sidebar-accent px-2 py-2 text-left transition-colors hover:bg-sidebar-accent/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
class="flex w-full items-center gap-2 rounded-md bg-sidebar-accent px-2 py-2 text-left transition-colors hover:bg-sidebar-accent/80 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-sidebar-ring/50"
:aria-label="tenantAriaLabel"
>
<Building2 class="size-4 shrink-0 text-sidebar-primary" aria-hidden="true" />
Expand Down Expand Up @@ -678,7 +678,7 @@ const sidebarWidth = computed(() => (isCollapsed.value ? 'w-16' : 'w-64'))
<DropdownMenu v-else>
<DropdownMenuTrigger as-child>
<button
class="flex w-full items-center gap-2 rounded-md bg-sidebar-accent px-2 py-2 text-left transition-colors hover:bg-sidebar-accent/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
class="flex w-full items-center gap-2 rounded-md bg-sidebar-accent px-2 py-2 text-left transition-colors hover:bg-sidebar-accent/80 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-sidebar-ring/50"
:aria-label="tenantAriaLabel"
>
<Building2 class="size-4 shrink-0 text-sidebar-primary" aria-hidden="true" />
Expand Down
4 changes: 2 additions & 2 deletions src/dev-ui/app/pages/integrate/mcp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ async function copyHeaderValue(key: string, value: string) {
<!-- Endpoint Details (collapsible) -->
<Card>
<button
class="flex w-full items-center justify-between px-6 py-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset rounded-lg"
class="flex w-full items-center justify-between px-6 py-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset rounded-lg"
:aria-expanded="showDetails" aria-controls="endpoint-details-content" @click="showDetails = !showDetails">
<div class="flex items-center gap-2">
<Info class="size-4 text-muted-foreground" />
Expand Down Expand Up @@ -682,7 +682,7 @@ async function copyHeaderValue(key: string, value: string) {
<!-- Available Tools (collapsible) -->
<Card>
<button
class="flex w-full items-center justify-between px-6 py-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset rounded-lg"
class="flex w-full items-center justify-between px-6 py-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset rounded-lg"
:aria-expanded="showTools" aria-controls="tools-content" @click="showTools = !showTools">
<div class="flex items-center gap-2">
<Terminal class="size-4 text-muted-foreground" />
Expand Down
2 changes: 1 addition & 1 deletion src/dev-ui/app/pages/tenants/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ onMounted(fetchTenants)
:key="tenant.id"
role="listitem"
tabindex="0"
class="flex items-center gap-2 px-4 py-2.5 transition-colors hover:bg-muted/50 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
class="flex items-center gap-2 px-4 py-2.5 transition-colors hover:bg-muted/50 cursor-pointer focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset"
:class="[
selectedTenant?.id === tenant.id ? 'bg-muted' : '',
]"
Expand Down
126 changes: 126 additions & 0 deletions src/dev-ui/app/tests/focus-ring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'

// ── Focus Ring Consistency Tests ──────────────────────────────────────────────
//
// Spec: "Interaction Principles — Scenario: Focus indicators"
// "GIVEN an interactive element receiving focus
// THEN a 3px ring in the primary color at 50% opacity is shown
// AND native outlines are suppressed in favor of the ring"
//
// The shadcn/vue component library uses `focus-visible:ring-[3px]` consistently.
// Several manually-written interactive elements were using `focus-visible:ring-2`
// (Tailwind's preset 8px unit) instead of the spec-required 3px literal.
//
// These tests enforce that all manually-written interactive elements in:
// - app/layouts/default.vue (tenant selector buttons)
// - app/pages/integrate/mcp.vue (collapsible section buttons)
// - app/pages/tenants/index.vue (tenant list items)
// ... use `focus-visible:ring-[3px]` (3px) and NOT `focus-visible:ring-2` (8px).
//
// Testing approach: Source-level inspection via readFileSync, matching the
// pattern used by design-system.test.ts. Mounting the full Nuxt layout in a
// unit test is impractical; source inspection is equivalent and more targeted.

// ── Read source files ─────────────────────────────────────────────────────────

const layoutsDir = resolve(__dirname, '../layouts')
const pagesDir = resolve(__dirname, '../pages')

const defaultVue = readFileSync(resolve(layoutsDir, 'default.vue'), 'utf-8')
const mcpVue = readFileSync(resolve(pagesDir, 'integrate/mcp.vue'), 'utf-8')
const tenantsVue = readFileSync(resolve(pagesDir, 'tenants/index.vue'), 'utf-8')

// ── default.vue — Tenant selector buttons ─────────────────────────────────────

describe('Focus Rings - default.vue: tenant selector buttons', () => {
it('does NOT use focus-visible:ring-2 anywhere in default.vue', () => {
// ring-2 is a Tailwind preset unit (8px), not the spec-required 3px
expect(defaultVue).not.toContain('focus-visible:ring-2')
})

it('uses focus-visible:ring-[3px] on the collapsed tenant icon button', () => {
// Line ~394: collapsed sidebar tenant trigger (icon-only)
// Must have ring-[3px] rather than ring-2
expect(defaultVue).toContain('focus-visible:ring-[3px]')
})

it('has ring-sidebar-ring/50 opacity on all sidebar focus rings in default.vue', () => {
// Every focus-visible:ring-sidebar-ring must include the /50 opacity modifier
// so the ring renders at 50% opacity as required by the spec
const withoutOpacity = [...defaultVue.matchAll(/focus-visible:ring-sidebar-ring(?!\/)/g)]
expect(withoutOpacity).toHaveLength(0)
})

it('retains outline-none on tenant selector buttons to suppress native outlines', () => {
// outline-none suppresses the browser default focus outline in favour of the ring
expect(defaultVue).toContain('focus-visible:outline-none')
})
})

// ── mcp.vue — Collapsible section buttons ─────────────────────────────────────

describe('Focus Rings - mcp.vue: collapsible section buttons', () => {
it('does NOT use focus-visible:ring-2 anywhere in mcp.vue', () => {
expect(mcpVue).not.toContain('focus-visible:ring-2')
})

it('uses focus-visible:ring-[3px] on the Endpoint Details collapsible button', () => {
// Line ~605: "Endpoint Details" section toggle button
expect(mcpVue).toContain('focus-visible:ring-[3px]')
})

it('has ring-ring/50 opacity on all focus ring colors in mcp.vue', () => {
// focus-visible:ring-ring must always be focus-visible:ring-ring/50
// A bare `focus-visible:ring-ring` without the opacity modifier violates the spec
const bareRing = [...mcpVue.matchAll(/focus-visible:ring-ring(?!\/)/g)]
expect(bareRing).toHaveLength(0)
})

it('retains outline-none on collapsible buttons to suppress native outlines', () => {
expect(mcpVue).toContain('focus-visible:outline-none')
})
})

// ── tenants/index.vue — Tenant list items ─────────────────────────────────────

describe('Focus Rings - tenants/index.vue: tenant list items', () => {
it('does NOT use focus-visible:ring-2 anywhere in tenants/index.vue', () => {
expect(tenantsVue).not.toContain('focus-visible:ring-2')
})

it('uses focus-visible:ring-[3px] on the tenant list item divs', () => {
// Line ~333: keyboard-focusable tenant row divs (role="listitem", tabindex="0")
expect(tenantsVue).toContain('focus-visible:ring-[3px]')
})

it('has ring-ring/50 opacity on all focus ring colors in tenants/index.vue', () => {
// focus-visible:ring-ring must always be focus-visible:ring-ring/50
const bareRing = [...tenantsVue.matchAll(/focus-visible:ring-ring(?!\/)/g)]
expect(bareRing).toHaveLength(0)
})

it('retains outline-none on tenant list items to suppress native outlines', () => {
expect(tenantsVue).toContain('focus-visible:outline-none')
})
})

// ── Cross-file regression guard ───────────────────────────────────────────────

describe('Focus Rings - global: no new ring-2 regressions', () => {
it('default.vue uses ring-[3px] for all manual interactive elements (no ring-2)', () => {
const ring2Count = (defaultVue.match(/focus-visible:ring-2/g) ?? []).length
expect(ring2Count).toBe(0)
})

it('mcp.vue uses ring-[3px] for all manual interactive elements (no ring-2)', () => {
const ring2Count = (mcpVue.match(/focus-visible:ring-2/g) ?? []).length
expect(ring2Count).toBe(0)
})

it('tenants/index.vue uses ring-[3px] for all manual interactive elements (no ring-2)', () => {
const ring2Count = (tenantsVue.match(/focus-visible:ring-2/g) ?? []).length
expect(ring2Count).toBe(0)
})
})