Skip to content
Draft
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
14 changes: 13 additions & 1 deletion catalogue/scripts/SKILL.template.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: app-shell-patterns
description: UI pattern catalog for building pages with @tailor-platform/app-shell components
description: "Best-practice UI patterns and correct component usage for building pages in apps that use @tailor-platform/app-shell. Use when: building or editing any screen, page, list, table, detail view, form, modal, dialog, wizard, or bulk/confirm/toast interaction in an app with @tailor-platform/app-shell installed — or when choosing the right AppShell component, layout, or design token for a UI."
---

# App-Shell Patterns
Expand Down Expand Up @@ -34,3 +34,15 @@ These are the foundational rules that underpin all patterns. All patterns build
- NEVER mix patterns in a single page component
- ALWAYS use AppShell components — do NOT use raw HTML or third-party UI libraries
- If no entry matches, compose directly from fundamental references

### Cross-cutting UX rules (apply to every screen)

Full rationale in [`design-system.md`](references/fundamental/design-system.md) → Composition & emphasis rules.

- **One primary action per view:** at most one primary/filled `Button`; everything else is `outline`/`secondary`/`ghost`.
- **Status badges by semantic color:** a **filled** semantic variant for the record's primary/lifecycle status, **`outline-*`** for secondary statuses, **`subtle-*`** for tags; reserve brand `default` for non-status emphasis.
- **No duplicate actions:** an action lives in exactly one place — never repeat the same action in both `Layout.Header` and `ActionPanel`.
- **Action placement:** primary CTA + status in `Layout.Header`; workflow actions in `ActionPanel`; back/navigation in the breadcrumb — never in `ActionPanel`.
- **Metric tiles always go in a `Grid`** (`columns={{ initial: 1, md: 2, xl: 4 }}`) — never one-per-row.
- **Forms default to `form/modal`** — only build a routed full-page form when the design explicitly calls for one.
- **Handle every state:** loading (skeleton), empty (labelled empty state), and error (inline + retry) — never ship only the happy path.
92 changes: 58 additions & 34 deletions catalogue/scripts/generate-skill.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - skills/app-shell-patterns/references/<category>/<slug>.md (per-entry docs)
*/

import { readdir, readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import matter from "gray-matter";
Expand All @@ -24,7 +24,7 @@ const referencesDir = join(skillsDir, "references");
* and add a corresponding {{<templateKey>}} placeholder to SKILL.template.md.
*
* - entryFile: marker filename to search recursively (e.g. "PATTERN.md").
* If null, all .md files in the category directory are copied as-is.
* If null, all top-level .md files in the category directory are processed directly.
*/
const CATEGORIES = [
{
Expand All @@ -43,7 +43,7 @@ const CATEGORIES = [

/**
* Resolve <!-- source: file.tsx --> markers in markdown body.
* Reads the referenced file relative to the PATTERN.md directory
* Reads the referenced file relative to the current markdown file directory
* and replaces the marker with a fenced code block.
*/
async function resolveSourceMarkers(body, patternDir) {
Expand Down Expand Up @@ -87,7 +87,8 @@ async function findEntryFiles(dir, filename) {
return results;
}

for (const entry of entries) {
const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
for (const entry of sortedEntries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...(await findEntryFiles(fullPath, filename)));
Expand Down Expand Up @@ -117,7 +118,8 @@ function slugToFilename(slug) {
*
* When sourceFile is set, entries are discovered by recursive search
* for that filename, parsed for frontmatter, and source markers are resolved.
* When sourceFile is null, all .md files in the category dir are copied as-is.
* When sourceFile is null, all .md files in the category dir are read directly and
* source markers are resolved in place.
*/
async function processCategory(category) {
const categoryDir = join(catalogueRoot, "src", category.name);
Expand All @@ -135,7 +137,9 @@ async function processCopiedCategory(category, categoryDir, outputDir) {
for (const filePath of files) {
const filename = filePath.split("/").pop();
const outputPath = join(outputDir, filename);
await copyFile(filePath, outputPath);
const content = await readFile(filePath, "utf-8");
const resolvedContent = await resolveSourceMarkers(content, dirname(filePath));
await writeFile(outputPath, resolvedContent);
console.log(` Generated references/${category.outputDir}/${filename}`);
}
return { category, files };
Expand Down Expand Up @@ -192,31 +196,49 @@ async function main() {
/**
* Generate an index table for entry-based categories (with frontmatter).
*/
function formatMarkdownTable(rows) {
const widths = rows[0].map((_, columnIndex) =>
Math.max(...rows.map((row) => String(row[columnIndex]).length)),
);

const renderRow = (row) =>
`| ${row.map((cell, columnIndex) => String(cell).padEnd(widths[columnIndex])).join(" | ")} |`;

return [
renderRow(rows[0]),
`| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`,
...rows.slice(1).map(renderRow),
].join("\n");
}

function generateEntryTable(entries, outputDir) {
if (entries.length === 0) return "";

// Group entries by subcategory
const grouped = {};
for (const { meta } of entries) {
const grouped = new Map();
for (const { meta } of [...entries].sort((a, b) => a.meta.slug.localeCompare(b.meta.slug))) {
const key = meta.subcategory || meta.category;
if (!grouped[key]) grouped[key] = [];
grouped[key].push(meta);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(meta);
}

let table = "";
for (const [group, items] of Object.entries(grouped)) {
table += `### ${group}\n\n`;
table += `| Slug | Name | Description |\n`;
table += `| ---- | ---- | ----------- |\n`;
for (const item of items) {
const filename = slugToFilename(item.slug) + ".md";
const displaySlug = item.slug.replace(/^[^/]+\//, "");
table += `| [\`${displaySlug}\`](references/${outputDir}/${filename}) | ${item.name} | ${item.description} |\n`;
}
table += `\n`;
}

return table.trim();
return [...grouped.entries()]
.map(([group, items]) => {
const rows = [
["Slug", "Name", "Description"],
...items.map((item) => {
const filename = slugToFilename(item.slug) + ".md";
const displaySlug = item.slug.replace(/^[^/]+\//, "");
return [
`[\`${displaySlug}\`](references/${outputDir}/${filename})`,
item.name,
item.description,
];
}),
];

return `### ${group}\n\n${formatMarkdownTable(rows)}`;
})
.join("\n\n");
}

/**
Expand All @@ -225,14 +247,16 @@ function generateEntryTable(entries, outputDir) {
function generateCopiedTable(files, outputDir) {
if (files.length === 0) return "";

let table = "| File | Description |\n";
table += "| ---- | ----------- |\n";
for (const filePath of files) {
const filename = filePath.split("/").pop();
const name = filename.replace(".md", "");
table += `| [${filename}](references/${outputDir}/${filename}) | ${name} reference |\n`;
}
return table.trim();
const rows = [
["File", "Description"],
...[...files].sort().map((filePath) => {
const filename = filePath.split("/").pop();
const name = filename.replace(".md", "");
return [`[${filename}](references/${outputDir}/${filename})`, `${name} reference`];
}),
];

return formatMarkdownTable(rows);
}

async function findMarkdownFiles(dir) {
Expand All @@ -243,7 +267,7 @@ async function findMarkdownFiles(dir) {
} catch {
return results;
}
for (const entry of entries) {
for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory() && entry.name.endsWith(".md")) {
results.push(join(dir, entry.name));
}
Expand Down
Loading
Loading