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
19 changes: 19 additions & 0 deletions corpus/frontend/ui-ux/dark-mode-principles.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: dark-mode-principles
domain: color
rule: "Dark mode: redesign, don't invert. Warm charcoal, not black."
detail: >-
Background: oklch(0.13 0.008 265) - warm charcoal, not #000. Text:
oklch(0.94 0.008 265) - off-white, not #fff. Higher elevation = lighter bg
(shadows invisible on dark). Reduce saturation slightly (vivid on dark =
neon). Primary accents brighten: brand-600 → brand-400.
cssExample: |
.dark {
--color-bg: oklch(0.13 0.008 265); /* warm charcoal */
--color-text: oklch(0.94 0.008 265); /* off-white */
/* surface-1 */ --color-surface: oklch(0.16 0.008 265);
/* surface-2 */ --color-card: oklch(0.19 0.008 265);
}
antiPatterns:
- Pure black background (#000)
- Pure white text (#fff) on dark
- Inverting light mode colors directly
10 changes: 10 additions & 0 deletions corpus/frontend/ui-ux/index.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace: frontend.ui-ux
principles:
type-scale:
file: type-scale.yaml
wcag-contrast:
file: wcag-contrast.yaml
dark-mode-principles:
file: dark-mode-principles.yaml
touch-targets:
file: touch-targets.yaml
17 changes: 17 additions & 0 deletions corpus/frontend/ui-ux/touch-targets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: touch-targets
domain: accessibility
rule: "Touch targets: min 44×44px (WCAG), recommended 48×48px. Gap ≥ 8px between targets."
detail: >-
Visual size ≠ touch target. A 34px button can have a 44px hit area via
padding or ::after pseudo-element. Gap prevents accidental taps on adjacent
targets.
cssExample: |
/* Hit area expansion */
.btn-small::after {
content: '';
position: absolute;
inset: -8px;
}
antiPatterns:
- "< 44px touch targets on mobile"
- Adjacent buttons with no gap
17 changes: 17 additions & 0 deletions corpus/frontend/ui-ux/type-scale.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: type-scale
domain: typography
rule: Use mathematical ratios for type scale, not arbitrary sizes.
detail: >-
Common ratios: 1.25 (Major Third), 1.333 (Perfect Fourth), 1.414 (Augmented
Fourth). Recommended web scale: Display 48-72px, H1 40-56px, H2 28-40px,
H3 20-24px, Subtitle 16-20px, Body 16px, Body-sm 14px, Caption 13px,
Overline 12px.
cssExample: |
/* fluid headings, fixed body */
font-size: clamp(2.5rem, 4vw, 3.5rem); /* h1 */
font-size: clamp(1.75rem, 3vw, 2.5rem); /* h2 */
font-size: 1rem; /* body - fixed */
antiPatterns:
- Random px values with no ratio
- Fluid body text (causes reflow)
- More than 2 type families
12 changes: 12 additions & 0 deletions corpus/frontend/ui-ux/wcag-contrast.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: wcag-contrast
domain: color
rule: "Body text: 4.5:1 (AA). Large text ≥18px bold or ≥24px: 3:1 (AA). UI components: 3:1."
detail: >-
AAA (enhanced): 7:1 for body, 4.5:1 for large. Fix: reduce OKLCH Lightness
(L) of status colors from ~0.63 to ~0.55. Keep C and H unchanged.
examples:
- "Run: npm install wcag-contrast"
- "Online: https://webaim.org/resources/contrastchecker/"
antiPatterns:
- Testing only in light mode
- Assuming brand colors pass without checking
2 changes: 2 additions & 0 deletions corpus/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ namespaces:
index: frontend/design-tokens/index.yaml
frontend.shadcn:
index: frontend/shadcn/index.yaml
frontend.ui-ux:
index: frontend/ui-ux/index.yaml
108 changes: 104 additions & 4 deletions src/plugins/ui-ux/tools/get-principle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,101 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
import { z } from "zod";
import { PRINCIPLES } from "../data.js";
import { PRINCIPLES, type Principle } from "../data.js";

type CorpusIndex = {
namespaces?: {
"frontend.ui-ux"?: {
index?: string;
};
};
};

type CorpusNamespaceIndex = {
namespace?: string;
principles?: Record<string, { file?: string }>;
};

type CorpusPrincipleEntry = {
name?: string;
domain?: string;
rule?: string;
detail?: string;
examples?: string[];
antiPatterns?: string[];
cssExample?: string;
};

type LoadedCorpusPrinciple = { principle: Principle; source: string };

const moduleDir = dirname(fileURLToPath(import.meta.url));
const corpusRoot = join(moduleDir, "../../../../corpus");
const corpusNamespace = "frontend.ui-ux";

const cachedCorpusPrinciples = new Map<string, LoadedCorpusPrinciple | null>();

function normalize(name: string): string {
return name.toLowerCase().trim();
}

function loadCorpusPrinciple(name: string): LoadedCorpusPrinciple | null {
const key = normalize(name);
if (cachedCorpusPrinciples.has(key)) {
return cachedCorpusPrinciples.get(key) ?? null;
}

try {
const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8");
const index = YAML.parse(indexRaw) as CorpusIndex | null;
const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index;
if (!namespaceIndexPath) {
cachedCorpusPrinciples.set(key, null);
return null;
}

const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8");
const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null;
const principlePath = namespaceIndex?.principles?.[key]?.file;
if (!principlePath) {
cachedCorpusPrinciples.set(key, null);
return null;
}

const raw = readFileSync(join(corpusRoot, "frontend/ui-ux", principlePath), "utf8");
const entry = YAML.parse(raw) as CorpusPrincipleEntry | null;
if (
!entry ||
normalize(entry.name ?? "") !== key ||
!entry.domain ||
!entry.rule ||
!entry.detail
) {
cachedCorpusPrinciples.set(key, null);
return null;
}

const loaded: LoadedCorpusPrinciple = {
principle: {
name: entry.name ?? name,
domain: entry.domain as Principle["domain"],
rule: entry.rule,
detail: entry.detail,
examples: entry.examples,
antiPatterns: entry.antiPatterns,
cssExample: entry.cssExample,
},
source: corpusNamespace,
};
cachedCorpusPrinciples.set(key, loaded);
return loaded;
} catch {
cachedCorpusPrinciples.set(key, null);
return null;
}
}

export function register(server: McpServer): void {
server.tool(
Expand All @@ -10,9 +105,10 @@ export function register(server: McpServer): void {
name: z.string().describe("Principle name (e.g. 'type-scale', 'wcag-contrast', 'dark-mode-principles', 'touch-targets', 'easing-rules')"),
},
async ({ name }) => {
const principle = PRINCIPLES.find(
(p) => p.name.toLowerCase() === name.toLowerCase()
);
const corpusEntry = loadCorpusPrinciple(name);
const principle =
corpusEntry?.principle ??
PRINCIPLES.find((p) => p.name.toLowerCase() === name.toLowerCase());

if (!principle) {
const available = PRINCIPLES.map((p) => p.name).join(", ");
Expand Down Expand Up @@ -41,6 +137,10 @@ export function register(server: McpServer): void {
for (const ap of principle.antiPatterns) text += `- ❌ ${ap}\n`;
}

if (corpusEntry) {
text += `\n**Corpus Source:** ${corpusEntry.source}`;
}

return { content: [{ type: "text", text }] };
}
);
Expand Down
49 changes: 49 additions & 0 deletions tests/ui-ux-principle-corpus-backed-tools-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { captureTool, extractTextContent } from "./helpers";
import { register as registerUiUxGetPrinciple } from "../src/plugins/ui-ux/tools/get-principle.ts";

const uiUxGetPrinciple = captureTool(registerUiUxGetPrinciple);

test("ui_ux_get_principle prefers corpus metadata for type-scale", async () => {
const result = await uiUxGetPrinciple.invoke({ name: "type-scale" });
const text = extractTextContent(result);

expect(text).toContain("# type-scale [typography]");
expect(text).toContain("**Corpus Source:** frontend.ui-ux");
expect(text).toContain("clamp(2.5rem, 4vw, 3.5rem)");
});

test("ui_ux_get_principle prefers corpus metadata for wcag-contrast", async () => {
const result = await uiUxGetPrinciple.invoke({ name: "wcag-contrast" });
const text = extractTextContent(result);

expect(text).toContain("# wcag-contrast [color]");
expect(text).toContain("**Corpus Source:** frontend.ui-ux");
expect(text).toContain("4.5:1 (AA)");
});

test("ui_ux_get_principle prefers corpus metadata for dark-mode-principles", async () => {
const result = await uiUxGetPrinciple.invoke({ name: "dark-mode-principles" });
const text = extractTextContent(result);

expect(text).toContain("# dark-mode-principles [color]");
expect(text).toContain("**Corpus Source:** frontend.ui-ux");
expect(text).toContain("oklch(0.13 0.008 265)");
});

test("ui_ux_get_principle prefers corpus metadata for touch-targets", async () => {
const result = await uiUxGetPrinciple.invoke({ name: "touch-targets" });
const text = extractTextContent(result);

expect(text).toContain("# touch-targets [accessibility]");
expect(text).toContain("**Corpus Source:** frontend.ui-ux");
expect(text).toContain("min 44×44px");
});

test("ui_ux_get_principle falls back to in-file data for non-corpus principles", async () => {
const result = await uiUxGetPrinciple.invoke({ name: "easing-rules" });
const text = extractTextContent(result);

expect(text).toContain("# easing-rules [motion]");
expect(text).not.toContain("**Corpus Source:** frontend.ui-ux");
});
Loading