feat: build local skills registry

This commit is contained in:
2026-06-05 09:30:00 +02:00
parent f0a948aec9
commit 370aeec2a0
18 changed files with 1334 additions and 16 deletions

View File

@@ -0,0 +1,17 @@
import { AuditDetail } from "@/components/audits/audit-detail";
export default async function AuditDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<main className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl">
<AuditDetail id={id as unknown as string} />
</div>
</main>
);
}

View File

@@ -1,10 +1,11 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
import { AuditsBoard } from "@/components/audits/audits-board";
export default function AuditsPage() {
return (
<DashboardPlaceholderPage
description="Audit-Review, Screenshots und oeffentliche Freigaben folgen in TASK-12 und TASK-13."
title="Audits"
/>
<main className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<AuditsBoard />
</div>
</main>
);
}

View File

@@ -1,9 +1,10 @@
---
id: TASK-10
title: Build the local skills registry
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:13'
updated_date: '2026-06-05 07:28'
labels:
- mvp
- agent
@@ -24,19 +25,34 @@ Create the local skills registry concept for the agent. Design and marketing ski
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A project-local skills directory or convention exists for imported design and marketing skills
- [ ] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
- [ ] #3 Agent code can load and parse the skills registry into structured skill metadata
- [ ] #4 Audit records store the list of used skills, including skill name/category and version or source where available
- [ ] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
- [x] #1 A project-local skills directory or convention exists for imported design and marketing skills
- [x] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
- [x] #3 Agent code can load and parse the skills registry into structured skill metadata
- [x] #4 Audit records store the list of used skills, including skill name/category and version or source where available
- [x] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Define project-local skill storage conventions.
2. Create the initial skills.md registry format and seed entries for design, UX, marketing, copy, SEO, and offer-writing skills.
3. Add parser/loader for registry metadata.
4. Store selected skill metadata with each audit.
5. Show used skills in the internal audit detail UI only.
1. Worker A uses TDD to add project-local skills conventions, seed skills.md, skills source files, and a strict skills registry parser/loader.
2. Worker B uses TDD to extend Convex audit persistence so audit records can store used skill metadata with name, category, version, and source.
3. Worker C uses TDD to add the internal dashboard audit detail/list UI and compact Verwendete Skills overview while keeping public audit pages free of skill metadata.
4. Orchestrator reviews subagent outputs, resolves integration issues through focused subagents, runs full verification, and checks TASK-10 acceptance criteria without marking Done until user confirmation.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation using subagent-driven and test-driven workflow with parallel agents where write scopes are independent. Orchestrator will not hand-code feature changes; workers own implementation patches and tests.
Worker C: implemented audits dashboard internals for TASK-10. Added new tests (tests/audit-skills-ui.test.ts), new components/audits/{audits-board,audit-detail}.tsx and routes app/dashboard/audits/page.tsx + app/dashboard/audits/[id]/page.tsx. Internal detail route still passes raw id from params Promise; public audit page unchanged and remains skill-free.
Implementation completed through parallel subagent-driven TDD slices. Worker scopes: registry/parser, Convex audit persistence, dashboard audit UI. Review findings addressed by follow-up workers for getDetail result shape/useQuery FunctionReference and indented skills.md field parsing. Fresh orchestrator verification: pnpm test passed with 179/179 tests; pnpm lint passed with 0 errors and 2 existing generated BetterAuth warnings; pnpm exec convex codegen --dry-run --typecheck enable passed after network escalation; pnpm build passed after network escalation. Sandbox-only failures before escalation were DNS/Sentry for Convex and Google Fonts for Next build.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped the local skills registry with project-local skills.md and skills/ source files, parser/loader tests, Convex audit usedSkills persistence, and internal dashboard audit skill overview. Verified with pnpm test; task remains public-audit safe because used skills are only shown in the dashboard detail route.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,184 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "convex/react";
import type { Id } from "@/convex/_generated/dataModel";
import { api } from "@/convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Globe } from "lucide-react";
type UsedSkill = {
name: string;
purpose?: string;
category?: string;
source?: string;
version?: string;
};
type LeadContext = {
_id: Id<"leads">;
companyName?: string;
websiteDomain?: string;
websiteUrl?: string;
city?: string;
niche?: string;
};
type SkillAwareAudit = {
_id: Id<"audits">;
slug: string;
checkedDomain: string;
status: "draft" | "approved" | "published" | "deactivated";
checkedPages: string[];
createdAt?: number;
updatedAt?: number;
usedSkills?: UsedSkill[];
internalSummary?: string | null;
};
type AuditDetailResult = {
audit: SkillAwareAudit;
lead: LeadContext | null;
} | null;
const statusText: Record<string, string> = {
draft: "Entwurf",
approved: "Freigegeben",
published: "Veröffentlicht",
deactivated: "Deaktiviert",
};
function getStatusLabel(status: SkillAwareAudit["status"]) {
return statusText[status] ?? "Unbekannt";
}
function leadSummary(lead: LeadContext | null | undefined) {
if (!lead) {
return "Kein Lead-Kontext gespeichert";
}
const detail = [lead.city, lead.niche].filter(Boolean).join(" • ");
let leadDomain = lead.websiteDomain ?? "—";
if (!leadDomain && lead.websiteUrl) {
try {
leadDomain = new URL(lead.websiteUrl).hostname;
} catch {
leadDomain = lead.websiteUrl;
}
}
return (
<>
<p className="font-medium">{lead.companyName ?? "Lead ohne Name"}</p>
<p className="text-sm text-muted-foreground">{detail || "Kein Kontext textlich"}</p>
<p className="mt-1 inline-flex items-center gap-1 text-sm text-muted-foreground">
<Globe className="size-3.5" />
{leadDomain}
</p>
</>
);
}
export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
const result = useQuery(api.audits.getDetail, {
id: id as Id<"audits">,
}) as AuditDetailResult | undefined;
const audit = result?.audit;
const lead = result?.lead;
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
if (result === null) {
return (
<Card>
<CardHeader>
<CardTitle>Audit nicht gefunden</CardTitle>
<CardDescription>
Der gewünschte Audit-Datensatz konnte nicht geladen werden.
</CardDescription>
</CardHeader>
</Card>
);
}
if (audit === undefined) {
return (
<Card>
<CardHeader>
<CardTitle>Audit wird geladen...</CardTitle>
</CardHeader>
</Card>
);
}
return (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardDescription>Audit-Detail</CardDescription>
<CardTitle className="text-xl">#{audit.slug}</CardTitle>
<p className="inline-flex max-w-full items-center gap-1 truncate text-sm text-muted-foreground">
<Globe className="size-3.5" />
{audit.checkedDomain}
</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<p>
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Seitenanzahl</p>
<p>{audit.checkedPages.length}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Lead-Kontext</p>
<div className="text-sm">{leadSummary(lead)}</div>
</div>
{audit.internalSummary ? (
<div>
<p className="text-sm text-muted-foreground">Interne Notiz</p>
<p className="text-sm text-muted-foreground">{audit.internalSummary}</p>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Verwendete Skills</CardTitle>
<CardDescription>Skills, die an diesem Audit beteiligt wurden.</CardDescription>
</CardHeader>
<CardContent>
{usedSkills.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Skills gespeichert</p>
) : (
<ul className="grid gap-2">
{usedSkills.map((skill, index) => (
<li
className="rounded-md border p-2 text-sm"
key={`${skill.name}-${index}`}
>
<p className="font-medium">{skill.name}</p>
<p className="text-sm text-muted-foreground">
{skill.purpose ?? "Keine Zweckbeschreibung"}
</p>
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
{skill.source ? <span className="text-xs text-muted-foreground">{skill.source}</span> : null}
</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Files, SquarePen } from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
type AuditRow = NonNullable<AuditsListResult>[number];
const statusText: Record<string, string> = {
draft: "Entwurf",
approved: "Freigegeben",
published: "Veröffentlicht",
deactivated: "Deaktiviert",
};
const fallbackStatus = "Unbekannt";
function formatPageCount(pages: AuditRow["checkedPages"]) {
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
}
function getStatusLabel(status: AuditRow["status"]) {
return statusText[status] ?? fallbackStatus;
}
function AuditsBoardLoading() {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
</header>
<div className="rounded-lg border">
<div className="grid gap-2 p-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-20 rounded-md" key={index} />
))}
</div>
</div>
</section>
);
}
export function AuditsBoard() {
const audits = useQuery(api.audits.list, { limit: 100 });
const rows = useMemo(() => {
if (!audits) {
return [];
}
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
}, [audits]);
if (audits === undefined) {
return <AuditsBoardLoading />;
}
if (rows.length === 0) {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<article className="rounded-lg border p-4">
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
Zeilen.
</p>
</article>
</section>
);
}
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<section className="space-y-2">
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Slug</span>
<span>Domain</span>
<span>Status</span>
<span>Seitenanzahl</span>
<span className="text-right">Aktion</span>
</div>
<div className="space-y-2">
{rows.map((audit: AuditRow) => (
<article
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
key={audit._id}
>
<div className="min-w-0">
<p className="truncate font-medium">{audit.slug}</p>
</div>
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
<p className="text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Files className="size-3.5" />
{formatPageCount(audit.checkedPages)}
</span>
</p>
<div className="flex justify-end">
<Link
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
href={`/dashboard/audits/${audit._id}`}
>
<SquarePen className="size-4" />
Öffnen
</Link>
</div>
</article>
))}
</div>
</section>
</section>
);
}

View File

@@ -9,6 +9,14 @@ const auditStatus = v.union(
v.literal("published"),
v.literal("deactivated"),
);
const usedSkillsValidator = v.array(
v.object({
name: v.string(),
category: v.string(),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
);
export const create = mutation({
args: {
@@ -16,6 +24,7 @@ export const create = mutation({
slug: v.string(),
checkedDomain: v.string(),
checkedPages: v.array(v.string()),
usedSkills: v.optional(usedSkillsValidator),
status: v.optional(auditStatus),
internalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()),
@@ -42,6 +51,19 @@ export const create = mutation({
},
});
export const getDetail = query({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
const audit = await ctx.db.get(args.id);
if (!audit) {
return null;
}
const lead = await ctx.db.get(audit.leadId);
return { audit, lead };
},
});
export const get = query({
args: { id: v.id("audits") },
handler: async (ctx, args) => {

View File

@@ -231,6 +231,16 @@ export default defineSchema({
pageSpeedSummary: v.optional(auditMetricSummary),
playwrightSummary: v.optional(playwrightSummary),
textFindings: v.optional(v.array(v.string())),
usedSkills: v.optional(
v.array(
v.object({
name: v.string(),
category: v.string(),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
),
),
skillSummaries: v.optional(
v.array(
v.object({

178
lib/skills-registry.ts Normal file
View File

@@ -0,0 +1,178 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const SKILL_CATEGORIES = [
"design",
"ux",
"marketing",
"copy",
"seo",
"offer",
] as const;
export type SkillCategory = (typeof SKILL_CATEGORIES)[number];
export type SkillRegistryEntry = {
name: string;
purpose: string;
whenToUse: string;
whenNotToUse: string;
requiredInput: string;
expectedOutput: string;
category: SkillCategory;
version?: string;
source?: string;
};
export type AuditUsedSkill = {
name: string;
category: SkillCategory;
version?: string;
source?: string;
};
type ParsedFieldName =
| "Purpose"
| "When to use"
| "When not to use"
| "Required input"
| "Expected output"
| "Category"
| "Version"
| "Source";
const REQUIRED_FIELDS: ParsedFieldName[] = [
"Purpose",
"When to use",
"When not to use",
"Required input",
"Expected output",
"Category",
];
const FIELD_LABELS_RE = /^(Purpose|When to use|When not to use|Required input|Expected output|Category|Version|Source):\s*(.*?)\s*$/;
function normalizeCategory(value: string): SkillCategory {
const normalized = value.toLowerCase();
if (!isValidSkillCategory(normalized)) {
throw new Error(
`Unknown category "${value}". Valid categories are: ${SKILL_CATEGORIES.join(", ")}.`,
);
}
return normalized;
}
function isValidSkillCategory(
value: string,
): value is SkillCategory {
return (SKILL_CATEGORIES as ReadonlyArray<string>).includes(value);
}
function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry {
let name: string | null = null;
const values: Partial<Record<ParsedFieldName, string>> = {};
let currentField: ParsedFieldName | null = null;
const sectionTitle = lines[0];
if (!sectionTitle.startsWith("##")) {
throw new Error(`Expected section ${sectionIndex} to start with a skill header.`);
}
name = sectionTitle.replace(/^##\s*/, "").trim();
if (name.length === 0) {
throw new Error(`Skill section ${sectionIndex} has an empty name.`);
}
for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
const trimmedLine = line.trim();
if (trimmedLine.length === 0) {
continue;
}
const match = trimmedLine.match(FIELD_LABELS_RE);
if (match) {
const field = match[1] as ParsedFieldName;
currentField = field;
values[field] = match[2].trim();
continue;
}
if (currentField === null) {
throw new Error(`Unexpected line in section "${name}": ${line}`);
}
values[currentField] = `${values[currentField] ?? ""}\n${line.trim()}`.trim();
}
for (const requiredField of REQUIRED_FIELDS) {
const value = values[requiredField]?.trim();
if (!value) {
throw new Error(
`Missing required field "${requiredField}" for skill "${name}".`,
);
}
}
const category = normalizeCategory(values.Category!.trim());
return {
name,
purpose: values["Purpose"]!,
whenToUse: values["When to use"]!,
whenNotToUse: values["When not to use"]!,
requiredInput: values["Required input"]!,
expectedOutput: values["Expected output"]!,
category,
version: values["Version"]?.trim() || undefined,
source: values["Source"]?.trim() || undefined,
};
}
export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
const normalized = source.replace(/\r\n/g, "\n");
const rawSections = normalized
.split(/^##\s+/m)
.map((entry) => entry.trim())
.filter(Boolean);
const entries: SkillRegistryEntry[] = [];
const names = new Set<string>();
for (let index = 0; index < rawSections.length; index += 1) {
const rawSection = rawSections[index];
const lines = rawSection
.split("\n")
.map((line) => line.trimEnd())
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
const sectionLines = [`## ${lines.at(0) ?? ""}`, ...lines.slice(1)];
const parsed = parseSection(sectionLines, index + 1);
const normalizedName = parsed.name.trim().toLowerCase();
if (names.has(normalizedName)) {
throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`);
}
names.add(normalizedName);
entries.push(parsed);
}
return entries;
}
export async function loadSkillsRegistry(
registryPath = join(process.cwd(), "skills.md"),
): Promise<SkillRegistryEntry[]> {
const source = await readFile(registryPath, "utf8");
return parseSkillsRegistry(source);
}
export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill {
return {
name: skill.name,
category: skill.category,
version: skill.version,
source: skill.source,
};
}

59
skills.md Normal file
View File

@@ -0,0 +1,59 @@
## Design Audit
Purpose: Prüft die visuelle Qualität einer Seite auf Konsistenz, Orientierung und Conversion-Relevanz.
When to use: Nutze diesen Skill, wenn das Website-Layout, visuelle Hierarchie oder die Markenwirkung bewertet werden soll.
When not to use: Nicht für technische Crawling- oder Performance-Analyse.
Required input: URL, Seitenstruktur, Kernaussagen, Designsystem- oder Style-Constraints.
Expected output: Konkrete Verbesserungsvorschläge mit Priorität nach Wirkung auf Wahrnehmung und Conversion.
Category: design
Version: 1.0
Source: skills/design-audit.md
## UX Friction Review
Purpose: Findet Reibungspunkte in Nutzerfluss, Formularen und Aktionen, die Nutzer vom Ziel abhalten.
When to use: Nutze diesen Skill für Kontaktformulare, Terminbuchung und erste Kontaktversuche.
When not to use: Nicht verwenden, wenn keine klaren User-Flows sichtbar sind oder Rohdaten fehlen.
Required input: URL, beobachtete Nutzerwege, wichtigste Aktionen und Ziel-CTA.
Expected output: Priorisierte Liste der größten UX-Hürden mit klaren, umsetzbaren Gegenmaßnahmen.
Category: ux
Version: 1.0
Source: skills/ux-friction-review.md
## Marketing Positioning
Purpose: Schärft die Positionierung für lokales B2B/B2C-Webdesign im Wettbewerb.
When to use: Nutze ihn, wenn Differenzierung, Angebotsschwerpunkt und Nutzenstory fehlen.
When not to use: Nicht für reine SEO-Fix-Lists oder technische Fehlerbehebungen.
Required input: Leistungsversprechen, Zielkundensegment, lokale Wettbewerbslage, bisheriger Auftritt.
Expected output: Klare Positionierungshypothese mit passenden USPs und Zielgruppen-Fokus.
Category: marketing
Version: 1.0
Source: skills/marketing-positioning.md
## Copy Clarity
Purpose: Optimiert Klarheit, Lesbarkeit und Tonalität für Webseiten- und Outreach-Texte.
When to use: Nutze diesen Skill bei unklaren, langen oder unpassend abstrakten Texten.
When not to use: Nicht bei legalen Texten mit festen Formulierungen oder Produkt-CTAs ohne Kontext.
Required input: Zielgruppe, Zweck des Textes, bestehender Entwurf, bevorzugter Sprachstil.
Expected output: Überarbeitete, verständliche und handlungsstarke Textvorschläge.
Category: copy
Version: 1.0
Source: skills/copy-clarity.md
## Local SEO
Purpose: Bewertet lokale Auffindbarkeit, Konsistenz und Relevanz der Website für eine definierte Ortsmarke.
When to use: Nutze diesen Skill, wenn lokale Suchrelevanz, Google-Präsenz oder Impressum/Struktur geprüft werden.
When not to use: Nicht als Ersatz für technische SEO-Fehlerdiagnose ohne inhaltlichen Kontext.
Required input: Standortdaten, Nischenfokus, Seitenstruktur, Kontakt-/Nennungsdaten.
Expected output: Konkrete Maßnahmen für lokale Sichtbarkeit, NAP-Konsistenz und Local-Authority.
Category: seo
Version: 1.0
Source: skills/local-seo.md
## Offer Writing
Purpose: Erstellt klare, konkrete Angebote mit Problemfokus und glaubwürdiger Preislogik.
When to use: Nutze diesen Skill, wenn ein Vorschlag oder Angebotsentwurf in Audit- oder Outreach-Schritten gebraucht wird.
When not to use: Nicht für reine Analyse-Ausgaben ohne Angebotsreifheit.
Required input: Projektumfang, Zielprobleme, Budgetrahmen, Leistungsumfang, Risiko-/Zeitaspekte.
Expected output: Offer-Prompt, Paketstruktur und überzeugende, ehrliche Angebots-Formulierungen.
Category: offer
Version: 1.0
Source: skills/offer-writing.md

9
skills/copy-clarity.md Normal file
View File

@@ -0,0 +1,9 @@
# Copy Clarity
Use this file for headline/body copy improvements.
- Replace jargon with clear benefit language.
- Tighten sentences to one core message per paragraph.
- Keep benefit first, proof second, next step third.
- Preserve the requested tone and avoid overpromising.
- Output a concise before/after improvement set.

9
skills/design-audit.md Normal file
View File

@@ -0,0 +1,9 @@
# Design Audit
Use this file as the concrete operating guide for design-oriented analysis.
- Check visual hierarchy and above-the-fold clarity.
- Validate CTA prominence, spacing, and action visibility.
- Flag inconsistent components, mismatched states, and weak contrast.
- Verify tone alignment between typography, imagery, and brand intent.
- Prioritize 35 actionable fixes by expected conversion impact.

9
skills/local-seo.md Normal file
View File

@@ -0,0 +1,9 @@
# Local SEO
Use this file for local discovery and trust-relevance analysis.
- Verify NAP consistency across page and metadata.
- Check service-location clarity and service-area fit.
- Review local proof signals: reviews, case examples, local references.
- Validate schema/markup hints and local keyword fit.
- Recommend realistic, high-impact local SEO adjustments.

View File

@@ -0,0 +1,9 @@
# Marketing Positioning
Use this file to sharpen market message and differentiation.
- Define one-liner positioning for the target segment.
- List the top 3 differentiators vs local alternatives.
- Connect positioning to pain points and decision criteria.
- Keep claims evidence-backed from website and audit findings.
- Produce a short positioning paragraph for outreach and landing intro.

9
skills/offer-writing.md Normal file
View File

@@ -0,0 +1,9 @@
# Offer Writing
Use this guide when producing outreach-ready offer text.
- Translate findings into 12 concrete pain-result pairings.
- Keep scope, deliverables, and next step explicit.
- Tie price framing to measurable benefit and timeline.
- Use transparent language and avoid inflated promises.
- Return a compact offer draft and optional follow-up variant.

View File

@@ -0,0 +1,9 @@
# UX Friction Review
Use this file when analyzing onboarding, lead capture, and first conversion path friction.
- Map the main path from landing → action.
- Find blockers: too many clicks, hidden links, field overload, unclear status.
- Identify trust anchors: who, what, how long, what happens next.
- Assess mobile ergonomics and cognitive load.
- Recommend friction-reduction experiments with effort and impact.

View File

@@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
const schemaPath = join(process.cwd(), "convex", "schema.ts");
const auditsPath = join(process.cwd(), "convex", "audits.ts");
const schemaSource = readFileSync(schemaPath, "utf8");
const auditsSource = readFileSync(auditsPath, "utf8");
const sourceFile = ts.createSourceFile(
"audits.ts",
auditsSource,
ts.ScriptTarget.ES2022,
true,
);
function extractTableSection(tableName: string) {
const marker = `${tableName}: defineTable({`;
const markerIndex = schemaSource.indexOf(marker);
assert.notEqual(
markerIndex,
-1,
`Expected schema table definition for ${tableName}.`,
);
const objectStart = schemaSource.indexOf("{", markerIndex);
let depth = 0;
let objectEnd = -1;
for (let index = objectStart; index < schemaSource.length; index += 1) {
if (schemaSource[index] === "{") {
depth += 1;
} else if (schemaSource[index] === "}") {
depth -= 1;
if (depth === 0) {
objectEnd = index;
break;
}
}
}
assert.notEqual(
objectEnd,
-1,
`Could not parse schema object for ${tableName}.`,
);
const objectBlock = schemaSource.slice(objectStart, objectEnd + 1);
return { objectBlock };
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = auditsSource.indexOf(marker);
assert.notEqual(
declarationIndex,
-1,
`Expected declaration for ${name}.`,
);
const openBraceIndex = auditsSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < auditsSource.length; index += 1) {
const char = auditsSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(
end,
-1,
`Expected balanced braces for export ${name}.`,
);
return auditsSource.slice(openBraceIndex, end + 1);
}
function extractFieldSection(source: string, fieldName: string, nextFieldName: string) {
const match = source.match(
new RegExp(
`${fieldName}:\\s*v\\.optional\\([\\s\\S]*?(?=\\s*${nextFieldName}:)`,
),
);
assert.notEqual(
match,
null,
`Expected ${fieldName} field with expected object structure in schema.`,
);
return match![0];
}
function hasPattern(source: string, pattern: RegExp, message: string) {
assert.equal(pattern.test(source), true, message);
}
test("audits schema stores compact usedSkills metadata", () => {
const { objectBlock } = extractTableSection("audits");
const usedSkillsSection = extractFieldSection(
objectBlock,
"usedSkills",
"skillSummaries",
);
const skillSummariesSection = extractFieldSection(
objectBlock,
"skillSummaries",
"multimodalSummary",
);
hasPattern(usedSkillsSection, /usedSkills:\s*v\.optional\(/, "usedSkills should be optional.");
hasPattern(
usedSkillsSection,
/name:\s*v\.string\(\)/,
"usedSkills.name should be string.",
);
hasPattern(
usedSkillsSection,
/category:\s*v\.string\(\)/,
"usedSkills.category should be string.",
);
hasPattern(
usedSkillsSection,
/version:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.version should be optional string.",
);
hasPattern(
usedSkillsSection,
/source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.source should be optional string.",
);
hasPattern(
usedSkillsSection,
/v\.array\(/,
"usedSkills should be an optional array of objects.",
);
hasPattern(
usedSkillsSection,
/v\.object\(/,
"usedSkills should be defined with v.object fields.",
);
hasPattern(skillSummariesSection, /skillSummaries:/, "skillSummaries should still exist.");
hasPattern(
skillSummariesSection,
/name:\s*v\.string\(\)/,
"skillSummaries.name should stay string.",
);
hasPattern(
skillSummariesSection,
/purpose:\s*v\.string\(\)/,
"skillSummaries.purpose should stay string.",
);
hasPattern(
skillSummariesSection,
/summary:\s*v\.string\(\)/,
"skillSummaries.summary should stay string.",
);
});
test("audits.create accepts usedSkills validator and persists metadata payloads", () => {
const createSource = extractExportSource("create");
hasPattern(
auditsSource,
/const usedSkillsValidator\s*=\s*v\.array\(/,
"audits.ts should define a reusable usedSkillsValidator.",
);
hasPattern(
auditsSource,
/v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"audits.ts should define a reusable usedSkillsValidator.",
);
hasPattern(
createSource,
/usedSkills:\s*v\.optional\(usedSkillsValidator\)/,
"create args should include optional usedSkills field.",
);
hasPattern(
createSource,
/ctx\.db\.insert\(\s*["']audits["'][\s\S]*?args[\s\S]*\}/,
"create should persist audit payload from args (so usedSkills is stored when provided).",
);
});
test("audits.getDetail returns audit + lead context with null-safe lead lookup", () => {
const getDetailSource = extractExportSource("getDetail");
hasPattern(
getDetailSource,
/args:\s*{[\s\S]*id:\s*v\.id\(["']audits["']\)[\s\S]*}/,
"getDetail should require id argument for audits.",
);
hasPattern(
getDetailSource,
/const\s+audit\s*=\s*await\s+ctx\.db\.get\s*\(\s*args\.id\s*\)/,
"getDetail should load audit by id.",
);
hasPattern(
getDetailSource,
/if\s*\(\s*!audit\s*\)\s*{\s*return null;\s*}/,
"getDetail should return null when audit is missing.",
);
hasPattern(
getDetailSource,
/const\s+lead\s*=\s*await\s+ctx\.db\.get\s*\(\s*audit\.leadId\s*\)/,
"getDetail should load lead by leadId from the audit.",
);
hasPattern(
getDetailSource,
/return\s*{\s*audit,\s*lead\s*}/,
"getDetail should return { audit, lead }.",
);
hasPattern(
sourceFile.getFullText(),
/export const getDetail = query\(/,
"audits.ts should export a getDetail query.",
);
});

View File

@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("audits dashboard page uses a dedicated board component", async () => {
const dashboardPageSource = await source("app/dashboard/audits/page.tsx");
assert.doesNotMatch(
dashboardPageSource,
/DashboardPlaceholderPage/i,
"Dashboard audits route should not render the placeholder page.",
);
assert.match(
dashboardPageSource,
/<AuditsBoard \/>/,
"Audits board should be mounted from route page.",
);
assert.match(
dashboardPageSource,
/"@\/components\/audits\/audits-board"/,
"Audits board should be imported from components.",
);
});
test("audits board renders compact list with convex list query and core columns", async () => {
const boardSource = await source("components/audits/audits-board.tsx");
assert.match(
boardSource,
/\"use client\"/,
"AuditsBoard must be a Client Component for useQuery.",
);
assert.match(
boardSource,
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
"AuditsBoard should call api.audits.list with { limit: 100 }.",
);
assert.match(
boardSource,
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
"Audits should be sorted newest first.",
);
assert.match(boardSource, /Loading|lädt|Lade/i);
assert.match(boardSource, /Keine Audits|keine Audits/i);
assert.match(boardSource, /Slug/);
assert.match(boardSource, /Domain/);
assert.match(boardSource, /Status/);
assert.match(boardSource, /Seiten/);
assert.match(
boardSource,
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
"Each audit row should link to /dashboard/audits/{id}.",
);
});
test("audit detail component uses getDetail query and renders skills overview section", async () => {
const detailSource = await source("components/audits/audit-detail.tsx");
assert.match(
detailSource,
/\"use client\"/,
"AuditDetail must be client-side for Convex query calls.",
);
assert.match(
detailSource,
/api\.audits[\s\S]{0,80}getDetail/,
"AuditDetail should use api.audits.getDetail query.",
);
assert.match(
detailSource,
/useQuery\(\s*api\.audits\.getDetail,\s*\{/,
"AuditDetail should call useQuery with api.audits.getDetail directly.",
);
assert.doesNotMatch(
detailSource,
/const\s+auditDetailQueryRef/,
"AuditDetail should not use a cast-based query fallback variable.",
);
assert.match(
detailSource,
/const\s+audit\s*=\s*result\?\.audit;/,
"AuditDetail should destructure audit from result.audit.",
);
assert.match(
detailSource,
/const\s+lead\s*=\s*result\?\.lead;/,
"AuditDetail should destructure lead from result.lead.",
);
assert.match(
detailSource,
/leadSummary\(\s*lead\s*\)/,
"AuditDetail should pass lead into leadSummary from result.lead.",
);
assert.match(
detailSource,
/usedSkills/,
"AuditDetail should inspect usedSkills for overview rendering.",
);
assert.match(
detailSource,
/Keine Skills gespeichert/,
"AuditDetail should show fallback text when no skills are saved.",
);
assert.match(
detailSource,
/Verwendete Skills/,
"AuditDetail should render Verwendete Skills heading.",
);
assert.match(
detailSource,
/Lead|lead/,
"AuditDetail should surface lead context when available.",
);
assert.doesNotMatch(
detailSource,
/<p[^>]*>\s*\{leadSummary\(\s*lead\|[\s\S]*?\)\s*\}\s*<\/p>/,
"Lead summary should not wrap leadSummary output in a nested <p>.",
);
assert.doesNotMatch(
detailSource,
/<p[^>]*>\s*\{leadSummary\(\s*audit\.lead\)\s*\}\s*<\/p>/,
"Lead summary should not wrap leadSummary output in a nested <p>.",
);
});
test("audits detail route passes id to AuditDetail via Promise params", async () => {
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");
assert.match(
pageSource,
/params:\s*Promise<\{\s*id:\s*string\s*\}>/,
"Audit detail route should accept params as Promise in Next.js 16 style.",
);
assert.match(
pageSource,
/const \{\s*id\s*\}\s*=\s*await params/,
"Audit detail route should unwrap Promise params.",
);
assert.match(
pageSource,
/<AuditDetail\s+id=/,
"Audit detail route should pass id prop into AuditDetail.",
);
});
test("public audit page does not expose used skills", async () => {
const publicAuditSource = await source("app/audit/[slug]/page.tsx");
assert.doesNotMatch(
publicAuditSource,
/Verwendete Skills|usedSkills/i,
"Public audit page must not show used skills.",
);
});

View File

@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import test from "node:test";
import {
type AuditUsedSkill,
loadSkillsRegistry,
parseSkillsRegistry,
toAuditUsedSkill,
SKILL_CATEGORIES,
} from "../lib/skills-registry";
function assertIncludes(values: readonly string[], value: string) {
assert.ok(values.includes(value), `Expected ${value} in [${values.join(", ")}]`);
}
function withTempProjectRegistry(
source: string,
run: () => Promise<void> | void,
) {
return mkdtemp(`${tmpdir()}${sep}`).then(async (projectRoot) => {
const registryPath = join(projectRoot, "skills.md");
const originalCwd = process.cwd();
await writeFile(registryPath, source, "utf8");
process.chdir(projectRoot);
try {
await run();
} finally {
process.chdir(originalCwd);
await rm(projectRoot, { recursive: true, force: true });
}
});
}
test("parseSkillsRegistry parses valid entries, trims whitespace, and normalizes category", async () => {
const registrySource = `
## Design Audit
Purpose: Evaluate layout, visual hierarchy, and CTA clarity.
When to use: Use when a homepage is available and should be assessed for conversion quality.
When not to use: Don't run during technical outages or non-web touchpoints.
Required input: Homepage URL, top-level page sections, style language, brand context.
Expected output: Prioritized improvement list with concrete design changes.
Category: design
Version: 2026.06
Source: skills/design-audit.md
`;
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, 1);
const entry = parsed.at(0);
assert.ok(entry);
assert.equal(entry!.name, "Design Audit");
assert.equal(entry!.purpose, "Evaluate layout, visual hierarchy, and CTA clarity.");
assert.equal(entry!.category, "design");
assert.equal(entry!.version, "2026.06");
assert.equal(entry!.source, "skills/design-audit.md");
});
test("parseSkillsRegistry accepts indented field labels", () => {
const registrySource = `
## Local SEO Boost
Purpose: Evaluate visibility for local search with nearby intent.
When to use: Use for local business pages and service locations.
When not to use: Avoid for non-local marketing pages.
Required input: City, address, NAP consistency.
Expected output: Prioritized local SEO recommendations.
Category: seo
`;
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, 1);
const entry = parsed.at(0);
assert.ok(entry);
assert.equal(entry!.name, "Local SEO Boost");
assert.equal(entry!.purpose, "Evaluate visibility for local search with nearby intent.");
assert.equal(entry!.category, "seo");
});
test("parseSkillsRegistry throws for missing required fields", () => {
const registrySource = `
## UX Friction Review
Purpose: Review interaction patterns for friction points.
When to use: Use for lead capture and booking flows.
When not to use: Use only when there is a user journey.
Required input: Session flow and target action.
Category: ux
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/missing required field "Expected output"/i,
);
});
test("parseSkillsRegistry throws for unknown category", () => {
const registrySource = `
## Bad Category Example
Purpose: Example.
When to use: Example scenario.
When not to use: Never for this case.
Required input: Example data.
Expected output: Example output.
Category: analytics
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/unknown category "analytics"/i,
);
});
test("parseSkillsRegistry throws for duplicate skill names", () => {
const registrySource = `
## Local SEO Boost
Purpose: Strengthen local SERPs.
When to use: Use for local service businesses.
When not to use: Not for international-only landing pages.
Required input: Name, address, service area.
Expected output: Local SEO gaps and quick wins.
Category: seo
## Local SEO Boost
Purpose: Another local SEO pass.
When to use: Use for new regions.
When not to use: Skip for pure lead-gen pages.
Required input: Name, address, service area.
Expected output: Competitor baseline.
Category: seo
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/duplicate skill name "Local SEO Boost"/i,
);
});
test("parseSkillsRegistry accepts all configured categories", () => {
assertIncludes(SKILL_CATEGORIES, "design");
assertIncludes(SKILL_CATEGORIES, "ux");
assertIncludes(SKILL_CATEGORIES, "marketing");
assertIncludes(SKILL_CATEGORIES, "copy");
assertIncludes(SKILL_CATEGORIES, "seo");
assertIncludes(SKILL_CATEGORIES, "offer");
const registrySource = SKILL_CATEGORIES.map(
(category) => `
## ${category}-skill
Purpose: Valid for ${category}.
When to use: Use for ${category} tasks.
When not to use: Skip when ${category} is not in scope.
Required input: Category inputs.
Expected output: Category-specific recommendations.
Category: ${category}
`,
).join("\n\n");
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, SKILL_CATEGORIES.length);
for (const category of SKILL_CATEGORIES) {
const match = parsed.find((entry) => entry.name === `${category}-skill`);
assert.ok(match, `Expected parsed entry for ${category}`);
assert.equal(match.category, category);
}
});
test("loadSkillsRegistry reads skills.md from process.cwd() by default", async () => {
await withTempProjectRegistry(
`
## Offer Writing
Purpose: Build offer-focused copy for outreach.
When to use: Use before drafting proposals.
When not to use: Avoid when no offer exists.
Required input: Offer structure and pricing envelope.
Expected output: Offer draft and pricing emphasis.
Category: offer
`,
async () => {
const parsed = await loadSkillsRegistry();
const parsedEntry = parsed.find((entry) => entry.name === "Offer Writing");
assert.ok(parsedEntry);
assert.equal(parsedEntry.category, "offer");
},
);
});
test("loadSkillsRegistry accepts an explicit registry path", async () => {
const projectRoot = await mkdtemp(`${tmpdir()}${sep}`);
const registryPath = join(projectRoot, "seed-skills.md");
await writeFile(
registryPath,
`
## Design Audit
Purpose: Validate design quality for local business pages.
When to use: Use for a quick visual prioritization pass.
When not to use: Skip when no public page exists.
Required input: Homepage URL and target conversion goal.
Expected output: Ranked design actions with confidence.
Category: design
`,
"utf8",
);
try {
const parsed = await loadSkillsRegistry(registryPath);
assert.equal(parsed.at(0)?.name, "Design Audit");
} finally {
await rm(projectRoot, { recursive: true, force: true });
}
});
test("toAuditUsedSkill returns only required audit-facing fields", () => {
const skill = {
name: "Copy Clarity",
purpose: "Reduce complexity and improve readability.",
whenToUse: "When existing copy is verbose.",
whenNotToUse: "Skip if website is plain text-only.",
requiredInput: "Page sections and CTAs.",
expectedOutput: "A concise writing pass.",
category: "copy",
version: "1.0",
source: "skills/copy-clarity.md",
} satisfies {
name: string;
purpose: string;
whenToUse: string;
whenNotToUse: string;
requiredInput: string;
expectedOutput: string;
category: "copy";
version: string;
source: string;
};
const auditUsed = toAuditUsedSkill(skill);
const expected: AuditUsedSkill = {
name: "Copy Clarity",
category: "copy",
version: "1.0",
source: "skills/copy-clarity.md",
};
assert.deepEqual(auditUsed, expected);
});