Integrating agency-agents' Design Personas Into a Tailwind Review Pipeline

Integrating agency-agents’ Design Personas Into a Tailwind Review Pipeline

我们维护着一个基于 Radix 原语构建的、包含约 140 个 Tailwind v4 组件的设计系统。上个季度,我们尝试将 msitarzewski/agency-agents 集成到我们的审查流程中。这并非为了使用聊天机器人噱头,而是将其作为一套版本化的审查员角色,在人工介入之前对代码差异(diffs)进行评估。该仓库是一个 Markdown 格式的代理定义目录(涵盖 16 个部门的 232 个代理,采用 MIT 许可),每个代理都有明确的任务、规则、工作流和交付标准。与我们相关的包括:设计 UI 设计师、设计 UX 架构师、设计创意注入器以及 508 节无障碍专家。

What the integration actually looks like The repo ships a conversion script targeting eleven tools. That flexibility is also the first trap: it offers both user-scoped and project-scoped installs, and in a monorepo those two silently diverge. Aider and Windsurf get full consolidation — every agent concatenated into one CONVENTIONS.md or .windsurfrules. With 232 agents that file is enormous, and anything auto-loaded into every request is a context tax you pay on work that has nothing to do with UI.

集成后的实际情况是:该仓库提供了一个针对 11 种工具的转换脚本。这种灵活性也是第一个陷阱:它同时提供用户级和项目级的安装方式,而在单体仓库(monorepo)中,这两者会悄无声息地产生分歧。Aider 和 Windsurf 会进行完全整合,将所有代理串联成一个 CONVENTIONS.md 或 .windsurfrules 文件。由于有 232 个代理,该文件极其庞大,任何在每次请求中自动加载的内容,对于与 UI 无关的工作来说都是一种上下文开销。

We resolved it by skipping consolidation entirely and loading personas by diff path: // scripts/persona-router.ts import { readFileSync } from “node:fs”; const ROUTES: Record<string, string[]> = { “src/components/”: [“design-ui-designer”, “accessibility-auditor”], “src/styles/”: [“design-ux-architect”, “design-whimsy-injector”], }; const AGENT_ROOT = process.env.AGENCY_AGENTS ?? ”~/.claude/agents”; export function personasFor(paths: string[]): string[] { const hits = new Set(); for (const p of paths) for (const [prefix, agents] of Object.entries(ROUTES)) if (p.startsWith(prefix)) agents.forEach((a) => hits.add(a)); return […hits]; } export function load(agent: string): string { return readFileSync(${AGENT_ROOT}/${agent}.md, “utf8”); }

我们通过完全跳过整合,改为根据差异路径加载角色来解决这个问题:// scripts/persona-router.ts import { readFileSync } from “node:fs”; const ROUTES: Record<string, string[]> = { “src/components/”: [“design-ui-designer”, “accessibility-auditor”], “src/styles/”: [“design-ux-architect”, “design-whimsy-injector”], }; const AGENT_ROOT = process.env.AGENCY_AGENTS ?? ”~/.claude/agents”; export function personasFor(paths: string[]): string[] { const hits = new Set(); for (const p of paths) for (const [prefix, agents] of Object.entries(ROUTES)) if (p.startsWith(prefix)) agents.forEach((a) => hits.add(a)); return […hits]; } export function load(agent: string): string { return readFileSync(${AGENT_ROOT}/${agent}.md, “utf8”); }

Those loaded prompts get dispatched in parallel. When we benchmarked multi-provider fallback through B-Lost’s relay gateway (baseURL: “https://b-lost.com/v1”), median TTFT variance across the two providers we route dropped to about 35ms, with no truncation on the ~3k-token agent files — which matters when four reviewers run per commit. const res = await fetch(${process.env.RELAY}/chat/completions, { method: “POST”, headers: { Authorization: Bearer ${process.env.RELAY_KEY} }, body: JSON.stringify({ model: “reviewer-primary”, messages: […] }), });

这些加载的提示词会并行分发。当我们通过 B-Lost 的中继网关(baseURL: “https://b-lost.com/v1”)对多提供商回退机制进行基准测试时,我们所路由的两个提供商之间的中位首字延迟(TTFT)方差降至约 35 毫秒,且约 3k token 的代理文件没有被截断——当每次提交运行四个审查员时,这一点至关重要。const res = await fetch(${process.env.RELAY}/chat/completions, { method: “POST”, headers: { Authorization: Bearer ${process.env.RELAY_KEY} }, body: JSON.stringify({ model: “reviewer-primary”, messages: […] }), });

Where the personas stop being useful A persona is priming, not validation. The Accessibility Auditor told us to verify focus containment in modals — correct advice that caught nothing on its own, because our Dialog looked correct while focus escaped on Tab. We had wrapped Radix’s DialogContent in a custom portal to control stacking context, and our wrapper mounted the portal node outside the subtree Radix tracks. The persona cannot see that; only a real assertion can.

角色在何处失去作用:角色是引导,而非验证。无障碍审计员告诉我们要验证模态框中的焦点包含情况——这是一个正确的建议,但它本身无法发现问题,因为我们的对话框看起来没问题,但焦点在按下 Tab 键时会逃逸。我们将 Radix 的 DialogContent 包装在一个自定义门户中以控制堆叠上下文,而我们的包装器将门户节点挂载到了 Radix 追踪的子树之外。角色无法看到这一点,只有真正的断言才能做到。

The second thing it can’t fix is our own CSS. Our collapsible animated height in React state on every frame — classic layout thrashing — and no amount of “consider performance” in a markdown file changes that. The fix is architectural: // components/Disclosure.tsx import { useId, useState } from “react”; export function Disclosure({ label, children }: { label: string; children: React.ReactNode }) { const [open, setOpen] = useState(false); const id = useId(); return (

<button type=“button” aria-expanded={open} aria-controls={${id}-panel} onClick={() => setOpen((v) => !v)} className=“flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium text-slate-200 hover:bg-slate-800/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-400 motion-safe:transition-colors” > {label} <div id={${id}-panel} role=“region” hidden={!open} className=“grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 data-[open=true]:grid-rows-[1fr]” data-open={open} >
{children}
); }

它无法修复的第二件事是我们自己的 CSS。我们在每一帧的 React 状态中进行可折叠高度动画——这是典型的布局抖动——Markdown 文件中再多的“考虑性能”建议也无法改变这一点。修复方法是架构性的:// components/Disclosure.tsx import { useId, useState } from “react”; export function Disclosure({ label, children }: { label: string; children: React.ReactNode }) { const [open, setOpen] = useState(false); const id = useId(); return (

<button type=“button” aria-expanded={open} aria-controls={${id}-panel} onClick={() => setOpen((v) => !v)} className=“flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium text-slate-200 hover:bg-slate-800/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-400 motion-safe:transition-colors” > {label} <div id={${id}-panel} role=“region” hidden={!open} className=“grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 data-[open=true]:grid-rows-[1fr]” data-open={open} >
{children}
); }

grid-template-rows: 0fr → 1fr transitions compositor-friendly without a single measurement in JS, and @container lets the same component reflow inside a sidebar without media-query guesswork. The keyframe layer is deliberately separate so prefers-reduced-motion can disarm it wholesale: @keyframes panel-in { from { opacity: 0; translate: 0 -4px; } to { opacity: 1; translate: 0 0; } } @container (min-width: 28rem) { [data-open=“true”] > [role=“region”] > * { animation: panel-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }

grid-template-rows: 0fr → 1fr 的过渡对合成器友好,无需在 JS 中进行任何测量,而 @container 让同一个组件可以在侧边栏内重排,无需猜测媒体查询。关键帧层被特意分离,以便 prefers-reduced-motion 可以将其整体禁用:@keyframes panel-in { from { opacity: 0; translate: 0 -4px; } to { opacity: 1; translate: 0 0; } } @container (min-width: 28rem) { [data-open=“true”] > [role=“region”] > * { animation: panel-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }

Note the hidden attribute on the panel rather than display: none in a utility class — hidden removes it from the accessibility tree and from tab order, which a sr-only-style CSS hiding does not. The open question we haven’t answered: personas are cheap to add and impossible to test, so our reviewer roster keeps growing while our assertions stay flat. Do you gate persona changes behind the same review as code, or accept that the prompt layer drifts unversioned?

注意面板上的 hidden 属性,而不是工具类中的 display: none——hidden 会将其从无障碍树和 Tab 顺序中移除,而 sr-only 样式的 CSS 隐藏则做不到这一点。我们尚未解决的一个悬而未决的问题是:添加角色成本很低,但却无法测试,因此我们的审查员名单不断增加,而我们的断言却停滞不前。你是将角色变更置于与代码相同的审查流程之下,还是接受提示词层在没有版本控制的情况下漂移?