diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 8af974981..29a8ccf08 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -73,6 +73,7 @@ import { KanbanPilotView } from './components/KanbanPilotView'; import { ChangelogPilotView } from './components/ChangelogPilotView'; import { GitHubPRsPilotView } from './components/GitHubPRsPilotView'; import { GitHubIssuesPilotView } from './components/GitHubIssuesPilotView'; +import { PatternsPilotView } from './components/PatternsPilotView'; import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store'; import { useClaudeProfileStore } from './stores/claude-profile-store'; import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store'; @@ -1119,6 +1120,9 @@ export function App() { {activeView === 'patterns' && (activeProjectId || selectedProjectId) && ( )} + {activeView === 'patterns-next' && (activeProjectId || selectedProjectId) && ( + + )} {activeView === 'agent-tools' && } {activeView === 'plugins' && selectedProject?.path && ( diff --git a/apps/frontend/src/renderer/__tests__/patterns-ui.test.ts b/apps/frontend/src/renderer/__tests__/patterns-ui.test.ts new file mode 100644 index 000000000..bde8b61c3 --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/patterns-ui.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for the Pattern → PatternLibrary mapping helpers. + */ + +import { describe, expect, it } from 'vitest'; +import { + aggregatePatterns, + filterPatterns, + mapPatternToUi, +} from '../lib/patterns-ui'; +import type { Pattern } from '../../preload/api/modules/pattern-api'; + +const LABELS = { high: 'high confidence', medium: 'medium confidence', low: 'low confidence' }; + +function makePattern(overrides: Partial = {}): Pattern { + return { + index: 1, + id: '1', + text: 'Prefer typed nav config from a string-literal union', + category: 'code-organization', + confidence: 'high', + reasoning: 'A typo then fails at compile time.', + ...overrides, + }; +} + +describe('mapPatternToUi', () => { + it('maps a backend pattern onto a sparse card with a spec-qualified id', () => { + const ui = mapPatternToUi(makePattern(), '001-auth', LABELS); + expect(ui).toEqual({ + id: '001-auth#1', + kind: 'pattern', + title: 'Prefer typed nav config from a string-literal union', + lang: 'code-organization', + description: 'A typo then fails at compile time.', + footer: [{ text: 'high confidence' }], + }); + }); + + it('omits the confidence footer and empty description when unknown', () => { + const ui = mapPatternToUi( + makePattern({ confidence: undefined, reasoning: '', category: undefined }), + '002', + LABELS, + ); + expect(ui.footer).toBeUndefined(); + expect(ui.description).toBeUndefined(); + expect(ui.lang).toBeUndefined(); + }); + + it('drops the footer when no confidence labels are supplied', () => { + expect(mapPatternToUi(makePattern(), '003').footer).toBeUndefined(); + }); +}); + +describe('aggregatePatterns', () => { + it('flattens per-spec patterns and dedupes identical texts, first wins', () => { + const shared = makePattern({ text: 'Branches stay local until pushed' }); + const result = aggregatePatterns( + [ + { specId: '001', patterns: [makePattern({ index: 1, text: 'A' }), shared] }, + { specId: '002', patterns: [{ ...shared, index: 5 }, makePattern({ index: 2, text: 'B' })] }, + ], + LABELS, + ); + expect(result.map((p) => p.title)).toEqual([ + 'A', + 'Branches stay local until pushed', + 'B', + ]); + // The deduped entry keeps the first spec's id. + expect(result[1].id).toBe('001#1'); + }); + + it('returns an empty list for no specs', () => { + expect(aggregatePatterns([])).toEqual([]); + expect(aggregatePatterns([{ specId: '001', patterns: [] }])).toEqual([]); + }); + + it('is case- and whitespace-insensitive when deduping', () => { + const result = aggregatePatterns([ + { specId: '001', patterns: [makePattern({ text: 'Same Rule' })] }, + { specId: '002', patterns: [makePattern({ text: ' same rule ' })] }, + ]); + expect(result).toHaveLength(1); + }); +}); + +describe('filterPatterns', () => { + const patterns = [ + mapPatternToUi(makePattern({ index: 1, text: 'Typed nav config', category: 'code-organization' }), 's1', LABELS), + mapPatternToUi(makePattern({ index: 2, text: 'Retry with backoff', category: 'error-handling', reasoning: 'exponential jitter' }), 's1', LABELS), + ]; + + it('matches title, lang, and description, case-insensitively', () => { + expect(filterPatterns(patterns, 'NAV').map((p) => p.title)).toEqual(['Typed nav config']); + expect(filterPatterns(patterns, 'error-handling').map((p) => p.title)).toEqual(['Retry with backoff']); + expect(filterPatterns(patterns, 'jitter').map((p) => p.title)).toEqual(['Retry with backoff']); + expect(filterPatterns(patterns, 'zzz')).toEqual([]); + }); + + it('returns everything for a blank query', () => { + expect(filterPatterns(patterns, ' ')).toHaveLength(2); + }); +}); diff --git a/apps/frontend/src/renderer/components/PatternsPilotView.tsx b/apps/frontend/src/renderer/components/PatternsPilotView.tsx new file mode 100644 index 000000000..67aeee0d6 --- /dev/null +++ b/apps/frontend/src/renderer/components/PatternsPilotView.tsx @@ -0,0 +1,185 @@ +/** + * Patterns library pilot on the shared design system (U5 B1). + * + * The backend stores learned patterns per spec, so this pilot aggregates + * across the project's known specs (from the task store) via the existing + * `window.electronAPI.pattern.listPatterns` IPC and renders them through + * `libs/ui`'s PatternLibrary. Read-only — the approve/override/delete + * flows stay on the legacy Patterns view. Reachable via the "Patterns + * (new UI)" sidebar item. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PatternLibrary } from '@auto-code/ui'; +import type { UiMetaSection, UiPattern } from '@auto-code/ui'; +import { useTaskStore } from '../stores/task-store'; +import { + aggregatePatterns, + filterPatterns, + type SpecPatterns, +} from '../lib/patterns-ui'; + +interface PatternsPilotViewProps { + projectId: string; +} + +interface PilotState { + patterns: UiPattern[] | null; + specCount: number; + loading: boolean; + error: Error | null; +} + +export function PatternsPilotView({ + projectId, +}: Readonly) { + const { t } = useTranslation(['patterns']); + const [state, setState] = useState({ + patterns: null, + specCount: 0, + loading: true, + error: null, + }); + const [reloadKey, setReloadKey] = useState(0); + const [query, setQuery] = useState(''); + + // Subscribe reactively so patterns refetch when the project's tasks load + // after mount (reading getState() once would strand the empty state). The + // effect depends on a stable string key, not the fresh-per-render array, + // so unrelated task updates don't trigger a refetch. + const tasks = useTaskStore((store) => store.tasks); + const specKey = useMemo( + () => + Array.from( + new Set( + tasks + .filter((task) => task.projectId === projectId) + .map((task) => task.specId), + ), + ).join('|'), + [tasks, projectId], + ); + + const confidenceLabels = useMemo( + () => ({ + high: t('patterns:patternsPilot.confidence.high'), + medium: t('patterns:patternsPilot.confidence.medium'), + low: t('patterns:patternsPilot.confidence.low'), + }), + [t], + ); + + useEffect(() => { + let active = true; + setState({ patterns: null, specCount: 0, loading: true, error: null }); + + // Patterns are stored per spec; load each spec's patterns, tolerating + // per-spec failures (a spec may have none / an unreadable file). + const specIds = specKey === '' ? [] : specKey.split('|'); + Promise.all( + specIds.map((specId) => + window.electronAPI.pattern + .listPatterns(projectId, specId) + .then((result): SpecPatterns => ({ + specId, + patterns: result.success ? (result.data ?? []) : [], + })) + .catch((err: unknown): SpecPatterns => { + console.warn( + '[PatternsPilotView] Failed to load patterns for spec', + specId, + err, + ); + return { specId, patterns: [] }; + }), + ), + ) + .then((perSpec) => { + if (!active) return; + setState({ + patterns: aggregatePatterns(perSpec, confidenceLabels), + specCount: specIds.length, + loading: false, + error: null, + }); + }) + .catch((err: unknown) => { + if (!active) return; + console.error('[PatternsPilotView] Failed to load patterns:', err); + setState({ + patterns: null, + specCount: 0, + loading: false, + error: new Error(t('patterns:patternsPilot.error')), + }); + }); + return () => { + active = false; + }; + }, [projectId, specKey, reloadKey, confidenceLabels, t]); + + const reload = useCallback(() => setReloadKey((key) => key + 1), []); + + const visible = useMemo( + () => + state.patterns == null ? null : filterPatterns(state.patterns, query), + [state.patterns, query], + ); + + const metaSections = useMemo(() => { + if (state.patterns == null) return undefined; + return [ + { + title: t('patterns:patternsPilot.meta.title'), + rows: [ + { + label: t('patterns:patternsPilot.meta.total'), + value: String(state.patterns.length), + }, + { + label: t('patterns:patternsPilot.meta.specs'), + value: String(state.specCount), + }, + ], + }, + ]; + }, [state.patterns, state.specCount, t]); + + const stateLabels = useMemo( + () => ({ + loading: t('patterns:patternsPilot.states.loading'), + retry: t('patterns:patternsPilot.states.retry'), + empty: t('patterns:patternsPilot.states.empty'), + }), + [t], + ); + + const kindLabels = useMemo( + () => ({ + pattern: t('patterns:patternsPilot.kinds.pattern'), + gotcha: t('patterns:patternsPilot.kinds.gotcha'), + decision: t('patterns:patternsPilot.kinds.decision'), + rule: t('patterns:patternsPilot.kinds.rule'), + }), + [t], + ); + + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index b15bbef9a..cb9a3de36 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -71,7 +71,7 @@ import { SessionContextIndicator } from './SessionContextIndicator'; import { NavIndicator } from './NavIndicator'; import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types'; -export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'model-usage' | 'agent-inspector'; +export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'agent-inspector'; interface SidebarProps { onSettingsClick: () => void; @@ -103,6 +103,7 @@ const baseNavItems: NavItem[] = [ { id: 'analytics', labelKey: 'navigation:items.analytics', icon: BarChart3, shortcut: 'Y' }, { id: 'productivity', labelKey: 'navigation:items.productivity', icon: TrendingUp, shortcut: 'P' }, { id: 'patterns', labelKey: 'navigation:items.patterns', icon: Code, shortcut: 'Z' }, + { id: 'patterns-next', labelKey: 'navigation:items.patternsNext', icon: Code }, { id: 'agent-tools', labelKey: 'navigation:items.agentTools', icon: Wrench, shortcut: 'M' }, { id: 'plugins', labelKey: 'navigation:items.plugins', icon: Puzzle, shortcut: 'U' }, { id: 'worktrees', labelKey: 'navigation:items.worktrees', icon: GitBranch, shortcut: 'W' }, diff --git a/apps/frontend/src/renderer/lib/patterns-ui.ts b/apps/frontend/src/renderer/lib/patterns-ui.ts new file mode 100644 index 000000000..d3332c34c --- /dev/null +++ b/apps/frontend/src/renderer/lib/patterns-ui.ts @@ -0,0 +1,93 @@ +/** + * Pure mapping helpers between the preload Pattern shape and the shared + * PatternLibrary screen. The backend stores patterns per spec as plain + * text with a coarse category + confidence bucket, so the mapped card is + * intentionally sparse (no snippet/tags) — the rich card fields exist for + * the mockup/Storybook and richer sources. Localized strings (the + * confidence footer label) stay in the pilot. + */ + +import type { + UiPattern, + UiPatternFooterStat, +} from '@auto-code/ui'; +import type { Pattern } from '../../preload/api/modules/pattern-api'; + +export interface PatternConfidenceLabels { + high: string; + medium: string; + low: string; +} + +/** + * Map one backend pattern onto the shared card shape. `specId` keeps ids + * unique (the backend index resets per spec). `footer` carries the + * confidence bucket when known; the caller supplies its localized wording. + */ +export function mapPatternToUi( + pattern: Pattern, + specId: string, + confidenceLabels?: PatternConfidenceLabels, +): UiPattern { + const footer: UiPatternFooterStat[] = []; + if (pattern.confidence != null && confidenceLabels != null) { + footer.push({ text: confidenceLabels[pattern.confidence] }); + } + return { + id: `${specId}#${pattern.index}`, + // The backend has no gotcha/decision/rule taxonomy — every learned + // item is a code pattern; category becomes the lang/domain chip. + kind: 'pattern', + title: pattern.text, + lang: pattern.category ?? undefined, + description: pattern.reasoning || undefined, + footer: footer.length > 0 ? footer : undefined, + }; +} + +/** One spec's pattern list, as returned per-spec by the pattern IPC. */ +export interface SpecPatterns { + specId: string; + patterns: readonly Pattern[]; +} + +/** + * Flatten patterns aggregated across a project's specs into one card list, + * dropping exact-duplicate texts (the same rule is often re-learned across + * specs). Order follows the input; the first occurrence wins. + */ +export function aggregatePatterns( + perSpec: readonly SpecPatterns[], + confidenceLabels?: PatternConfidenceLabels, +): UiPattern[] { + const seen = new Set(); + const out: UiPattern[] = []; + for (const { specId, patterns } of perSpec) { + for (const pattern of patterns) { + const dedupeKey = pattern.text.trim().toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + out.push(mapPatternToUi(pattern, specId, confidenceLabels)); + } + } + return out; +} + +/** Case-insensitive match over title, lang, description, and tags. */ +export function filterPatterns( + patterns: readonly UiPattern[], + query: string, +): UiPattern[] { + const needle = query.trim().toLowerCase(); + if (needle === '') return [...patterns]; + const matches = (field?: string) => + field?.toLowerCase().includes(needle) ?? false; + return patterns.filter( + (pattern) => + matches(pattern.title) || + matches(pattern.lang) || + matches(pattern.description) || + matches(pattern.code) || + (pattern.tags ?? []).some((tag) => matches(tag)), + ); +} diff --git a/apps/frontend/src/shared/i18n/index.ts b/apps/frontend/src/shared/i18n/index.ts index dc74bf7d7..a74b273e2 100644 --- a/apps/frontend/src/shared/i18n/index.ts +++ b/apps/frontend/src/shared/i18n/index.ts @@ -23,6 +23,7 @@ import enAgentInspector from './locales/en/agent-inspector.json'; import enTimeline from './locales/en/timeline.json'; import enKanban from './locales/en/kanban.json'; import enChangelog from './locales/en/changelog.json'; +import enPatterns from './locales/en/patterns.json'; // Import French translation resources import frCommon from './locales/fr/common.json'; @@ -46,6 +47,7 @@ import frAgentInspector from './locales/fr/agent-inspector.json'; import frTimeline from './locales/fr/timeline.json'; import frKanban from './locales/fr/kanban.json'; import frChangelog from './locales/fr/changelog.json'; +import frPatterns from './locales/fr/patterns.json'; export const defaultNS = 'common'; @@ -71,7 +73,8 @@ export const resources = { 'agent-inspector': enAgentInspector, timeline: enTimeline, kanban: enKanban, - changelog: enChangelog + changelog: enChangelog, + patterns: enPatterns }, fr: { common: frCommon, @@ -94,7 +97,8 @@ export const resources = { 'agent-inspector': frAgentInspector, timeline: frTimeline, kanban: frKanban, - changelog: frChangelog + changelog: frChangelog, + patterns: frPatterns } } as const; @@ -105,7 +109,7 @@ i18n lng: 'en', // Default language (will be overridden by settings) fallbackLng: 'en', defaultNS, - ns: ['common', 'navigation', 'settings', 'security', 'tasks', 'welcome', 'onboarding', 'dialogs', 'github', 'gitlab', 'taskReview', 'terminal', 'errors', 'analytics', 'model-usage', 'codeReview', 'quality', 'agent-inspector', 'timeline', 'kanban', 'changelog'], + ns: ['common', 'navigation', 'settings', 'security', 'tasks', 'welcome', 'onboarding', 'dialogs', 'github', 'gitlab', 'taskReview', 'terminal', 'errors', 'analytics', 'model-usage', 'codeReview', 'quality', 'agent-inspector', 'timeline', 'kanban', 'changelog', 'patterns'], interpolation: { escapeValue: false // React already escapes values }, diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index 9973fc6ea..9dd06f37c 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -32,7 +32,8 @@ "kanbanNext": "Kanban (New UI)", "changelogNext": "Changelog (New UI)", "githubPRsNext": "GitHub PRs (New UI)", - "githubIssuesNext": "GitHub Issues (New UI)" + "githubIssuesNext": "GitHub Issues (New UI)", + "patternsNext": "Patterns (New UI)" }, "actions": { "settings": "Settings", diff --git a/apps/frontend/src/shared/i18n/locales/en/patterns.json b/apps/frontend/src/shared/i18n/locales/en/patterns.json index bc967d286..306070ccb 100644 --- a/apps/frontend/src/shared/i18n/locales/en/patterns.json +++ b/apps/frontend/src/shared/i18n/locales/en/patterns.json @@ -36,5 +36,31 @@ "low": "Low confidence (<70%): Pattern used infrequently, verify before approving", "approved": "Approved patterns are marked as team standards (confidence: 100%)", "deprecated": "Deprecated patterns are marked to avoid (confidence: 0%)" + }, + "patternsPilot": { + "error": "Failed to load patterns.", + "searchPlaceholder": "Search by name, category, description…", + "searchLabel": "Search patterns by name, category, or description", + "confidence": { + "high": "high confidence", + "medium": "medium confidence", + "low": "low confidence" + }, + "kinds": { + "pattern": "Pattern", + "gotcha": "Gotcha", + "decision": "Decision", + "rule": "Rule" + }, + "states": { + "loading": "Loading patterns…", + "retry": "Retry", + "empty": "No patterns learned yet." + }, + "meta": { + "title": "Library", + "total": "Patterns", + "specs": "From specs" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index 64621ce4e..6a13eac9c 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -32,7 +32,8 @@ "kanbanNext": "Kanban (nouvelle interface)", "changelogNext": "Journal des modifications (nouvelle interface)", "githubPRsNext": "PRs GitHub (nouvelle interface)", - "githubIssuesNext": "Issues GitHub (nouvelle interface)" + "githubIssuesNext": "Issues GitHub (nouvelle interface)", + "patternsNext": "Patterns (nouvelle interface)" }, "actions": { "settings": "Paramètres", diff --git a/apps/frontend/src/shared/i18n/locales/fr/patterns.json b/apps/frontend/src/shared/i18n/locales/fr/patterns.json index 6e28a6b51..649982fa5 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/patterns.json +++ b/apps/frontend/src/shared/i18n/locales/fr/patterns.json @@ -36,5 +36,31 @@ "low": "Confiance faible (<70%) : Modèle utilisé rarement, vérifier avant d'approuver", "approved": "Les modèles approuvés sont marqués comme standards d'équipe (confiance : 100%)", "deprecated": "Les modèles obsolètes sont marqués à éviter (confiance : 0%)" + }, + "patternsPilot": { + "error": "Échec du chargement des patterns.", + "searchPlaceholder": "Rechercher par nom, catégorie, description…", + "searchLabel": "Rechercher des patterns par nom, catégorie ou description", + "confidence": { + "high": "confiance élevée", + "medium": "confiance moyenne", + "low": "confiance faible" + }, + "kinds": { + "pattern": "Pattern", + "gotcha": "Piège", + "decision": "Décision", + "rule": "Règle" + }, + "states": { + "loading": "Chargement des patterns…", + "retry": "Réessayer", + "empty": "Aucun pattern appris pour le moment." + }, + "meta": { + "title": "Bibliothèque", + "total": "Patterns", + "specs": "Depuis les spécs" + } } } diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts index 628aec1e9..0e51aa182 100644 --- a/libs/ui/src/client/types.ts +++ b/libs/ui/src/client/types.ts @@ -217,3 +217,37 @@ export interface UiIssue { assignees?: UiIssueAssignee[]; commentsCount?: number; } + +/** Kind of a learned pattern; drives the card's type glyph + colors. */ +export type UiPatternKind = 'pattern' | 'gotcha' | 'decision' | 'rule'; + +/** One pre-formatted fact in a pattern card footer (adapter owns wording). */ +export interface UiPatternFooterStat { + text: string; + /** 'bad' colors failures red; 'good' greens reuse wins. */ + tone?: 'default' | 'bad' | 'good'; +} + +/** A code snippet shown inside a pattern card. Rendered as plain preformatted text. */ +export interface UiPatternSnippet { + code: string; + language?: string; +} + +/** One learned pattern / gotcha / decision / rule. Adapters own all formatting. */ +export interface UiPattern { + /** Unique key for React identity (e.g. "001-auth#3"). */ + id: string; + kind: UiPatternKind; + /** Short id prefix shown before the title (e.g. "D81", "R03"). */ + code?: string; + title: string; + /** Language / domain chip (e.g. "tsx", "policy", "arch"). */ + lang?: string; + /** Description; may be plain text. */ + description?: string; + snippet?: UiPatternSnippet; + tags?: string[]; + /** Dot-separated footer facts (confidence, reuse count, provenance, …). */ + footer?: UiPatternFooterStat[]; +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index d0f23aa80..154c7c761 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -49,6 +49,10 @@ export type { UiIssueLabel, UiIssueLabelTone, UiIssueAssignee, + UiPattern, + UiPatternKind, + UiPatternFooterStat, + UiPatternSnippet, UiPrCheck, UiPrCheckStatus, UiPrReviewer, @@ -99,3 +103,10 @@ export type { IssueListFilter, IssueListStateLabels, } from './screens/IssueList'; +// Patterns library (U5 B1) +export { PatternLibrary } from './screens/PatternLibrary'; +export type { + PatternLibraryProps, + PatternLibraryFilter, + PatternLibraryStateLabels, +} from './screens/PatternLibrary'; diff --git a/libs/ui/src/screens/PatternLibrary.css b/libs/ui/src/screens/PatternLibrary.css new file mode 100644 index 000000000..754cb0d11 --- /dev/null +++ b/libs/ui/src/screens/PatternLibrary.css @@ -0,0 +1,317 @@ +/* Patterns library ported from .lazyweb/mockups/desktop-patterns.html: + search + chip toolbar | 2-up pattern card grid beside an optional rail. */ + +.ac-patterns { + font-family: var(--font-sans); + color: var(--ink); + height: 100%; + display: flex; + flex-direction: column; +} + +/* --- Toolbar (same family as the Issues/PRs toolbars) --- */ + +.ac-patterns__toolbar { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 12px 28px; + background: var(--panel); + border-bottom: 1px solid var(--line); +} +.ac-patterns__search { + min-height: 34px; +} +.ac-patterns__filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + border: none; +} +.ac-patterns__chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--soft); + color: var(--muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.ac-patterns__chip:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 1px; +} +.ac-patterns__chip--active { + color: var(--ink); + background: var(--panel); + border-color: var(--line-strong); + font-weight: 720; +} +.ac-patterns__chip-count { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--quiet); +} +.ac-patterns__chip--active .ac-patterns__chip-count { + color: var(--blue); +} + +/* --- States --- */ + +.ac-patterns__state { + color: var(--muted); + font-size: 14px; + padding: 20px 28px; +} +.ac-patterns__state--error { + color: var(--red); +} +.ac-patterns__retry { + font-family: inherit; + margin-left: 8px; + font-size: 13px; + color: var(--blue); + background: transparent; + border: 1px solid var(--line); + border-radius: 6px; + padding: 2px 10px; + cursor: pointer; +} + +/* --- Two-column body --- */ + +.ac-patterns__body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + overflow: hidden; +} +.ac-patterns__body--no-rail { + grid-template-columns: minmax(0, 1fr); +} + +/* --- Pattern card grid --- */ + +.ac-patterns__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + align-content: start; + padding: 18px 28px; + overflow-y: auto; +} +.ac-patterns__card { + display: grid; + gap: 10px; + align-content: start; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px 18px; +} +.ac-patterns__card--link { + cursor: pointer; +} +.ac-patterns__card--link:hover { + border-color: var(--line-strong); +} +.ac-patterns__card--link:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 2px; +} + +.ac-patterns__top { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 10px; +} +.ac-patterns__ico { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 9px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + font-weight: 800; +} +.ac-patterns__ico--pattern { + color: var(--green); + background: var(--green-soft); +} +.ac-patterns__ico--gotcha { + color: var(--amber); + background: var(--amber-soft); +} +.ac-patterns__ico--decision { + color: var(--blue); + background: var(--blue-soft); +} +/* Same non-tokenized purple family as the merged PR tile (WCAG AA fg). */ +.ac-patterns__ico--rule { + color: #8a3aa3; + background: #f6e7fb; +} +[data-theme='dark'] .ac-patterns__ico--rule { + color: #c98fd8; + background: #2c1f33; +} +.ac-patterns__title { + margin: 0; + font-size: 14px; + font-weight: 720; + line-height: 1.35; + overflow-wrap: anywhere; +} +.ac-patterns__code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--quiet); + margin-right: 6px; +} +.ac-patterns__lang { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--muted); + background: var(--soft); + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 6px; + white-space: nowrap; +} + +.ac-patterns__desc { + margin: 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--muted); +} +.ac-patterns__desc code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + background: var(--soft); + border: 1px solid var(--line); + border-radius: 4px; + padding: 0 4px; +} + +.ac-patterns__snippet { + margin: 0; + background: var(--term-bg); + color: var(--term-fg); + border-radius: 8px; + padding: 12px 14px; + max-height: 120px; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + line-height: 1.5; +} + +.ac-patterns__tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.ac-patterns__tag { + font-size: 10.5px; + color: var(--muted); + background: var(--soft); + border: 1px solid var(--line); + border-radius: 999px; + padding: 1px 8px; +} + +.ac-patterns__footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--line); + font-size: 11.5px; + color: var(--muted); +} +.ac-patterns__footer > span + span::before { + content: '·'; + margin-right: 6px; + color: var(--quiet); +} +.ac-patterns__stat--bad { + color: var(--red); + font-weight: 720; +} +.ac-patterns__stat--good { + color: var(--green); + font-weight: 720; +} + +/* --- Right meta rail (same card/kv family as the sibling screens) --- */ + +.ac-patterns__rail { + border-left: 1px solid var(--line); + background: var(--soft); + overflow-y: auto; + padding: 18px 22px; + display: grid; + gap: 14px; + align-content: start; +} +.ac-patterns__meta-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; +} +.ac-patterns__meta-card h3 { + margin: 0 0 6px; + font-size: 13px; + font-weight: 760; +} +.ac-patterns__kv { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + gap: 8px; + padding: 2px 0; + font-size: 12.5px; +} +.ac-patterns__kv span { + color: var(--quiet); + font-weight: 720; +} +.ac-patterns__kv strong { + font-weight: 720; + overflow-wrap: anywhere; +} + +@media (max-width: 1280px) { + .ac-patterns__grid { + grid-template-columns: minmax(0, 1fr); + } +} +@media (max-width: 1180px) { + .ac-patterns__body { + grid-template-columns: minmax(0, 1fr); + } + .ac-patterns__rail { + display: none; + } +} +@media (max-width: 760px) { + .ac-patterns__toolbar { + grid-template-columns: minmax(0, 1fr); + } + .ac-patterns__grid { + padding: 14px; + } +} diff --git a/libs/ui/src/screens/PatternLibrary.stories.tsx b/libs/ui/src/screens/PatternLibrary.stories.tsx new file mode 100644 index 000000000..f968a63d2 --- /dev/null +++ b/libs/ui/src/screens/PatternLibrary.stories.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react'; +import type React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { PatternLibrary } from './PatternLibrary'; +import type { UiPattern } from '../client/types'; + +const PATTERNS: UiPattern[] = [ + { + id: 'p1', + kind: 'pattern', + title: 'Typed nav config from a string-literal union', + lang: 'tsx', + description: 'Derive the sidebar item ids from a union so a typo fails at compile time.', + snippet: { + code: "type View = 'kanban' | 'changelog';\nconst NAV: Record = { … };", + }, + tags: ['frontend', 'react', 'i18n', 'refactor'], + footer: [ + { text: '3 reuses' }, + { text: 'conf 0.94' }, + { text: 'last used in SPEC-201 · 12m ago' }, + ], + }, + { + id: 'p2', + kind: 'gotcha', + title: 'i18n raw-key flash on lazy nav config', + lang: 'tsx', + description: 'Sidebar renders navigation:items.* before the bundle resolves at boot.', + snippet: { code: 'useTranslation(ns); // resolves async — guard first paint' }, + tags: ['frontend', 'i18n', 'qa-gate'], + footer: [ + { text: '2 past failures', tone: 'bad' }, + { text: 'conf 0.88' }, + { text: 'QA gate · screenshot guard required' }, + ], + }, + { + id: 'p3', + kind: 'decision', + code: 'D81', + title: 'Prefer a single shared AppShell over per-page layouts', + lang: 'arch', + description: 'One AppShell primitive owns the sidebar + topbar slots; pages fill content only.', + tags: ['architecture', 'shell', 'decided', '2026-04-02'], + footer: [ + { text: 'cited by 5 specs' }, + { text: 'conf 1.00' }, + { text: 'locked — change requires retro' }, + ], + }, + { + id: 'p4', + kind: 'rule', + code: 'R03', + title: 'All user-facing text uses translation keys', + lang: 'policy', + description: 'No hardcoded strings in JSX; every label goes through react-i18next.', + snippet: { code: "t('navigation:items.kanban') // not 'Kanban'" }, + tags: ['policy', 'i18n', 'enforced'], + footer: [{ text: 'applies to all frontend specs' }, { text: 'conf 1.00' }], + }, + { + id: 'p5', + kind: 'pattern', + title: 'Retry transient SDK errors with exponential backoff and jitter', + lang: 'py', + description: 'Wrap SDK calls; back off min(60, 2 ** attempt + random()) on 5xx.', + snippet: { code: 'delay = min(60, 2 ** attempt + random())' }, + tags: ['backend', 'recovery', 'claude-sdk'], + footer: [ + { text: '7 reuses' }, + { text: 'conf 0.96' }, + { text: 'last used in SPEC-203 · 8m ago' }, + ], + }, + { + id: 'p6', + kind: 'pattern', + title: 'Pilot one migration before bulk refactor', + lang: 'process', + description: 'Prove the contract on a single screen, then fan out via subagents.', + tags: ['process', 'refactor', 'subagents'], + footer: [ + { text: '3 reuses' }, + { text: 'conf 0.91' }, + { text: 'saved a full QA loop in SPEC-201', tone: 'good' }, + ], + }, + { + id: 'p7', + kind: 'gotcha', + title: 'Renaming a TS export without updating barrel files breaks watch-mode silently', + lang: 'ts', + tags: ['typescript', 'silent-fail', 'build'], + footer: [{ text: '1 past failure', tone: 'bad' }, { text: 'conf 0.82' }], + }, + { + id: 'p8', + kind: 'rule', + code: 'R07', + title: 'Branches stay local until the user explicitly pushes', + lang: 'policy', + tags: ['policy', 'git', 'safety'], + footer: [{ text: 'applies to all specs' }, { text: 'conf 1.00' }], + }, +]; + +const meta = { + title: 'Screens/PatternLibrary', + component: PatternLibrary, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + loading: false, + error: null, + patterns: PATTERNS, + searchPlaceholder: 'Search by name, snippet, tag, language…', + filters: [ + { id: 'all', label: 'All', count: 86 }, + { id: 'pattern', label: 'Patterns', count: 54 }, + { id: 'gotcha', label: 'Gotchas', count: 18 }, + { id: 'decision', label: 'Decisions', count: 11 }, + { id: 'rule', label: 'Rules', count: 14 }, + ], + activeFilterId: 'all', + onSelectPattern: () => {}, + metaSections: [ + { + title: 'Stats', + rows: [ + { label: 'Patterns', value: '54' }, + { label: 'Gotchas', value: '18' }, + { label: 'Decisions', value: '11' }, + { label: 'Rules', value: '14' }, + { label: 'Reuse hit', value: '74% this week' }, + ], + }, + { + title: 'Default context bundle', + rows: [ + { label: 'Included', value: '12 patterns' }, + { label: 'Budget', value: '~8k tokens' }, + ], + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function InteractivePatternLibrary( + args: Readonly>, +) { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + return ( + + ); +} + +export const Library: Story = { + render: (args) => , +}; + +export const NoRail: Story = { + args: { metaSections: undefined, onSelectPattern: undefined }, +}; + +export const Loading: Story = { + args: { patterns: null, loading: true, filters: undefined }, +}; + +export const ErrorState: Story = { + args: { + patterns: null, + error: new Error('Failed to load patterns'), + filters: undefined, + }, +}; + +export const Empty: Story = { + args: { patterns: [], filters: undefined, metaSections: undefined }, +}; diff --git a/libs/ui/src/screens/PatternLibrary.tsx b/libs/ui/src/screens/PatternLibrary.tsx new file mode 100644 index 000000000..29ed3cdae --- /dev/null +++ b/libs/ui/src/screens/PatternLibrary.tsx @@ -0,0 +1,284 @@ +import type { KeyboardEvent } from 'react'; +import { Input } from '../primitives/Input'; +import type { + UiMetaSection, + UiPattern, + UiPatternKind, +} from '../client/types'; +import './PatternLibrary.css'; + +const KIND_GLYPHS: Record = { + pattern: 'PAT', + gotcha: '⚠', + decision: 'D', + rule: 'R', +}; + +const KIND_LABELS: Record = { + pattern: 'Pattern', + gotcha: 'Gotcha', + decision: 'Decision', + rule: 'Rule', +}; + +export interface PatternLibraryFilter { + id: string; + label: string; + count?: number; +} + +export interface PatternLibraryStateLabels { + loading?: string; + retry?: string; + empty?: string; +} + +export interface PatternLibraryProps { + patterns: UiPattern[] | null; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + /** Accessible name for the search input. */ + searchLabel?: string; + filters?: PatternLibraryFilter[]; + activeFilterId?: string; + onSelectFilter?: (id: string) => void; + /** Accessible name for the filter chip group. */ + filtersLabel?: string; + onSelectPattern?: (pattern: UiPattern) => void; + /** Localized loading/retry/empty state labels; falls back to English. */ + stateLabels?: PatternLibraryStateLabels; + /** Localized pattern-kind glyph labels (accessible names). */ + kindLabels?: Partial>; + /** Right-rail meta cards (stats, default bundle, …). */ + metaSections?: UiMetaSection[]; +} + +/** + * Presentational patterns library mirroring the `.lazyweb` mockup: a + * search + filter-chip toolbar over a two-column body — a 2-up grid of + * pattern cards (kind glyph, title, lang chip, description, optional code + * snippet, tags, footer facts) beside an optional right meta rail. + * Data-agnostic — pair with an adapter that maps the app's pattern store + * onto UiPattern[]. + */ +export function PatternLibrary({ + patterns, + loading = false, + error = null, + onRetry, + searchValue, + onSearchChange, + searchPlaceholder, + searchLabel, + filters, + activeFilterId, + onSelectFilter, + filtersLabel, + onSelectPattern, + stateLabels, + kindLabels, + metaSections, +}: Readonly) { + const hasToolbar = + onSearchChange != null || (filters != null && filters.length > 0); + const sections = metaSections ?? []; + + return ( +
+ {hasToolbar && ( +
+ {onSearchChange != null && ( + onSearchChange(event.target.value)} + /> + )} + {filters != null && filters.length > 0 && ( +
+ {filters.map((filter) => ( + + ))} +
+ )} +
+ )} + + {loading && ( +

+ {stateLabels?.loading ?? 'Loading…'} +

+ )} + + {!loading && error != null && ( +

+ {error.message} + {onRetry != null && ( + + )} +

+ )} + + {!loading && + error == null && + (patterns == null || patterns.length === 0) && ( +

+ {stateLabels?.empty ?? 'No patterns yet.'} +

+ )} + + {!loading && error == null && patterns != null && patterns.length > 0 && ( +
0 ? '' : ' ac-patterns__body--no-rail' + }`} + > +
+ {patterns.map((pattern) => ( + + ))} +
+ + {sections.length > 0 && ( + + )} +
+ )} +
+ ); +} + +function PatternCard({ + pattern, + onSelect, + kindLabels, +}: Readonly<{ + pattern: UiPattern; + onSelect?: (pattern: UiPattern) => void; + kindLabels?: Partial>; +}>) { + const interactive = onSelect != null; + return ( +
onSelect(pattern), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(pattern); + } + }, + } + : {})} + > +
+ + {KIND_GLYPHS[pattern.kind]} + +

+ {pattern.code != null && ( + {pattern.code} + )} + {pattern.title} +

+ {pattern.lang != null && ( + {pattern.lang} + )} +
+ + {pattern.description != null && pattern.description !== '' && ( +

{pattern.description}

+ )} + + {pattern.snippet != null && ( +
+          {pattern.snippet.code}
+        
+ )} + + {pattern.tags != null && pattern.tags.length > 0 && ( +
+ {pattern.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {pattern.footer != null && pattern.footer.length > 0 && ( +
+ {pattern.footer.map((stat, index) => ( + + {stat.text} + + ))} +
+ )} +
+ ); +}