chore: initial monorepo scaffold + WDS Phase 1+2 artifacts

- Nx 22.7 monorepo (pnpm 11.1, TypeScript 5.9, Node 24)
- apps/api: NestJS 11 (CJS conforme CODING-RULES.md PGD-DB-004)
- apps/web: React 19 + Vite 8 (ESM)
- libs/shared/api-interface: Zod contract base
- Docker Compose dev: Postgres 18, Valkey 8, MinIO, Mailpit
- WDS artifacts:
  - design-artifacts/A-Product-Brief/ (5 docs canônicos + 16 dialogs)
  - design-artifacts/B-Trigger-Map/ (hub + 4 personas + feature impact)
- Stack canon: STACK.md v2.2 + CODING-RULES.md v2.0 + brand.md
- AGENTS.md + README.md como entrada para devs/agentes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:34:20 +00:00
commit 17c08e6392
3631 changed files with 855518 additions and 0 deletions

155
_bmad/wds/scripts/README.md Normal file
View File

@@ -0,0 +1,155 @@
# WDS Scaffold Scripts
Node.js scripts that enforce deterministic output from AI agents. Agents provide content via CLI flags; scripts produce structure.
All scripts use only Node.js stdlib (no external dependencies). Run from the project root.
---
## Scripts
### `wds-init-scenario.js` — Initialize a scenario
Creates the scenario folder and a README index file.
```bash
node src/scripts/wds-init-scenario.js \
--scenario "01 New User Onboarding" \
--description "New user first visit to account creation"
```
Output: `C-UX-Scenarios/01-new-user-onboarding/README.md`
---
### `wds-init-page.js` — Initialize a page spec
Creates a new page spec file with all required sections pre-filled with placeholders.
```bash
node src/scripts/wds-init-page.js \
--page "01 Start" \
--scenario "01 New User Onboarding" \
--platform "Mobile web" \
--visibility "Public"
```
Output:
- `C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md`
- `C-UX-Scenarios/01-new-user-onboarding/01-start/sketches/`
After creating all pages in a scenario, run `wds-nav.js` to wire up navigation links.
---
### `wds-nav.js` — Update navigation links
Scans pages in a scenario (sorted by name) and writes correct prev/next navigation rows into each page spec.
```bash
# One scenario
node src/scripts/wds-nav.js --scenario "01 New User Onboarding"
# All scenarios
node src/scripts/wds-nav.js --all
```
Run this after adding or removing pages, or after reordering page numbers.
---
### `wds-add-object.js` — Append an object spec
Appends a structured object spec block to a page spec under a named section.
```bash
node src/scripts/wds-add-object.js \
--page "C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md" \
--section "Hero" \
--object "Primary Headline" \
--component "H1 heading" \
--se "Välkommen" \
--en "Welcome" \
--behavior "Static display"
```
Object ID is auto-derived: `start-hero-primary-headline`
The section heading (`### Section: Hero`) is created if it doesn't already exist.
---
### `wds-add-spacing.js` — Append a spacing object
Appends a spacing notation entry to the `## Spacing` section of a page spec.
```bash
node src/scripts/wds-add-spacing.js \
--page "C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md" \
--direction v \
--type space \
--size xl \
--reason "major section boundary between hero and features"
```
Valid directions: `v` (vertical), `h` (horizontal)
Valid types: `space`, `separator`, `line`
Valid sizes: `zero`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `flex`
Spacing ID is auto-derived: `start-v-space-xl`
---
### `wds-validate.js` — Validate page specs
Checks page spec files for structural correctness.
```bash
# Single page
node src/scripts/wds-validate.js \
--page "C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md"
# All pages in a scenario
node src/scripts/wds-validate.js --scenario "01 New User Onboarding"
# All scenarios
node src/scripts/wds-validate.js --all
```
Validates:
- Required sections present
- Object IDs are kebab-case with correct page prefix
- No duplicate Object IDs
- Navigation rows (3 expected)
- Metadata table has all required properties
- Sketches folder exists
- SE + EN content present for each object
---
## How agents use these scripts
1. Agent calls `wds-init-scenario.js` with scenario name and description
2. Agent calls `wds-init-page.js` for each page in the scenario
3. Agent calls `wds-nav.js` to wire navigation after all pages exist
4. Agent calls `wds-add-object.js` for each UI object, providing Swedish and English content
5. Agent calls `wds-add-spacing.js` for each spacing decision
6. Agent calls `wds-validate.js` to confirm the spec is structurally correct before handoff
The agent never writes raw markdown — it only supplies content as flag values. The scripts own all structural decisions.
---
## File location convention
```
C-UX-Scenarios/
{scenario-slug}/
README.md
{page-slug}/
{page-slug}.md
sketches/
{page-slug}-concept.jpg
```
Example: `C-UX-Scenarios/01-new-user-onboarding/02-signup/02-signup.md`

View File

@@ -0,0 +1,202 @@
// wds-add-object.js — WDS scaffold: append an object spec block to a page spec file
// Usage: node src/scripts/wds-add-object.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md" \
// --section "Hero" --object "Primary Headline" --component "H1 heading" \
// --se "Välkommen" --en "Welcome"
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
args[key] = value;
if (value !== true) i++;
}
}
return args;
}
function toSlug(str) {
return str.toLowerCase().replaceAll(/\s+/g, '-');
}
function printUsage() {
process.stdout.write(
[
'Usage: node src/scripts/wds-add-object.js --page <path> --section <name> --object <name> [options]',
'',
'Required:',
' --page Path to the page spec .md file',
' --section Section name (e.g. "Hero")',
' --object Object name (e.g. "Primary Headline")',
'',
'Optional:',
' --component Component name (default: "—")',
' --translation Translation key (auto-derived if omitted)',
' --se Swedish text content',
' --en English text content',
' --behavior Behavior description (e.g. "onClick: submit form")',
' --component-path Path to component file (default: "—")',
'',
].join('\n'),
);
}
// Derive page slug from file path: "01-start/01-start.md" -> "01-start"
function pageSlugFromPath(filePath) {
const base = path.basename(filePath, '.md');
return base;
}
// Derive Object ID: pageSlug + sectionSlug + objectSlug
// e.g. page=01-start, section=Hero, object=Primary Headline -> 01-start-hero-primary-headline
function deriveObjectId(pageSlug, sectionName, objectName) {
// Strip leading page number from pageSlug for ID prefix
// "01-start" -> "start", "02-signup" -> "signup"
const slugParts = pageSlug.split('-');
const pagePrefix = slugParts.length > 1 ? slugParts.slice(1).join('-') : pageSlug;
const sectionSlug = toSlug(sectionName);
const objectSlug = toSlug(objectName);
return `${pagePrefix}-${sectionSlug}-${objectSlug}`;
}
function buildObjectBlock({ objectName, objectId, component, componentPath, translationKey, se, en, behavior }) {
const compDisplay = componentPath && componentPath !== '—' ? `[${component}](${componentPath})` : component || '—';
const lines = [
`#### ${objectName}`,
'',
`**OBJECT ID:** \`${objectId}\``,
'',
'| Property | Value |',
'|----------|-------|',
`| Component | ${compDisplay} |`,
`| Translation Key | \`${translationKey}\` |`,
`| SE | "${se || '—'}" |`,
`| EN | "${en || '—'}" |`,
`| Behavior | ${behavior || '—'} |`,
'',
];
return lines.join('\n');
}
// Insert content after a section heading. Creates the section heading if it doesn't exist.
function insertUnderSection(content, sectionHeading, objectBlock) {
const lines = content.split('\n');
const headingLine = `### Section: ${sectionHeading}`;
const headingIdx = lines.findIndex((l) => l.trim() === headingLine);
if (headingIdx === -1) {
// Section doesn't exist — append it before the next ## heading after ## Page Sections
const pageSectionsIdx = lines.findIndex((l) => l.trim() === '## Page Sections');
if (pageSectionsIdx === -1) {
// Just append at end before last nav row
return content + `\n${headingLine}\n\n${objectBlock}\n`;
}
// Find end of ## Page Sections block
let insertIdx = pageSectionsIdx + 1;
while (insertIdx < lines.length) {
const t = lines[insertIdx].trim();
if (t.startsWith('## ') || t === '---') break;
insertIdx++;
}
const before = lines.slice(0, insertIdx);
const after = lines.slice(insertIdx);
return [...before, '', headingLine, '', objectBlock, ...after].join('\n');
} else {
// Find the end of this section (next ### or ## or end of file)
let insertIdx = headingIdx + 1;
// Skip blank lines after heading
while (insertIdx < lines.length && lines[insertIdx].trim() === '') insertIdx++;
// Skip comment lines
while (insertIdx < lines.length && lines[insertIdx].trim().startsWith('<!--')) insertIdx++;
// Find end of section
let endIdx = insertIdx;
while (endIdx < lines.length) {
const t = lines[endIdx].trim();
if (t.startsWith('### ') || t.startsWith('## ') || t === '---') break;
endIdx++;
}
// Insert object block before end of section
const before = lines.slice(0, endIdx);
const after = lines.slice(endIdx);
return [...before, '', objectBlock, ...after].join('\n');
}
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.page || !args.section || !args.object) {
process.stderr.write('Error: --page, --section, and --object are required.\n\n');
printUsage();
process.exit(1);
}
const filePath = path.resolve(args.page);
if (!fs.existsSync(filePath)) {
process.stderr.write(`Error: File not found: ${filePath}\n`);
process.exit(1);
}
const pageSlug = pageSlugFromPath(filePath);
const objectId = deriveObjectId(pageSlug, args.section, args.object);
// Auto-derive translation key from objectId
const translationKey = args.translation || objectId.replaceAll('-', '.');
const objectBlock = buildObjectBlock({
objectName: args.object,
objectId,
component: args.component || '—',
componentPath: args['component-path'] || '—',
translationKey,
se: args.se || '',
en: args.en || '',
behavior: args.behavior || '—',
});
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
process.stderr.write(`Error reading file: ${error.message}\n`);
process.exit(1);
}
// Check for duplicate object ID
if (content.includes(`\`${objectId}\``)) {
process.stderr.write(`Error: Object ID already exists in file: ${objectId}\n`);
process.exit(1);
}
const updated = insertUnderSection(content, args.section, objectBlock);
try {
fs.writeFileSync(filePath, updated, 'utf8');
} catch (error) {
process.stderr.write(`Error writing file: ${error.message}\n`);
process.exit(1);
}
process.stdout.write(`✓ Added object ${objectId}\n`);
process.stdout.write(` File: ${filePath}\n`);
}
main();

View File

@@ -0,0 +1,158 @@
// wds-add-spacing.js — WDS scaffold: append a spacing object to a page spec file
// Usage: node src/scripts/wds-add-spacing.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md" \
// --direction v --type space --size xl --reason "major section boundary between hero and features"
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
args[key] = value;
if (value !== true) i++;
}
}
return args;
}
function printUsage() {
process.stdout.write(
[
'Usage: node src/scripts/wds-add-spacing.js --page <path> --direction <v|h> --type <type> --size <size> [options]',
'',
'Required:',
' --page Path to the page spec .md file',
' --direction v (vertical) or h (horizontal)',
' --type space | separator | line',
' --size zero | sm | md | lg | xl | 2xl | 3xl | flex',
'',
'Optional:',
' --reason Why this spacing exists',
'',
'Valid directions: v, h',
'Valid types: space, separator, line',
'Valid sizes: zero, sm, md, lg, xl, 2xl, 3xl, flex',
'',
].join('\n'),
);
}
const VALID_DIRECTIONS = ['v', 'h'];
const VALID_TYPES = ['space', 'separator', 'line'];
const VALID_SIZES = ['zero', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', 'flex'];
// Derive page prefix from slug: "01-start" -> "start"
function pagePrefix(slug) {
const parts = slug.split('-');
return parts.length > 1 ? parts.slice(1).join('-') : slug;
}
function pageSlugFromPath(filePath) {
return path.basename(filePath, '.md');
}
function buildSpacingBlock(spacingId, reason) {
const icon = '↕';
const reasonText = reason ? `${reason}` : '';
return `#### ${icon} \`${spacingId}\`${reasonText}\n`;
}
function appendToSpacingSection(content, spacingBlock) {
const lines = content.split('\n');
const spacingIdx = lines.findIndex((l) => l.trim() === '## Spacing');
if (spacingIdx === -1) {
// No spacing section — append before first ## after metadata
return content + `\n## Spacing\n\n${spacingBlock}\n`;
}
// Find end of spacing section (next ## or ---)
let endIdx = spacingIdx + 1;
while (endIdx < lines.length) {
const t = lines[endIdx].trim();
if ((t.startsWith('## ') && t !== '## Spacing') || t === '---') break;
endIdx++;
}
// Insert just before the end marker
const before = lines.slice(0, endIdx);
const after = lines.slice(endIdx);
return [...before, spacingBlock, ...after].join('\n');
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.page || !args.direction || !args.type || args.size === 0) {
process.stderr.write('Error: --page, --direction, --type, and --size are required.\n\n');
printUsage();
process.exit(1);
}
if (!VALID_DIRECTIONS.includes(args.direction)) {
process.stderr.write(`Error: Invalid direction "${args.direction}". Must be: ${VALID_DIRECTIONS.join(', ')}\n`);
process.exit(1);
}
if (!VALID_TYPES.includes(args.type)) {
process.stderr.write(`Error: Invalid type "${args.type}". Must be: ${VALID_TYPES.join(', ')}\n`);
process.exit(1);
}
if (!VALID_SIZES.includes(args.size)) {
process.stderr.write(`Error: Invalid size "${args.size}". Must be: ${VALID_SIZES.join(', ')}\n`);
process.exit(1);
}
const filePath = path.resolve(args.page);
if (!fs.existsSync(filePath)) {
process.stderr.write(`Error: File not found: ${filePath}\n`);
process.exit(1);
}
const slug = pageSlugFromPath(filePath);
const prefix = pagePrefix(slug);
const spacingId = `${prefix}-${args.direction}-${args.type}-${args.size}`;
const reason = args.reason || '';
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
process.stderr.write(`Error reading file: ${error.message}\n`);
process.exit(1);
}
// Check for duplicate spacing ID
if (content.includes(`\`${spacingId}\``)) {
process.stderr.write(`Error: Spacing ID already exists in file: ${spacingId}\n`);
process.stderr.write('Use a different combination of direction/type/size or manually edit the file.\n');
process.exit(1);
}
const spacingBlock = buildSpacingBlock(spacingId, reason);
const updated = appendToSpacingSection(content, spacingBlock);
try {
fs.writeFileSync(filePath, updated, 'utf8');
} catch (error) {
process.stderr.write(`Error writing file: ${error.message}\n`);
process.exit(1);
}
process.stdout.write(`✓ Added spacing object ${spacingId}\n`);
process.stdout.write(` File: ${filePath}\n`);
}
main();

View File

@@ -0,0 +1,229 @@
// wds-init-page.js — WDS scaffold: initialize new page spec
// Usage: node src/scripts/wds-init-page.js --page "01 Start" --scenario "01 Onboarding"
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
args[key] = value;
if (value !== true) i++;
}
}
return args;
}
function toSlug(str) {
return str.toLowerCase().replaceAll(/\s+/g, '-');
}
function printUsage() {
process.stdout.write(
[
'Usage: node src/scripts/wds-init-page.js --page "01 Start" --scenario "01 Onboarding" [options]',
'',
'Required:',
' --page Page name with number, e.g. "01 Start"',
' --scenario Scenario name, e.g. "01 New User Onboarding"',
'',
'Optional:',
' --platform Platform value (default: "Mobile web")',
' --visibility Visibility value (default: "Public")',
' --output Base path to write to (default: current directory)',
'',
].join('\n'),
);
}
function buildTemplate({ pageSlug, pageName, scenarioSlug, scenarioName, platform, visibility }) {
const sketchFile = `sketches/${pageSlug}-concept.jpg`;
const navEmpty = `← [Previous]() | [Next →]()`;
const metaTable = [
'| Property | Value |',
'|----------|-------|',
`| Scenario | ${scenarioName} |`,
`| Page Number | ${pageName.split(' ')[0]} |`,
`| Platform | ${platform} |`,
`| Page Type | — |`,
`| Viewport | — |`,
`| Interaction | — |`,
`| Visibility | ${visibility} |`,
].join('\n');
const overviewSection = [
'| Property | Value |',
'|----------|-------|',
'| Purpose | — |',
'| User Situation | — |',
'| Success Criteria | — |',
'| Entry Points | — |',
'| Exit Points | — |',
].join('\n');
const spacingTable = [
'| Token | Direction | Type | Size | Reason |',
'|-------|-----------|------|------|--------|',
`| \`${pageSlug}-v-space-md\` | Vertical | space | md | — |`,
].join('\n');
const typographyTable = [
'| Element | Semantic | Size | Weight | Typeface |',
'|---------|----------|------|--------|----------|',
'| Page title | H1 | — | — | — |',
].join('\n');
const statesTable = [
'| State | When | Appearance | Actions |',
'|-------|------|------------|---------|',
'| Default | On load | — | — |',
].join('\n');
return [
navEmpty,
'',
`![${pageName}](${sketchFile})`,
'',
navEmpty,
'',
'---',
'',
`# ${pageName}`,
'',
'## Page Metadata',
'',
metaTable,
'',
'---',
'',
'## Overview',
'',
overviewSection,
'',
'---',
'',
'## Reference Materials',
'',
'- —',
'',
'---',
'',
'## Layout Structure',
'',
'```',
'+---------------------------+',
'| Header |',
'+---------------------------+',
'| |',
'| Content |',
'| |',
'+---------------------------+',
'| Footer |',
'+---------------------------+',
'```',
'',
'---',
'',
'## Spacing',
'',
spacingTable,
'',
'---',
'',
'## Typography',
'',
typographyTable,
'',
'---',
'',
'## Page Sections',
'',
`### Section: Main`,
'',
`<!-- Objects go here. Use wds-add-object.js to append. -->`,
'',
'---',
'',
'## Page States',
'',
statesTable,
'',
'---',
'',
'## Open Questions',
'',
'- —',
'',
'---',
'',
'## Checklist',
'',
'- [ ] Navigation links updated',
'- [ ] Sketch added',
'- [ ] All objects have SE + EN content',
'- [ ] All Object IDs use correct prefix',
'- [ ] Page States defined',
'- [ ] Spacing tokens defined',
'',
navEmpty,
'',
].join('\n');
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.page || !args.scenario) {
process.stderr.write('Error: --page and --scenario are required.\n\n');
printUsage();
process.exit(1);
}
const pageName = args.page;
const scenarioName = args.scenario;
const platform = args.platform || 'Mobile web';
const visibility = args.visibility || 'Public';
const outputBase = args.output || process.cwd();
const pageSlug = toSlug(pageName);
const scenarioSlug = toSlug(scenarioName);
const pageDir = path.join(outputBase, 'C-UX-Scenarios', scenarioSlug, pageSlug);
const sketchesDir = path.join(pageDir, 'sketches');
const pageFile = path.join(pageDir, `${pageSlug}.md`);
try {
fs.mkdirSync(pageDir, { recursive: true });
fs.mkdirSync(sketchesDir, { recursive: true });
} catch (error) {
process.stderr.write(`Error creating directories: ${error.message}\n`);
process.exit(1);
}
const content = buildTemplate({ pageSlug, pageName, scenarioSlug, scenarioName, platform, visibility });
try {
fs.writeFileSync(pageFile, content, 'utf8');
} catch (error) {
process.stderr.write(`Error writing file: ${error.message}\n`);
process.exit(1);
}
process.stdout.write(`✓ Created ${pageSlug}.md\n`);
process.stdout.write(` Path: ${pageFile}\n`);
process.stdout.write(` Run wds-nav.js to update navigation links.\n`);
}
main();

View File

@@ -0,0 +1,120 @@
// wds-init-scenario.js — WDS scaffold: initialize new scenario folder
// Usage: node src/scripts/wds-init-scenario.js --scenario "01 Onboarding" --description "New user first visit to account creation"
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
args[key] = value;
if (value !== true) i++;
}
}
return args;
}
function toSlug(str) {
return str.toLowerCase().replaceAll(/\s+/g, '-');
}
function printUsage() {
process.stdout.write(
[
'Usage: node src/scripts/wds-init-scenario.js --scenario "01 Onboarding" [options]',
'',
'Required:',
' --scenario Scenario name with number, e.g. "01 New User Onboarding"',
'',
'Optional:',
' --description Short description of the scenario',
' --output Base path to write to (default: current directory)',
'',
].join('\n'),
);
}
function buildReadme({ scenarioName, scenarioSlug, description }) {
const scenarioNumber = scenarioName.split(' ')[0];
const desc = description || '—';
return [
`# Scenario ${scenarioNumber}: ${scenarioName}`,
'',
`**Description:** ${desc}`,
'',
'**Trigger Map:** [Link to trigger map]()',
'',
'---',
'',
'## Pages',
'',
'| # | Page | File | Status |',
'|---|------|------|--------|',
'| — | (no pages yet) | — | — |',
'',
'---',
'',
'## Notes',
'',
'- Add pages with: `node src/scripts/wds-init-page.js --scenario "' + scenarioName + '" --page "01 Start"`',
'- Update navigation after adding pages: `node src/scripts/wds-nav.js --scenario "' + scenarioName + '"`',
'- Validate pages: `node src/scripts/wds-validate.js --scenario "' + scenarioName + '"`',
'',
].join('\n');
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.scenario) {
process.stderr.write('Error: --scenario is required.\n\n');
printUsage();
process.exit(1);
}
const scenarioName = args.scenario;
const description = args.description || '';
const outputBase = args.output || process.cwd();
const scenarioSlug = toSlug(scenarioName);
const scenarioDir = path.join(outputBase, 'C-UX-Scenarios', scenarioSlug);
const readmeFile = path.join(scenarioDir, 'README.md');
if (fs.existsSync(scenarioDir)) {
process.stderr.write(`Error: Scenario folder already exists: ${scenarioDir}\n`);
process.exit(1);
}
try {
fs.mkdirSync(scenarioDir, { recursive: true });
} catch (error) {
process.stderr.write(`Error creating directory: ${error.message}\n`);
process.exit(1);
}
const content = buildReadme({ scenarioName, scenarioSlug, description });
try {
fs.writeFileSync(readmeFile, content, 'utf8');
} catch (error) {
process.stderr.write(`Error writing README: ${error.message}\n`);
process.exit(1);
}
process.stdout.write(`✓ Created scenario ${scenarioSlug}/\n`);
process.stdout.write(` Path: ${scenarioDir}\n`);
process.stdout.write(` README: ${readmeFile}\n`);
}
main();

View File

@@ -0,0 +1,201 @@
// wds-nav.js — WDS scaffold: generate/update navigation links across all pages in a scenario
// Usage: node src/scripts/wds-nav.js --scenario "01 Onboarding"
// node src/scripts/wds-nav.js --all
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
args[key] = value;
if (value !== true) i++;
}
}
return args;
}
function toSlug(str) {
return str.toLowerCase().replaceAll(/\s+/g, '-');
}
function printUsage() {
process.stdout.write(
[
'Usage: node src/scripts/wds-nav.js --scenario "01 Onboarding"',
' node src/scripts/wds-nav.js --all',
'',
'Options:',
' --scenario Scenario name or slug to update',
' --all Update all scenarios',
' --output Base path (default: current directory)',
'',
].join('\n'),
);
}
// Build a human-readable page name from the slug for nav labels
// e.g. "01-start" -> "01 Start"
function slugToLabel(slug) {
return slug
.split('-')
.map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
.join(' ');
}
function buildNavRow(prev, next) {
const leftPart = prev ? `← [${slugToLabel(prev.slug)}](../${prev.slug}/${prev.slug}.md)` : '←';
const rightPart = next ? `[${slugToLabel(next.slug)} →](../${next.slug}/${next.slug}.md)` : '→';
return `${leftPart} | ${rightPart}`;
}
// Replace all 3 occurrences of navigation rows in a page file.
// Nav rows are lines matching the pattern: starts with '←' or contains '| [' and ends with '→'
// We identify them by a simple pattern: line that starts with "←" (after trim).
function updateNavInContent(content, navRow) {
const lines = content.split('\n');
const result = [];
let navCount = 0;
for (const line of lines) {
const trimmed = line.trim();
// Match navigation rows: lines that start with "←" (our nav format)
if (trimmed.startsWith('←')) {
result.push(navRow);
navCount++;
} else {
result.push(line);
}
}
return { content: result.join('\n'), navCount };
}
function getPageFolders(scenarioDir) {
let entries;
try {
entries = fs.readdirSync(scenarioDir, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => {
// Must have a matching .md file inside
const mdFile = path.join(scenarioDir, name, `${name}.md`);
return fs.existsSync(mdFile);
})
.sort(); // Sort alphabetically — page numbers ensure correct order
}
function processScenario(scenariosBase, scenarioSlug) {
const scenarioDir = path.join(scenariosBase, scenarioSlug);
if (!fs.existsSync(scenarioDir)) {
process.stderr.write(`Error: Scenario not found: ${scenarioDir}\n`);
return false;
}
const pageSlugs = getPageFolders(scenarioDir);
if (pageSlugs.length === 0) {
process.stdout.write(` ${scenarioSlug}: no pages found, skipping.\n`);
return true;
}
let updated = 0;
for (let i = 0; i < pageSlugs.length; i++) {
const slug = pageSlugs[i];
const prev = i > 0 ? { slug: pageSlugs[i - 1] } : null;
const next = i < pageSlugs.length - 1 ? { slug: pageSlugs[i + 1] } : null;
const navRow = buildNavRow(prev, next);
const mdFile = path.join(scenarioDir, slug, `${slug}.md`);
let content;
try {
content = fs.readFileSync(mdFile, 'utf8');
} catch (error) {
process.stderr.write(` Error reading ${mdFile}: ${error.message}\n`);
continue;
}
const { content: newContent, navCount } = updateNavInContent(content, navRow);
if (navCount === 0) {
process.stderr.write(` Warning: No navigation rows found in ${slug}.md\n`);
}
try {
fs.writeFileSync(mdFile, newContent, 'utf8');
updated++;
} catch (error) {
process.stderr.write(` Error writing ${mdFile}: ${error.message}\n`);
}
}
process.stdout.write(`✓ Updated navigation for ${updated} pages in ${scenarioSlug}\n`);
return true;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.scenario && !args.all) {
process.stderr.write('Error: --scenario or --all is required.\n\n');
printUsage();
process.exit(1);
}
const outputBase = args.output || process.cwd();
const scenariosBase = path.join(outputBase, 'C-UX-Scenarios');
if (!fs.existsSync(scenariosBase)) {
process.stderr.write(`Error: C-UX-Scenarios directory not found at: ${scenariosBase}\n`);
process.exit(1);
}
if (args.all) {
let entries;
try {
entries = fs.readdirSync(scenariosBase, { withFileTypes: true });
} catch (error) {
process.stderr.write(`Error reading C-UX-Scenarios: ${error.message}\n`);
process.exit(1);
}
const scenarios = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
if (scenarios.length === 0) {
process.stdout.write('No scenarios found.\n');
process.exit(0);
}
let allOk = true;
for (const slug of scenarios) {
if (!processScenario(scenariosBase, slug)) allOk = false;
}
process.exit(allOk ? 0 : 1);
} else {
const scenarioSlug = toSlug(args.scenario);
const ok = processScenario(scenariosBase, scenarioSlug);
process.exit(ok ? 0 : 1);
}
}
main();

View File

@@ -0,0 +1,301 @@
// wds-validate.js — WDS scaffold: validate page spec files for correctness
// Usage: node src/scripts/wds-validate.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md"
// node src/scripts/wds-validate.js --scenario "01 Onboarding"
// node src/scripts/wds-validate.js --all
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
args[key] = value;
if (value !== true) i++;
}
}
return args;
}
function toSlug(str) {
return str.toLowerCase().replaceAll(/\s+/g, '-');
}
function printUsage() {
process.stdout.write(
[
'Usage: node src/scripts/wds-validate.js --page <path>',
' node src/scripts/wds-validate.js --scenario "01 Onboarding"',
' node src/scripts/wds-validate.js --all',
'',
'Options:',
' --page Path to a single page spec .md file',
' --scenario Scenario name or slug to validate all pages',
' --all Validate all scenarios',
' --output Base path (default: current directory)',
'',
].join('\n'),
);
}
const REQUIRED_SECTIONS = [
'## Overview',
'## Page Metadata',
'## Layout Structure',
'## Spacing',
'## Typography',
'## Page Sections',
'## Page States',
'## Checklist',
];
const REQUIRED_METADATA_PROPS = ['Scenario', 'Page Number', 'Platform', 'Page Type', 'Viewport', 'Interaction', 'Visibility'];
const KEBAB_CASE_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
// Extract all OBJECT IDs from content: lines matching **OBJECT ID:** `...`
function extractObjectIds(content) {
const ids = [];
const re = /\*\*OBJECT ID:\*\*\s+`([^`]+)`/g;
let m;
while ((m = re.exec(content)) !== null) {
ids.push(m[1]);
}
return ids;
}
// Extract all spacing IDs from content: lines matching #### ↕ `...`
function extractSpacingIds(content) {
const ids = [];
const re = /####\s+[↕↔]\s+`([^`]+)`/g;
let m;
while ((m = re.exec(content)) !== null) {
ids.push(m[1]);
}
return ids;
}
// Count nav rows: lines starting with '←'
function countNavRows(content) {
const lines = content.split('\n');
return lines.filter((l) => l.trim().startsWith('←')).length;
}
// Check Swedish + English content: for each object block, look for SE and EN rows
function checkObjectContent(content) {
const missing = [];
// Split into object blocks by #### heading
const blocks = content.split(/(?=#### )/);
for (const block of blocks) {
const idMatch = block.match(/\*\*OBJECT ID:\*\*\s+`([^`]+)`/);
if (!idMatch) continue;
const objectId = idMatch[1];
// Check for SE row
if (block.includes('| SE |')) {
const seMatch = block.match(/\| SE \| "([^"]*)"/);
if (!seMatch || seMatch[1].trim() === '' || seMatch[1].trim() === '—') {
missing.push(`Object "${objectId}" has empty SE content`);
}
} else {
missing.push(`Object "${objectId}" missing SE content field`);
}
// Check for EN row
if (block.includes('| EN |')) {
const enMatch = block.match(/\| EN \| "([^"]*)"/);
if (!enMatch || enMatch[1].trim() === '' || enMatch[1].trim() === '—') {
missing.push(`Object "${objectId}" has empty EN content`);
}
} else {
missing.push(`Object "${objectId}" missing EN content field`);
}
}
return missing;
}
function validatePage(filePath) {
const errors = [];
const warnings = [];
if (!fs.existsSync(filePath)) {
return { errors: [`File not found: ${filePath}`], warnings: [], objectCount: 0, spacingCount: 0 };
}
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
return { errors: [`Cannot read file: ${error.message}`], warnings: [], objectCount: 0, spacingCount: 0 };
}
const pageSlug = path.basename(filePath, '.md');
// Derive page prefix (strip leading number): "01-start" -> "start"
const slugParts = pageSlug.split('-');
const pagePrefix = slugParts.length > 1 ? slugParts.slice(1).join('-') : pageSlug;
// 1. Required sections
for (const section of REQUIRED_SECTIONS) {
if (!content.includes(section)) {
errors.push(`Missing section: ${section}`);
}
}
// 2. Object IDs — kebab-case and prefix
const objectIds = extractObjectIds(content);
const seenIds = new Set();
for (const id of objectIds) {
// Kebab-case check
if (!KEBAB_CASE_RE.test(id)) {
errors.push(`Object ID not in kebab-case: \`${id}\``);
}
// Prefix check
if (!id.startsWith(pagePrefix + '-')) {
errors.push(`Object ID missing prefix: \`${id}\` (expected prefix: ${pagePrefix}-)`);
}
// Duplicate check
if (seenIds.has(id)) {
errors.push(`Duplicate Object ID: \`${id}\``);
}
seenIds.add(id);
}
// 3. Navigation rows (expect 3)
const navCount = countNavRows(content);
if (navCount < 3) {
errors.push(`Navigation rows: found ${navCount}, expected 3`);
}
// 4. Metadata table properties
for (const prop of REQUIRED_METADATA_PROPS) {
if (!content.includes(`| ${prop} |`)) {
errors.push(`Metadata table missing property: ${prop}`);
}
}
// 5. Sketches folder
const sketchesDir = path.join(path.dirname(filePath), 'sketches');
if (!fs.existsSync(sketchesDir)) {
warnings.push('Sketches folder does not exist');
}
// 6. Swedish + English content check
const contentWarnings = checkObjectContent(content);
for (const w of contentWarnings) {
warnings.push(w);
}
// Spacing IDs
const spacingIds = extractSpacingIds(content);
return {
errors,
warnings,
objectCount: objectIds.length,
spacingCount: spacingIds.length,
};
}
function formatResult(label, result) {
const { errors, warnings, objectCount, spacingCount } = result;
if (errors.length === 0 && warnings.length === 0) {
return `${label} — valid (${objectCount} objects, ${spacingCount} spacing objects)\n`;
}
const lines = [];
if (errors.length > 0) {
lines.push(`${label}${errors.length} error(s):`);
for (const e of errors) lines.push(` - ${e}`);
}
if (warnings.length > 0) {
lines.push(` ${warnings.length} warning(s):`);
for (const w of warnings) lines.push(` ! ${w}`);
}
return lines.join('\n') + '\n';
}
function getPageFiles(scenarioDir) {
let entries;
try {
entries = fs.readdirSync(scenarioDir, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory())
.map((e) => {
const mdFile = path.join(scenarioDir, e.name, `${e.name}.md`);
return fs.existsSync(mdFile) ? mdFile : null;
})
.filter(Boolean)
.sort();
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.page && !args.scenario && !args.all) {
process.stderr.write('Error: --page, --scenario, or --all is required.\n\n');
printUsage();
process.exit(1);
}
const outputBase = args.output || process.cwd();
const scenariosBase = path.join(outputBase, 'C-UX-Scenarios');
let filesToValidate = [];
if (args.page) {
filesToValidate = [path.resolve(args.page)];
} else if (args.scenario) {
const scenarioSlug = toSlug(args.scenario);
const scenarioDir = path.join(scenariosBase, scenarioSlug);
filesToValidate = getPageFiles(scenarioDir);
if (filesToValidate.length === 0) {
process.stdout.write(`No pages found in scenario: ${scenarioSlug}\n`);
process.exit(0);
}
} else if (args.all) {
if (!fs.existsSync(scenariosBase)) {
process.stderr.write(`Error: C-UX-Scenarios not found at: ${scenariosBase}\n`);
process.exit(1);
}
let entries;
try {
entries = fs.readdirSync(scenariosBase, { withFileTypes: true });
} catch (error) {
process.stderr.write(`Error reading scenarios: ${error.message}\n`);
process.exit(1);
}
for (const e of entries.filter((e) => e.isDirectory()).sort()) {
const pages = getPageFiles(path.join(scenariosBase, e.name));
filesToValidate.push(...pages);
}
if (filesToValidate.length === 0) {
process.stdout.write('No page specs found.\n');
process.exit(0);
}
}
let hasErrors = false;
for (const filePath of filesToValidate) {
const label = path.basename(filePath);
const result = validatePage(filePath);
process.stdout.write(formatResult(label, result));
if (result.errors.length > 0) hasErrors = true;
}
process.exit(hasErrors ? 1 : 0);
}
main();