diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5a26fd8d..fa2a9546 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -103,6 +103,7 @@ const guideSidebar = [ {text: 'scapi_custom_apis_status', link: '/mcp/tools/scapi-custom-apis-status'}, {text: 'storefront_next_development_guidelines', link: '/mcp/tools/storefront-next-development-guidelines'}, {text: 'storefront_next_page_designer_decorator', link: '/mcp/tools/storefront-next-page-designer-decorator'}, + {text: 'storefront_next_site_theming', link: '/mcp/tools/storefront-next-site-theming'}, ], }, ]; diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 2ab025f7..18681231 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -120,6 +120,7 @@ AI assistants automatically decide which MCP tools to use based on your prompts. **Storefront Next Development:** - ✅ "I'm new to Storefront Next. Use the MCP tool to show me the critical rules I need to know." - ✅ "I need to build a product detail page. Use the MCP tool to show me best practices for data fetching and component patterns." +- ✅ "I want to apply my brand colors to my Storefront Next site. Use the MCP tool to help me." **SCAPI Discovery:** - ✅ "Use the MCP tool to list all available SCAPI schemas." diff --git a/docs/mcp/tools/storefront-next-site-theming.md b/docs/mcp/tools/storefront-next-site-theming.md new file mode 100644 index 00000000..251bf546 --- /dev/null +++ b/docs/mcp/tools/storefront-next-site-theming.md @@ -0,0 +1,291 @@ +--- +description: Get theming guidelines, guided questions, and WCAG color contrast validation for Storefront Next. +--- + +# storefront_next_site_theming + +Guides theming changes (colors, fonts, visual styling) for Storefront Next and validates color combinations for WCAG accessibility. **Call this tool first** when the user wants to apply brand colors or change the site theme. + +## Overview + +The `storefront_next_site_theming` tool provides a structured workflow for applying theming to Storefront Next sites: + +1. **Guidelines** - Layout preservation rules, specification compliance, and accessibility requirements +2. **Guided Questions** - Collects user preferences (colors, fonts, mappings) one at a time +3. **WCAG Validation** - Automatically validates color contrast when `colorMapping` is provided + +The tool enforces a mandatory workflow: ask questions → validate colors → present findings → wait for confirmation → implement. Never implement theming changes without calling this tool first. + +## Authentication + +No authentication required. This tool operates on local content and returns guidance text. + +**Requirements:** +- `--allow-non-ga-tools` flag (preview release) +- Storefront Next project (for implementation; tool itself works without project) + +## Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `fileKeys` | string[] | No | File keys to add to the default set. Custom keys are merged with defaults: `theming-questions`, `theming-validation`, `theming-accessibility`. | +| `conversationContext` | object | No | Context from previous rounds. Omit to list available files. See [Conversation Context](#conversation-context) for details. | + +### Conversation Context + +When using the tool across multiple turns, provide `conversationContext` with the following structure: + +| Field | Type | Description | +|-------|------|-------------| +| `currentStep` | `"updating-information"` \| `"validation"` | Current step in the workflow | +| `collectedAnswers` | object | Previously collected answers. Include `colorMapping` to trigger automatic WCAG validation. | +| `questionsAsked` | string[] | List of question IDs already asked | + +**collectedAnswers** can include: + +| Field | Type | Description | +|-------|------|-------------| +| `colors` | object[] | Extracted colors with `hex` and optional `type` | +| `fonts` | object[] | Extracted fonts with `name` and optional `type` | +| `colorMapping` | object | Maps color keys to hex values (for example, `lightText`, `lightBackground`, `buttonText`, `buttonBackground`). **Providing this triggers automatic WCAG contrast validation.** | + +## Operation Modes + +### List Available Files + +Call the tool without `conversationContext` (and without `fileKeys`) to list loaded theming file keys: + +```json +{} +``` + +Returns the list of available keys. Default files are always used when `conversationContext` is provided. + +### Theming Workflow + +When `conversationContext` is provided, the tool returns guidelines and questions, or validation results when `colorMapping` is included. + +## Workflow + +### Phase 1: Information Gathering + +1. **First call** - Call the tool with `conversationContext.collectedAnswers` (can be empty `{colors: [], fonts: []}`) +2. **Extract** - If the user provided colors/fonts in their message, extract and include in `collectedAnswers` +3. **Ask questions** - Tool returns questions; ask the user one at a time and collect answers +4. **Update** - Call the tool again with updated `collectedAnswers` after each user response + +### Phase 2: Validation (Mandatory) + +1. **Construct colorMapping** - Map each color to its usage (text, buttons, links, accents) based on user answers +2. **Validation call** - Call the tool with `collectedAnswers.colorMapping` to trigger automatic WCAG validation +3. **Present findings** - Show contrast ratios, WCAG status (AA/AAA/FAIL), and recommendations to the user +4. **Wait for confirmation** - Do not implement until the user explicitly confirms + +### Phase 3: Implementation + +Only after completing Phases 1 and 2 may you apply theme changes to `app.css` (or project theme files). + +## Usage Examples + +### Example Prompts (Natural Language) + +Try these prompts to get started: + +| Goal | Example prompt | +|------|----------------| +| Start theming from scratch | "I want to apply my brand colors to my Storefront Next site. Use the MCP tool to help me." | +| Validate before implementing | "I have a color scheme ready. Use the MCP tool to validate my colors for accessibility before I implement." | +| Colors + fonts upfront | "Use these colors: #635BFF (accent), #0A2540 (dark). Font: Inter. Use the MCP tool to guide me through theming." | +| Check accessibility only | "Use the MCP tool to validate these color combinations for WCAG: light text #333 on background #FFF, button #0A2540 with white text." | +| Change existing theme | "I want to change my site theme. Use the MCP tool to walk me through the process." | +| List available content | "What theming files does the MCP tool have? Use the tool to list them." | + +### First Call - Get Guidelines and Questions + +``` +I want to apply my brand colors to my Storefront Next site. Use the MCP tool to help me. +``` + +**Example arguments:** + +```json +{ + "conversationContext": { + "collectedAnswers": {"colors": [], "fonts": []} + } +} +``` + +### Validation Call - After Constructing colorMapping + +After collecting user answers, construct the color mapping and call the tool to validate. + +**Minimal colorMapping (light theme only):** + +```json +{ + "conversationContext": { + "collectedAnswers": { + "colorMapping": { + "lightText": "#000000", + "lightBackground": "#FFFFFF", + "buttonText": "#FFFFFF", + "buttonBackground": "#0A2540" + } + } + } +} +``` + +**Full colorMapping (light + dark theme):** + +```json +{ + "conversationContext": { + "collectedAnswers": { + "colorMapping": { + "lightText": "#171717", + "lightBackground": "#FFFFFF", + "darkText": "#FAFAFA", + "darkBackground": "#0A0A0A", + "buttonText": "#FFFFFF", + "buttonBackground": "#0A2540", + "linkColor": "#2563EB", + "accent": "#635BFF" + } + } + } +} +``` + +### With Pre-Provided Colors + +When the user provides colors upfront, extract and include them: + +``` +Use these colors: #635BFF (accent), #0A2540 (dark), #F6F9FC (brand), #FFFFFF (light). Use the MCP tool to guide me through theming. +``` + +**Example arguments:** + +```json +{ + "conversationContext": { + "collectedAnswers": { + "colors": [ + {"hex": "#635BFF", "type": "accent"}, + {"hex": "#0A2540", "type": "dark"}, + {"hex": "#F6F9FC", "type": "brand"}, + {"hex": "#FFFFFF", "type": "light"} + ] + } + } +} +``` + +### With Pre-Provided Fonts + +When the user specifies fonts: + +``` +I want to use Inter for body text and Playfair Display for headings. Use the MCP tool to help me theme my site. +``` + +**Example arguments:** + +```json +{ + "conversationContext": { + "collectedAnswers": { + "colors": [], + "fonts": [ + {"name": "Inter", "type": "body"}, + {"name": "Playfair Display", "type": "heading"} + ] + } + } +} +``` + +### Mid-Conversation Update + +When the user answers a question and provides new information, merge it into `collectedAnswers` and call again: + +```json +{ + "conversationContext": { + "collectedAnswers": { + "colors": [{"hex": "#635BFF", "type": "accent"}], + "fonts": [], + "color-1": "primary action buttons", + "color-2": "links and hover states" + }, + "questionsAsked": ["color-1", "color-2"] + } +} +``` + +### List Available Files (No Context) + +Call with empty arguments to list loaded theming file keys: + +```json +{} +``` + +Returns available keys such as `theming-questions`, `theming-validation`, `theming-accessibility`. + +## Custom Theming Files + +You can add custom theming files via `fileKeys` or the `THEMING_FILES` environment variable. Custom files must follow a specific Markdown format so the parser can extract guidelines, questions, and validation rules. + +**Required heading patterns** (use these exact patterns for content to be parsed): + +- `## 🔄 WORKFLOW` - Workflow steps (numbered `1. Step text`) +- `### 📝 EXTRACTION` - Extraction instructions +- `### ✅ PRE-IMPLEMENTATION` - Pre-implementation checklist +- `## ✅ VALIDATION` - Validation rules (with `### A. Color`, `### B. Font`, `### C. General`, `### IMPORTANT`) +- `## ⚠️ CRITICAL: Title` - Critical guidelines +- `## 📋 Title` - Specification rules +- `### What TO Change:` / `### What NOT to Change:` - DO/DON'T rules (list items with `-`) + +**Questions**: Lines ending with `?` (length > 10) from bullet or numbered lists are extracted. Reference the built-in files in `content/site-theming/` for examples. + +## Rules and Constraints + +- `colorMapping` triggers automatic WCAG validation; `colors` and `fonts` arrays are optional when `colorMapping` is provided +- `fileKeys` add to the default files; they do not replace them +- Call the tool first before implementing any theming changes; never skip the question-answer or validation workflow +- Theme changes apply to `app.css` (standalone: `src/app.css`; monorepo: `packages/template-retail-rsc-app/src/app.css`) + +## Output + +The tool returns text content that includes: + +- **Internal instructions** - Workflow steps, critical rules, validation requirements +- **User-facing response** - What to say to the user, questions to ask +- **Validation results** (when `colorMapping` provided) - Contrast ratios, WCAG compliance status, recommendations for failing combinations + +## Requirements + +- `--allow-non-ga-tools` flag (preview release) +- Storefront Next project (for applying theme changes to `app.css`) + +## Features + +- **Mandatory workflow** - Ensures questions are asked and validation is performed before implementation +- **Automatic WCAG validation** - Validates color contrast when `colorMapping` is provided +- **Content-driven** - Loads guidance from markdown files (`theming-questions`, `theming-validation`, `theming-accessibility`) +- **Layout preservation** - Guidelines enforce that only colors, typography, and visual styling change—never layout or positioning + +## Related Tools + +- Part of the [STOREFRONTNEXT](../toolsets#storefrontnext) toolset +- Auto-enabled for Storefront Next projects (with `--allow-non-ga-tools`) +- Related: [`storefront_next_development_guidelines`](../toolsets#storefrontnext) - Architecture and coding guidelines + +## See Also + +- [STOREFRONTNEXT Toolset](../toolsets#storefrontnext) - Overview of Storefront Next development tools +- [Storefront Next Guide](../../guide/storefront-next) - Storefront Next development guide +- [Configuration](../configuration) - Configure project directory diff --git a/docs/mcp/toolsets.md b/docs/mcp/toolsets.md index 23ad0336..128235fe 100644 --- a/docs/mcp/toolsets.md +++ b/docs/mcp/toolsets.md @@ -97,6 +97,7 @@ Storefront Next development tools for building modern storefronts. |------|-------------|---------------| | [`storefront_next_development_guidelines`](./tools/storefront-next-development-guidelines) | Get Storefront Next development guidelines and best practices | [View details](./tools/storefront-next-development-guidelines) | | [`storefront_next_page_designer_decorator`](./tools/storefront-next-page-designer-decorator) | Add Page Designer decorators to Storefront Next components | [View details](./tools/storefront-next-page-designer-decorator) | +| [`storefront_next_site_theming`](./tools/storefront-next-site-theming) | Get theming guidelines, questions, and WCAG color validation for Storefront Next | [View details](./tools/storefront-next-site-theming) | | [`scapi_schemas_list`](./tools/scapi-schemas-list) | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | [View details](./tools/scapi-schemas-list) | | [`scapi_custom_api_scaffold`](./tools/scapi-custom-api-scaffold) | Generate a new custom SCAPI endpoint (schema, api.json, script.js) in an existing cartridge. | [View details](./tools/scapi-custom-api-scaffold) | | [`scapi_custom_apis_status`](./tools/scapi-custom-apis-status) | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | [View details](./tools/scapi-custom-apis-status) | diff --git a/packages/b2c-dx-mcp/.c8rc.json b/packages/b2c-dx-mcp/.c8rc.json index 82e7743b..eb81059e 100644 --- a/packages/b2c-dx-mcp/.c8rc.json +++ b/packages/b2c-dx-mcp/.c8rc.json @@ -1,7 +1,7 @@ { "all": true, "src": ["src"], - "exclude": ["test/**", "**/*.d.ts", "**/index.ts"], + "exclude": ["test/**", "**/*.d.ts", "**/index.ts", "**/site-theming/types.ts"], "reporter": ["text", "text-summary", "html", "lcov"], "report-dir": "coverage", "check-coverage": true, diff --git a/packages/b2c-dx-mcp/README.md b/packages/b2c-dx-mcp/README.md index 2933bc24..a6eaa535 100644 --- a/packages/b2c-dx-mcp/README.md +++ b/packages/b2c-dx-mcp/README.md @@ -112,6 +112,15 @@ The `storefront_next_development_guidelines` tool provides critical architecture - `extensions` - Extension development - `pitfalls` - Common pitfalls +##### Site Theming + +The `storefront_next_site_theming` tool guides theming changes (colors, fonts, visual styling) and validates color combinations for WCAG accessibility. **Use this tool first** when the user wants to apply brand colors or change the site theme. + +**Prompt examples:** +- "I want to apply my brand colors to my Storefront Next site. Use the MCP tool to help me." +- "Change the theme colors and fonts. Use the MCP tool to guide me through the process." +- "Use the MCP tool to validate my color combinations for accessibility before I implement." + ##### PWA Kit Development **Prompt examples:** @@ -311,6 +320,7 @@ Storefront Next development tools for building modern storefronts. |------|-------------| | `storefront_next_development_guidelines` | Get Storefront Next development guidelines and best practices | | `storefront_next_page_designer_decorator` | Add Page Designer decorators to Storefront Next components | +| `storefront_next_site_theming` | Get theming guidelines, questions, and WCAG color validation for Storefront Next | | `scapi_schemas_list` | List or fetch SCAPI schemas (standard and custom). Use apiFamily: "custom" for custom APIs. | | `scapi_custom_apis_status` | Get registration status of custom API endpoints (active/not_registered). Remote only, requires OAuth. | | `scapi_customapi_scaffold` | Generate a new custom SCAPI endpoint (OAS 3.0 schema, api.json, script.js) in an existing cartridge. Required: apiName. Optional: cartridgeName (defaults to first cartridge), apiType, apiDescription, projectRoot, outputDir. | diff --git a/packages/b2c-dx-mcp/content/site-theming/theming-accessibility.md b/packages/b2c-dx-mcp/content/site-theming/theming-accessibility.md new file mode 100644 index 00000000..f83c5359 --- /dev/null +++ b/packages/b2c-dx-mcp/content/site-theming/theming-accessibility.md @@ -0,0 +1,126 @@ +--- +description: Accessibility guidelines and WCAG compliance rules for theming +alwaysApply: false +--- +# Accessibility Guidelines for Theming + +## 🎯 Accessibility Requirements + +**All theming implementations MUST comply with WCAG 2.1 Level AA standards at minimum.** + +### Color Contrast Requirements + +**WCAG 2.1 Contrast Ratios:** +- **Normal text (16px and below)**: Minimum 4.5:1 contrast ratio +- **Large text (18pt+ or 14pt+ bold)**: Minimum 3:1 contrast ratio +- **AAA compliance (recommended)**: 7:1 for normal text, 4.5:1 for large text + +**Critical Color Combinations to Validate:** +1. Primary text on primary background +2. Secondary text on secondary background +3. Button text on button background +4. Link text on page background +5. Accent colors on all background variations +6. Muted text on muted backgrounds +7. Border colors against adjacent backgrounds + +### Visual Hierarchy and Readability + +**Text Readability:** +- Ensure sufficient contrast for all text sizes +- Avoid using similar brightness levels for text and background +- Test color combinations in both light and dark themes +- Consider users with color vision deficiencies + +**Interactive Elements:** +- Buttons must have clear visual distinction from backgrounds +- Hover states must maintain or improve contrast +- Focus indicators must be clearly visible (minimum 3:1 contrast) +- Disabled states should still be readable (minimum 3:1 contrast) + +### Font Accessibility + +**Font Readability Requirements:** +- Body text should use fonts optimized for screen reading +- Minimum font size: 16px for body text (recommended) +- Line height: Minimum 1.5 for body text +- Letter spacing: Ensure adequate spacing for readability +- Font weights: Ensure sufficient contrast between weights + +**Font Loading Performance:** +- Web fonts should not block rendering +- Provide appropriate fallback fonts +- Consider font-display: swap for better performance +- Test font loading on slow connections + +### Color Vision Deficiency Considerations + +**Design for Color Blindness:** +- Don't rely solely on color to convey information +- Use patterns, icons, or text labels in addition to color +- Test color combinations with color blindness simulators +- Ensure sufficient contrast even when colors are desaturated + +**Common Color Blindness Types:** +- Protanopia (red-blind) +- Deuteranopia (green-blind) +- Tritanopia (blue-blind) +- Monochromacy (total color blindness) + +### Focus and Keyboard Navigation + +**Focus Indicators:** +- All interactive elements must have visible focus indicators +- Focus indicators must have minimum 3:1 contrast ratio +- Focus indicators should be at least 2px thick +- Use outline or border to indicate focus state + +**Keyboard Accessibility:** +- All interactive elements must be keyboard accessible +- Tab order should be logical and intuitive +- Skip links should be provided for long pages +- Focus trap should be implemented in modals + +### Screen Reader Considerations + +**Semantic HTML:** +- Use proper heading hierarchy (h1-h6) +- Use semantic HTML elements (nav, main, article, etc.) +- Provide alt text for images +- Use ARIA labels when necessary + +**Color and Screen Readers:** +- Screen readers don't convey color information +- Ensure information is accessible without color +- Use text labels, icons, or patterns in addition to color + +### Testing and Validation + +**Required Testing:** +1. Automated contrast checking (WCAG AA/AAA) +2. Manual visual inspection +3. Color blindness simulator testing +4. Screen reader testing +5. Keyboard navigation testing + +**Tools for Validation:** +- Color contrast analyzers (WebAIM, WAVE) +- Color blindness simulators +- Screen reader testing (NVDA, JAWS, VoiceOver) +- Browser accessibility inspectors + +### Implementation Guidelines + +**When Implementing Themes:** +1. Always validate color contrast ratios before implementation +2. Test with multiple color combinations +3. Provide alternative color suggestions if contrast fails +4. Document accessibility decisions +5. Test with assistive technologies + +**If Accessibility Issues Are Found:** +- Present findings clearly to the user +- Provide specific contrast ratios and WCAG compliance status +- Suggest concrete alternatives with improved contrast +- Explain the accessibility impact +- Wait for user confirmation before proceeding diff --git a/packages/b2c-dx-mcp/content/site-theming/theming-questions.md b/packages/b2c-dx-mcp/content/site-theming/theming-questions.md new file mode 100644 index 00000000..c3a5cb4e --- /dev/null +++ b/packages/b2c-dx-mcp/content/site-theming/theming-questions.md @@ -0,0 +1,208 @@ +--- +description: Theming questions and information gathering guidelines +alwaysApply: false +--- +# Theming Questions and Information Gathering + +## ⚠️ CRITICAL: Layout Preservation Rules + +**NEVER modify positioning, layout, or structural CSS properties when applying theming changes. Only change colors, typography, and visual styling.** + +### What NOT to Change: +- `position` properties (fixed, absolute, relative) +- `top`, `left`, `right`, `bottom` positioning +- `margin` and `padding` values +- `width`, `height`, `min-height`, `max-height` +- `display` properties (flex, grid, block) +- `z-index` values (unless specifically requested) +- `transform` properties +- Grid/flexbox layout properties + +### What TO Change: +- `color`, `background-color`, `border-color` +- `text-decoration`, `font-weight`, `font-size` +- `opacity`, `box-shadow`, `border-radius` +- CSS custom properties (CSS variables) +- Hover states and transitions +- Theme-specific styling + +### Example of CORRECT theming: +```css +/* ✅ CORRECT - Only changing colors and visual styling */ +.navigation-item { + color: var(--foreground); + background-color: var(--background); + border-color: var(--border); + transition: color 0.2s ease; +} + +.navigation-item:hover { + color: var(--accent); + background-color: var(--accent/10); +} +``` + +### Example of INCORRECT theming: +```css +/* ❌ INCORRECT - Changing layout/positioning */ +.navigation-item { + margin-left: 20px; /* DON'T change margins */ + position: relative; /* DON'T change positioning */ + z-index: 100; /* DON'T change z-index */ + width: 200px; /* DON'T change dimensions */ +} +``` + +### When Layout Changes Are Needed: +If layout modifications are required, they should be: +1. Explicitly requested by the user +2. Clearly separated from theming changes +3. Documented as layout fixes, not theming +4. Tested thoroughly for responsive behavior + +## 📋 Specification Compliance Rules + +**ALWAYS follow user specifications exactly. Never make assumptions or interpretations.** + +### ⚠️ **CRITICAL: WAIT FOR USER RESPONSE RULE** + +**NEVER implement changes after asking clarifying questions without waiting for the user's response.** + +**Required Process:** +1. Ask clarifying questions +2. **WAIT for user response** ⚠️ **CRITICAL STEP** +3. Only implement after receiving explicit guidance +4. Never proceed with assumptions + +**Violations:** +- ❌ Ask questions then immediately implement +- ❌ Make assumptions about color/font mapping +- ❌ Proceed without explicit user guidance +- ❌ Implement "best guess" solutions + +### ✅ **Color Specification Rules:** +1. **Use exact hex values** provided by the user +2. **Ask for clarification** on color type mapping (e.g., "brand" vs "accent") +3. **Propose color combinations** before implementing: + - Which color should be primary vs secondary? + - How should "brand" colors be used vs "accent" colors? + - Should "dark" colors be used for text or backgrounds? + - What should be the hover state colors? +4. **Never assume** color usage without explicit user guidance + +### ✅ **Font Specification Rules:** +1. **Use exact font names** as provided by the user +2. **Ask for clarification** if font names are unclear or unfamiliar +3. **Verify font availability** before implementing +4. **Never substitute** similar fonts without user permission + +### ✅ **General Specification Rules:** +1. **Follow exact specifications** without interpretation +2. **Ask clarifying questions** when specifications are ambiguous +3. **Propose implementation approaches** before making changes +4. **Confirm understanding** of requirements before proceeding + +### Example of CORRECT specification handling: +``` +User: "Use these colors: #635BFF (accent), #0A2540 (dark), #F6F9FC (brand), #FFFFFF (light)" + +AI Response: "I see you've provided 4 colors. Before implementing, I'd like to clarify: +- Should #635BFF be used for primary actions and hover states? +- Should #0A2540 be used for text color or background? +- Should #F6F9FC be used for secondary elements or primary branding? +- What should be the light theme vs dark theme color mapping?" + +[WAITS for user response before implementing] +``` + +### Example of INCORRECT specification handling: +``` +❌ DON'T: Assume "sohne-var" means "Sohne" font +❌ DON'T: Guess color usage without asking +❌ DON'T: Make assumptions about font weights or styles +❌ DON'T: Implement without confirming understanding +❌ DON'T: Ask clarifying questions then immediately implement +``` + +## 🔄 WORKFLOW - PHASE 1: INFORMATION GATHERING + +**YOU MUST FOLLOW THIS WORKFLOW - NO EXCEPTIONS:** + +1. **DO NOT implement any changes yet** +2. **Ask the questions below ONE AT A TIME** +3. **WAIT for the user's response** before asking the next question +4. **Only proceed after ALL required questions are answered** +5. **Even if the user provided colors/fonts upfront, you MUST still ask ALL these clarifying questions** + - Colors provided? Still ask about color mapping, usage, hover states, etc. + - Fonts provided? Still ask about font usage, availability, headings vs body, etc. +6. **You MUST ask questions from ALL categories (colors, fonts, general) - do not skip any** + +**VIOLATION OF THIS WORKFLOW IS A CRITICAL ERROR.** + +### 📝 EXTRACTION + +**BEFORE generating questions, you MUST extract and provide the theming information from the user's input.** + +1. **Review the user's input** carefully for any theming information: + - Colors (hex values like "#635BFF", color types like "accent", "primary", "dark", "light") + - Fonts (font names like "Sohne Var", font types like "title", "body", "heading") + - Any other theming preferences (spacing, sizes, etc.) + +2. **Extract and structure the information** in the following format: + - Colors: Array of objects with `hex` (string) and `type` (string, optional) properties + - Fonts: Array of objects with `name` (string) and `type` (string, optional) properties + - Other info: Key-value pairs as needed + +3. **Call this tool again** with the extracted information in `conversationContext.collectedAnswers`: + ``` + { + conversationContext: { + collectedAnswers: { + colors: [{ hex: "#635BFF", type: "accent" }, { hex: "#0A2540", type: "dark" }, ...], + fonts: [{ name: "Sohne Var", type: "title" }, ...], + // ... other info + } + } + } + ``` + +4. **Only after providing this information** will the tool generate the appropriate questions. + +**IMPORTANT:** +- If the user did NOT mention colors or fonts, you can still call the tool with empty arrays or skip this step +- But if the user DID mention colors/fonts, you MUST extract them before proceeding +- **DO NOT proceed with questions until you have extracted and provided the theming information.** + +### 🔄 UPDATING INFORMATION (MANDATORY) + +**⚠️ CRITICAL: Whenever the user provides NEW or UPDATED theming information, you MUST call the tool again with the updated information.** + +**This applies to:** New/updated colors, fonts, color mappings, or any theming preferences. + +**Required Process:** +1. Extract the new/updated information from the user's message +2. Merge with ALL previously collected information (include everything, not just new data) +3. Call the `site_theming` tool IMMEDIATELY with complete updated information +4. If `colorMapping` is provided, validation will trigger automatically (see validation phase) + +**Tool call structure:** +``` +{ + conversationContext: { + currentStep: "updating-information" | "validation", + collectedAnswers: { + colors: [...all colors, including previous and new...], + fonts: [...all fonts, including previous and new...], + colorMapping: {...all mappings, including previous and updated...}, + // ... all other collected information + }, + questionsAsked: [...previous questions...] + } +} +``` + +**⚠️ CRITICAL RULES:** +- Every update requires a new tool call (not optional) +- Include ALL previously collected information in each call +- If user provides `colorMapping`, it triggers automatic validation +- DO NOT implement without calling the tool with updated information first diff --git a/packages/b2c-dx-mcp/content/site-theming/theming-validation.md b/packages/b2c-dx-mcp/content/site-theming/theming-validation.md new file mode 100644 index 00000000..9196b025 --- /dev/null +++ b/packages/b2c-dx-mcp/content/site-theming/theming-validation.md @@ -0,0 +1,174 @@ +--- +description: Theming validation rules and validation gate workflow +alwaysApply: false +--- +# Theming Validation Rules + +## 🔄 WORKFLOW - PHASE 2: VALIDATION GATE (MANDATORY - CANNOT SKIP) + +**⚠️ CRITICAL: YOU CANNOT PROCEED TO IMPLEMENTATION WITHOUT COMPLETING THIS PHASE** + +**STEP 7: CONSTRUCT COLOR MAPPING (MANDATORY IF COLORS PROVIDED)** +- **After collecting all user answers, you MUST construct a `colorMapping` object** +- **Map each color to its specific usage based on user answers:** + - Text colors (foreground on background) + - Button colors (button text on button background) + - Link colors (link text on page background) + - Accent colors (accent elements on various backgrounds) + - All other color combinations that will be used +- **This mapping is REQUIRED - you cannot skip this step** + +**STEP 8: MANDATORY VALIDATION TOOL CALL** +- **YOU MUST call the `site_theming` tool AGAIN with `colorMapping` in `conversationContext.collectedAnswers`** +- **This is a SEPARATE tool call - do NOT implement after collecting answers** +- **This triggers automatic validation that you CANNOT skip** +- **See "HOW TO TRIGGER VALIDATION" section below for details** +- **⚠️ CRITICAL: If you skip this tool call and proceed to implementation, you are making a CRITICAL ERROR** + +**STEP 9: PRESENT VALIDATION FINDINGS** +- **After the tool returns validation results, you MUST present ALL validation results to the user** +- **Show the complete validation output from the tool, including:** + - All contrast ratios for each color combination + - WCAG compliance status (AA/AAA/FAIL) + - Visual assessment (excellent/good/acceptable/poor) + - Any recommendations provided by the tool +- **If ANY issues found:** + - Show contrast ratios and WCAG compliance status + - Explain accessibility problems clearly + - Provide concrete alternative suggestions + - **WAIT for user confirmation before proceeding** +- **Even if all validations pass, you MUST still present the results to the user** + +**STEP 10: USER CONFIRMATION** +- **You MUST wait for explicit user confirmation to proceed** +- **User must acknowledge validation findings (even if they choose to proceed with issues)** +- **Do NOT implement until user explicitly confirms they want to proceed** + +### PHASE 3: IMPLEMENTATION (ONLY AFTER PHASE 2 COMPLETE) + +**STEP 11: Implementation** +- **ONLY after completing ALL steps above may you implement the theme** +- **Specifically, you MUST have:** + 1. Collected all user answers + 2. Constructed colorMapping object + 3. Called the tool with colorMapping (STEP 8) + 4. Received validation results from the tool + 5. Presented validation results to user (STEP 9) + 6. Received user confirmation (STEP 10) +- **If you implement before completing Phase 2, you are making a CRITICAL ERROR** +- **If you implement without calling the tool with colorMapping, you are making a CRITICAL ERROR** + +**Where to apply theme changes:** +- **For StorefrontNext/Odyssey projects:** Apply theme changes to `app.css` + - Standalone project: `src/app.css` + - Monorepo: `packages/template-retail-rsc-app/src/app.css` +- Update CSS custom properties in the `:root` block (light theme) and `.dark` / `[data-theme='dark']` blocks (dark theme) as needed +- **If the project uses a different theme file structure** (e.g., multiple CSS files, custom theme location): Ask the user to specify the destination file before implementing + +### ✅ PRE-IMPLEMENTATION CHECKLIST (BLOCKING GATE) + +**🛑 YOU CANNOT IMPLEMENT UNTIL ALL ITEMS BELOW ARE COMPLETE** + +**This checklist is a MANDATORY BLOCKING GATE - you MUST verify each item before proceeding:** + +- [ ] **Item 1:** All required questions answered +- [ ] **Item 2:** Constructed `colorMapping` object mapping all colors to their usage (text, buttons, links, accents, etc.) +- [ ] **Item 3:** Called `site_theming` tool AGAIN with `colorMapping` in `conversationContext.collectedAnswers` (this is a separate tool call, not the same call where you collected answers) +- [ ] **Item 4:** Received validation results from the tool (the tool automatically validates when colorMapping is provided) +- [ ] **Item 5:** Presented ALL validation findings to user (even if all pass) - show contrast ratios, WCAG status, visual assessment +- [ ] **Item 6:** If issues found, provided specific contrast ratios, WCAG status, and alternative suggestions +- [ ] **Item 7:** Received explicit user confirmation to proceed (user acknowledged findings) +- [ ] **Item 8:** Font validation completed (if fonts provided) +- [ ] **Item 9:** All validation concerns addressed or user explicitly chose to proceed despite issues + +**ONLY when ALL items above are checked may you proceed to implementation.** + +**⚠️ IF YOU IMPLEMENT WITHOUT COMPLETING THIS CHECKLIST, YOU ARE MAKING A CRITICAL ERROR.** + +## ✅ VALIDATION + +**⚠️ MANDATORY: Input Validation** - BEFORE implementing, you MUST validate ALL user-provided inputs: + +### A. Color Combination Validation (MANDATORY if colors provided): + +- Calculate contrast ratios for ALL color combinations (text on background, accent on backgrounds, etc.) +- Check WCAG AA/AAA compliance (4.5:1 for normal text, 3:1 for large text) +- Identify ANY accessibility issues or poor contrast combinations +- Visual/UX impact explanation - whether the combinations look good, are readable, and maintain visual hierarchy +- If issues are found, present them to the user with: + * Specific contrast ratios and WCAG compliance status + * Clear explanation of the accessibility problem + * Concrete alternative suggestions with improved contrast ratios + +### B. Font Validation (MANDATORY if fonts provided): + +- Verify font availability and accessibility (can it be loaded? Is it a real font?) +- Check if font is available on Google Fonts, Adobe Fonts, or needs custom hosting +- Validate font weights availability (does the font support the weights needed?) +- Assess font readability/legibility (especially for body text) +- Check font loading performance implications (web fonts vs system fonts) +- Evaluate font pairing (if multiple fonts provided, do they work well together?) +- Verify fallback fonts are appropriate +- If issues are found, present them to the user with: + * Specific concerns (availability, readability, performance, etc.) + * Clear explanation of the problem + * Concrete alternative suggestions (e.g., Google Fonts equivalents, better pairings) + * Performance/UX impact explanation + +### C. General Input Validation: + +- Validate any other user-provided inputs (spacing, sizes, etc.) if applicable +- Check for potential conflicts or issues + +### IMPORTANT: + +- **Validation is MANDATORY even if inputs seem fine** +- **DO NOT skip validation or implement without validating ALL provided inputs** +- **Call the tool with `colorMapping` to trigger automatic validation (see "HOW TO TRIGGER VALIDATION" above)** +- **Always respect the user's final decision** - if they insist on their choices after your suggestions, use them as specified + +### 🚨 HOW TO TRIGGER VALIDATION: + +**⚠️ CRITICAL: This is a SEPARATE tool call that you MUST make AFTER collecting all user answers OR when user provides/updates colorMapping.** + +**Process:** +1. Construct `colorMapping` object mapping colors to usage (text, buttons, links, accents, hover states, etc.) +2. Call the `site_theming` tool with `colorMapping` (include ALL previously collected information): + +```javascript +{ + conversationContext: { + currentStep: "validation", + collectedAnswers: { + colors: [...all colors...], + fonts: [...all fonts...], + colorMapping: { + textColor: "#635BFF", + background: "#FFFFFF", + buttonBackground: "#0A2540", + buttonText: "#FFFFFF", + linkColor: "#0A2540", + accent: "#0A2540", + // ... all combinations + }, + // ... all other collected information + }, + questionsAsked: [...] + } +} +``` + +**The tool automatically:** +- Calculates contrast ratios for all color combinations +- Checks WCAG AA/AAA compliance +- Provides visual assessment +- Returns validation results + +**You MUST then:** +- Present ALL validation results to the user +- Wait for user confirmation +- Only then proceed to implementation + +**⚠️ IF YOU SKIP THIS TOOL CALL AND PROCEED TO IMPLEMENTATION, YOU ARE MAKING A CRITICAL ERROR.** + +**Note:** If user provides updated colorMapping at any time, call the tool with updated information (see information gathering phase for update process). Validation will trigger automatically when colorMapping is provided. diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md b/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md index 3d8ae54f..6726789d 100644 --- a/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/README.md @@ -127,6 +127,59 @@ Add Page Designer decorators (`@Component`, `@AttributeDefinition`, `@RegionDefi } ``` +### `storefront_next_site_theming` + +**MANDATORY** before implementing any theming changes. Provides theming guidelines, questions, and automatic color contrast validation. Call this tool FIRST when the user requests theming (even if colors/fonts are provided). Never implement without calling it first. + +**Status**: ✅ Implemented (non-GA - use `--allow-non-ga-tools` flag) + +**Use cases**: + +- Apply colors, fonts, or visual styling to a Storefront Next site +- Validate color combinations for WCAG accessibility before implementing +- Follow the theming workflow (questions → validation → confirmation → implement) + +**Parameters**: + +- `fileKeys` (optional, array): File keys to add to the default set. Defaults use `theming-questions`, `theming-validation`, `theming-accessibility` +- `conversationContext` (optional, object): Context from previous rounds + - `currentStep` (optional): Current step in the conversation + - `collectedAnswers` (optional): Previously collected answers; include `colorMapping` to trigger automatic validation (colorMapping alone is sufficient; colors array is not required) + - `questionsAsked` (optional): List of question IDs already asked + +**Returns**: Theming guidelines, questions to ask, and (when `colorMapping` provided, with or without colors array) automated WCAG contrast validation results + +**Example usage**: + +```json +// First call - get guidelines and questions +{ + "name": "storefront_next_site_theming", + "arguments": { + "conversationContext": { + "collectedAnswers": {"colors": [], "fonts": []} + } + } +} + +// Validation call - after constructing colorMapping (colorMapping alone triggers validation) +{ + "name": "storefront_next_site_theming", + "arguments": { + "conversationContext": { + "collectedAnswers": { + "colorMapping": { + "lightText": "#000000", + "lightBackground": "#FFFFFF", + "buttonText": "#FFFFFF", + "buttonBackground": "#0A2540" + } + } + } + } +} +``` + ## Implementation Details ### Architecture @@ -201,3 +254,12 @@ The tool automatically searches for components in these locations (in order): Component discovery uses the project directory resolved from `--project-directory` flag or `SFCC_PROJECT_DIRECTORY` environment variable (via Services). This ensures searches start from the correct project directory, especially when MCP clients spawn servers from the home directory. **See also**: [Detailed documentation](./page-designer-decorator/README.md) for complete usage guide, architecture details, and examples. + +#### `storefront_next_site_theming` + +The tool loads theming guidance from markdown files in `content/site-theming/` and runs automatic WCAG contrast validation when `colorMapping` is provided: + +- **Content source**: `theming-questions`, `theming-validation`, `theming-accessibility` (default); custom files via `fileKeys` or `THEMING_FILES` env +- **Workflow**: Call tool → Ask questions → Call with `colorMapping` (triggers validation) → Present findings → Wait for confirmation → Implement + +**See also**: [Detailed documentation](./site-theming/README.md) for complete usage guide, architecture details, and examples. diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts index 5f6d9c8a..4ff01b40 100644 --- a/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/index.ts @@ -12,6 +12,7 @@ * **Implemented Tools:** * - `storefront_next_development_guidelines` - Get development guidelines and best practices * - `storefront_next_page_designer_decorator` - Add Page Designer decorators to React components + * - `storefront_next_site_theming` - Get theming guidelines, questions, and validation * * Note: mrt_bundle_push is defined in the MRT toolset and appears in STOREFRONTNEXT. * @@ -22,6 +23,7 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import {createDeveloperGuidelinesTool} from './sfnext-development-guidelines.js'; import {createPageDesignerDecoratorTool} from './page-designer-decorator/index.js'; +import {createSiteThemingTool} from './site-theming/index.js'; /** * Creates all tools for the STOREFRONTNEXT toolset. @@ -34,5 +36,9 @@ import {createPageDesignerDecoratorTool} from './page-designer-decorator/index.j * @returns Array of MCP tools */ export function createStorefrontNextTools(loadServices: () => Services): McpTool[] { - return [createDeveloperGuidelinesTool(loadServices), createPageDesignerDecoratorTool(loadServices)]; + return [ + createDeveloperGuidelinesTool(loadServices), + createPageDesignerDecoratorTool(loadServices), + createSiteThemingTool(loadServices), + ]; } diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/README.md b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/README.md new file mode 100644 index 00000000..6f746590 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/README.md @@ -0,0 +1,179 @@ +# Site Theming Tool + +Tool for applying colors, fonts, and visual styling to Storefront Next sites with guided questions and automatic WCAG color contrast validation. + +## Overview + +This tool provides theming guidelines, collects user preferences through structured questions, and validates color combinations for accessibility before implementation. **Call this tool FIRST** when the user requests theming changes—even if they have already provided colors or fonts. + +## Key Features + +- **Mandatory workflow**: Ensures questions are asked and validation is performed before implementation +- **Automatic WCAG validation**: Validates color contrast when `colorMapping` is provided in `conversationContext.collectedAnswers` +- **Content-driven**: Loads guidance from markdown files in `content/site-theming/` +- **Merge support**: Combines multiple theming files via `fileKeys` +- **Custom content**: Add custom files via `THEMING_FILES` environment variable + +## File Structure + +``` +site-theming/ +├── index.ts # Tool factory and orchestration +├── types.ts # Shared types (ColorMapping, CollectedAnswers, etc.) +├── color-mapping.ts # Color combination derivation and WCAG validation +├── guidance-merger.ts # Merges multiple ThemingGuidance objects +├── response-builder.ts # Response construction from guidance and context +├── theming-store.ts # Content loading and parsing +└── color-contrast.ts # WCAG 2.1 contrast calculation and validation +``` + +Content files (in `packages/b2c-dx-mcp/content/site-theming/`): + +- `theming-questions.md` - Questions, critical rules, DO/DON'T guidelines +- `theming-validation.md` - Validation workflow, color/font validation rules +- `theming-accessibility.md` - Accessibility-specific guidance + +## Usage + +### Workflow + +1. **First call**: Call tool with `conversationContext.collectedAnswers` (can be empty `{colors: [], fonts: []}`) +2. **Ask questions**: Tool returns questions—ask user one at a time, collect answers +3. **Validation call**: Construct `colorMapping` from answers, call tool again with `collectedAnswers.colorMapping` +4. **Present findings**: Show validation results (contrast ratios, WCAG status) to user +5. **Wait for confirmation**: Do not implement until user confirms +6. **Implement**: Apply theme changes to `app.css` or project theme files + +### Basic Usage + +```json +// First call - get guidelines and questions +{ + "name": "storefront_next_site_theming", + "arguments": { + "conversationContext": { + "collectedAnswers": {"colors": [], "fonts": []} + } + } +} + +// Validation call - after constructing colorMapping +{ + "name": "storefront_next_site_theming", + "arguments": { + "conversationContext": { + "collectedAnswers": { + "colors": [{"hex": "#635BFF", "type": "primary"}], + "colorMapping": { + "lightText": "#000000", + "lightBackground": "#FFFFFF", + "buttonText": "#FFFFFF", + "buttonBackground": "#0A2540" + } + } + } + } +} +``` + +### List Available Files + +```json +{} +``` + +Returns list of loaded theming file keys. Use `fileKeys` to add custom files to the default set. + +### Custom Theming Files + +Set `THEMING_FILES` environment variable (JSON array of `{key, path}`): + +```bash +export THEMING_FILES='[{"key":"custom-theming","path":"path/to/custom-theming.md"}]' +``` + +Paths are relative to the project directory (from `--project-directory` or `SFCC_PROJECT_DIRECTORY`). + +Custom files can also be added via the `fileKeys` parameter when calling the tool. Files must follow the format below to be parsed correctly. + +#### Custom Theming File Format + +Custom theming files must be Markdown (`.md` or `.mdc`). The parser extracts content based on specific heading patterns. Use these headings to structure your file: + +| Heading pattern | Purpose | +| --------------------------- | ------------------------------------------------------------------------------- | +| `## 🔄 WORKFLOW` | Workflow steps and instructions. Numbered steps (`1. Step text`) are extracted. | +| `### 📝 EXTRACTION` | Instructions for extracting theming info from user input. | +| `### ✅ PRE-IMPLEMENTATION` | Pre-implementation checklist. | +| `## ✅ VALIDATION` | Validation rules. | +| `### A. Color` | Color validation rules. | +| `### B. Font` | Font validation rules. | +| `### C. General` | General validation rules. | +| `### IMPORTANT` | Validation requirements. | +| `## ⚠️ CRITICAL: Title` | Critical guidelines (layout preservation, wait-for-response, etc.). | +| `## 📋 Title` | Specification compliance rules. | +| `### What TO Change:` | DO rules. List items with `-` are extracted. | +| `### What NOT to Change:` | DON'T rules. List items with `-` are extracted. | + +**Questions**: Lines ending with `?` and length > 10 (from bullet or numbered lists) are extracted as questions. Keywords like "color", "font", "primary", "accent" determine category (colors, typography, general). + +**Optional frontmatter** (YAML at top of file): + +```yaml +--- +description: Brief description +alwaysApply: false +--- +``` + +Reference the built-in files (`theming-questions.md`, `theming-validation.md`, `theming-accessibility.md`) in `content/site-theming/` for examples. + +## Architecture + +### Content Loading + +The tool loads markdown files from `content/site-theming/` at initialization. It parses: + +- Workflow steps (## WORKFLOW) +- Validation rules (## VALIDATION) +- Critical guidelines (## CRITICAL) +- DO/DON'T rules (### What TO Change / What NOT to Change) +- Generated questions from guidelines + +### Color Contrast Validation + +When `colorMapping` is present in `collectedAnswers` (colorMapping alone is sufficient; the colors array is not required), the tool: + +1. Derives foreground/background combinations from the mapping +2. Calculates WCAG 2.1 contrast ratios +3. Determines AA/AAA compliance +4. Returns validation results with recommendations for failing combinations + +### Merging Guidance + +Multiple files are merged: questions are deduplicated by ID, guidelines and rules are concatenated, workflow and validation sections are combined. + +## When to Use This Tool + +Use this tool when: + +- User wants to apply brand colors, fonts, or visual styling to a Storefront Next site +- User has provided colors/fonts and needs validation before implementation +- You need to follow the theming workflow (questions, validation, confirmation) + +## Testing + +### Automated Tests + +```bash +cd packages/b2c-dx-mcp +pnpm run test:agent -- test/tools/storefrontnext/site-theming/ +``` + +The test suite covers tool metadata, behavior, color validation, file merging, edge cases, `color-contrast.ts`, and `theming-store.ts`. + +See [`test/tools/storefrontnext/site-theming/README.md`](../../../../test/tools/storefrontnext/site-theming/README.md) for detailed testing instructions and manual test scenarios. + +## License + +Apache-2.0 - Copyright (c) 2025, Salesforce, Inc. diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/color-contrast.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/color-contrast.ts new file mode 100644 index 00000000..c8cec96d --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/color-contrast.ts @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * WCAG 2.1 color contrast utilities for accessibility validation. + * + * Provides luminance calculation, contrast ratio computation, and WCAG compliance + * checking for theming and color validation in Storefront Next. + * + * @module tools/storefrontnext/site-theming/color-contrast + */ + +/** + * WCAG 2.1 constants for contrast ratio calculation + * These values are specified in the WCAG 2.1 standard + */ +const WCAG_CONTRAST_OFFSET = 0.05; // Offset added to luminance values in contrast ratio formula + +// Linear RGB conversion constants (sRGB to linear RGB) +const LINEAR_RGB_THRESHOLD = 0.039_28; // Threshold for linear RGB conversion +const LINEAR_RGB_DIVISOR = 12.92; // Divisor for values below threshold +const GAMMA_CORRECTION_OFFSET = 0.055; // Offset for gamma correction +const GAMMA_CORRECTION_DIVISOR = 1.055; // Divisor for gamma correction +const GAMMA_EXPONENT = 2.4; // Gamma exponent for sRGB + +// Relative luminance weights (WCAG 2.1 standard) +const LUMINANCE_RED_WEIGHT = 0.2126; +const LUMINANCE_GREEN_WEIGHT = 0.7152; +const LUMINANCE_BLUE_WEIGHT = 0.0722; + +/** Valid 6-digit hex color pattern (with optional # prefix) */ +const HEX_PATTERN = /^#?[0-9A-Fa-f]{6}$/; + +/** + * Validates that a string is a valid 6-digit hex color. + * @param hex - Hex color string to validate + * @returns true if valid + */ +export function isValidHex(hex: string): boolean { + return typeof hex === 'string' && HEX_PATTERN.test(hex.trim()); +} + +/** + * Calculates the relative luminance of a color according to WCAG 2.1 + * @param hex - Hex color string (e.g., "#635BFF") + * @returns Relative luminance value between 0 and 1 + * @throws Error if hex format is invalid + */ +export function getLuminance(hex: string): number { + const trimmed = hex.trim(); + if (!HEX_PATTERN.test(trimmed)) { + throw new Error(`Invalid hex color: "${hex}". Expected 6-digit hex (e.g., #635BFF).`); + } + const cleanHex = trimmed.replace('#', ''); + + // Parse RGB values + const r = Number.parseInt(cleanHex.slice(0, 2), 16) / 255; + const g = Number.parseInt(cleanHex.slice(2, 4), 16) / 255; + const b = Number.parseInt(cleanHex.slice(4, 6), 16) / 255; + + // Convert to linear RGB + const [rs, gs, bs] = [r, g, b].map((c) => { + return c <= LINEAR_RGB_THRESHOLD + ? c / LINEAR_RGB_DIVISOR + : ((c + GAMMA_CORRECTION_OFFSET) / GAMMA_CORRECTION_DIVISOR) ** GAMMA_EXPONENT; + }); + + // Calculate relative luminance + return LUMINANCE_RED_WEIGHT * rs + LUMINANCE_GREEN_WEIGHT * gs + LUMINANCE_BLUE_WEIGHT * bs; +} + +/** + * Calculates the contrast ratio between two colors according to WCAG 2.1 + * @param color1 - First hex color string + * @param color2 - Second hex color string + * @returns Contrast ratio (1:1 to 21:1) + */ +export function getContrastRatio(color1: string, color2: string): number { + const l1 = getLuminance(color1); + const l2 = getLuminance(color2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + WCAG_CONTRAST_OFFSET) / (darker + WCAG_CONTRAST_OFFSET); +} + +/** + * WCAG compliance levels + */ +export enum WCAGLevel { + AA = 'AA', // 4.5:1 for normal text + AA_LARGE = 'AA_LARGE', // 3:1 for large text + AAA = 'AAA', // 7:1 for normal text + AAA_LARGE = 'AAA_LARGE', // 4.5:1 for large text + FAIL = 'FAIL', +} + +/** + * Determines WCAG compliance level for a contrast ratio + * @param ratio - Contrast ratio + * @param isLargeText - Whether this is for large text (18pt+ or 14pt+ bold) + * @returns WCAG compliance level + */ +export function getWCAGLevel(ratio: number, isLargeText = false): WCAGLevel { + if (isLargeText) { + if (ratio >= 4.5) { + return WCAGLevel.AAA_LARGE; + } + if (ratio >= 3) { + return WCAGLevel.AA_LARGE; + } + return WCAGLevel.FAIL; + } + + if (ratio >= 7) { + return WCAGLevel.AAA; + } + if (ratio >= 4.5) { + return WCAGLevel.AA; + } + return WCAGLevel.FAIL; +} + +/** + * Result of color contrast validation for a single foreground/background pair. + * + * @property {string} color1 - First hex color (typically foreground) + * @property {string} color2 - Second hex color (typically background) + * @property {number} ratio - Contrast ratio (1:1 to 21:1) + * @property {WCAGLevel} wcagLevel - WCAG compliance level + * @property {boolean} passesAA - Whether the combination meets WCAG AA + * @property {boolean} passesAAA - Whether the combination meets WCAG AAA + * @property {boolean} isLargeText - Whether validation used large-text thresholds + * @property {string} visualAssessment - Readability assessment (excellent, good, acceptable, poor) + * @property {string} [recommendation] - Optional suggestion when contrast is suboptimal + */ +export interface ContrastValidationResult { + color1: string; + color2: string; + ratio: number; + wcagLevel: WCAGLevel; + passesAA: boolean; + passesAAA: boolean; + isLargeText: boolean; + visualAssessment: 'acceptable' | 'excellent' | 'good' | 'poor'; + recommendation?: string; +} + +/** + * Validates contrast between two colors + * @param color1 - First hex color string + * @param color2 - Second hex color string + * @param isLargeText - Whether this is for large text + * @returns Validation result with contrast ratio and compliance info + */ +export function validateContrast(color1: string, color2: string, isLargeText = false): ContrastValidationResult { + const ratio = getContrastRatio(color1, color2); + const wcagLevel = getWCAGLevel(ratio, isLargeText); + const passesAA = ratio >= (isLargeText ? 3 : 4.5); + const passesAAA = ratio >= (isLargeText ? 4.5 : 7); + + // Visual assessment based on ratio + let visualAssessment: 'acceptable' | 'excellent' | 'good' | 'poor'; + let recommendation: string | undefined; + + if (ratio >= 7) { + visualAssessment = 'excellent'; + } else if (ratio >= 5) { + visualAssessment = 'good'; + } else if (ratio >= 4.5) { + visualAssessment = 'acceptable'; + recommendation = + 'Meets minimum WCAG AA but may be difficult to read, especially for body text. Consider using a darker/lighter color for better readability.'; + } else { + visualAssessment = 'poor'; + recommendation = + 'Does not meet WCAG AA standards. Text will be difficult to read. Strongly recommend using a color with better contrast.'; + } + + return { + color1, + color2, + ratio, + wcagLevel, + passesAA, + passesAAA, + isLargeText, + visualAssessment, + recommendation, + }; +} + +/** + * Validates multiple color combinations for WCAG compliance. + * + * @param combinations - Array of foreground/background pairs with optional label and large-text flag + * @returns Array of validation results, each including the input label if provided + */ +export function validateColorCombinations( + combinations: Array<{ + foreground: string; + background: string; + isLargeText?: boolean; + label?: string; + }>, +): Array { + return combinations.map((combo) => ({ + ...validateContrast(combo.foreground, combo.background, combo.isLargeText ?? false), + label: combo.label, + })); +} + +/** + * Formats a validation result as a human-readable string for display to users. + * + * @param result - Validation result, optionally with a label for the color combination + * @returns Multi-line string with contrast ratio, WCAG status, and recommendation (if any) + */ +export function formatValidationResult(result: ContrastValidationResult & {label?: string}): string { + const label = result.label ? `${result.label}: ` : ''; + const textType = result.isLargeText ? 'large text' : 'normal text'; + const wcagStatus = result.passesAAA ? '✅ AAA' : result.passesAA ? '✅ AA' : '❌ FAIL'; + + let output = `${label}${result.color1} on ${result.color2}\n`; + output += ` Contrast Ratio: ${result.ratio.toFixed(2)}:1\n`; + output += ` WCAG ${textType}: ${wcagStatus}\n`; + output += ` Visual Assessment: ${result.visualAssessment.toUpperCase()}\n`; + + if (result.recommendation) { + output += ` ⚠️ ${result.recommendation}\n`; + } + + return output; +} diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/color-mapping.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/color-mapping.ts new file mode 100644 index 00000000..4384d43e --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/color-mapping.ts @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Derives foreground/background color combinations from a color mapping and + * appends WCAG validation results to response text. + * + * @module tools/storefrontnext/site-theming/color-mapping + */ + +import {validateColorCombinations, formatValidationResult, isValidHex} from './color-contrast.js'; + +/** A foreground/background color pair for contrast validation */ +export type ColorCombination = { + foreground: string; + background: string; + label: string; + isLargeText?: boolean; +}; + +type ComboContext = {colorMapping: Record; lightBg: string; darkBg: string; buttonBg: string}; + +function tryTextCombo(key: string, color: string, keyLower: string, ctx: ComboContext): ColorCombination | null { + if (keyLower.includes('text') && keyLower.includes('light') && isValidHex(ctx.lightBg)) { + return {foreground: color, background: ctx.lightBg, label: `${key}: ${color} on light background (${ctx.lightBg})`}; + } + if (keyLower.includes('text') && keyLower.includes('dark') && isValidHex(ctx.darkBg)) { + return {foreground: color, background: ctx.darkBg, label: `${key}: ${color} on dark background (${ctx.darkBg})`}; + } + const isButtonText = keyLower === 'buttontext' || (keyLower.includes('button') && keyLower.includes('text')); + if (isButtonText && isValidHex(ctx.buttonBg)) { + return { + foreground: color, + background: ctx.buttonBg, + label: `${key}: ${color} on button background (${ctx.buttonBg})`, + }; + } + if (keyLower.includes('link') && isValidHex(ctx.lightBg)) { + return {foreground: color, background: ctx.lightBg, label: `${key}: ${color} on light background (${ctx.lightBg})`}; + } + return null; +} + +function tryBackgroundCombo(key: string, color: string, ctx: ComboContext): ColorCombination | null { + const foregroundKey = key.replace(/Background|Bg/i, 'Text') || key.replace(/Background|Bg/i, 'Foreground'); + const foreground = ctx.colorMapping[foregroundKey] || ctx.colorMapping[`${key.replace(/Background|Bg/i, '')}Text`]; + if (foreground?.startsWith('#') && isValidHex(foreground)) { + return {foreground, background: color, label: `${foregroundKey || 'text'} (${foreground}) on ${key} (${color})`}; + } + return null; +} + +function tryTextForegroundCombo( + key: string, + color: string, + keyLower: string, + ctx: ComboContext, +): ColorCombination | null { + const backgroundKey = key.replace(/Text|Foreground/i, 'Background') || key.replace(/Text|Foreground/i, 'Bg'); + let background = ctx.colorMapping[backgroundKey]; + let backgroundLabel = backgroundKey; + if (!background) { + background = keyLower.includes('button') ? ctx.buttonBg : keyLower.includes('dark') ? ctx.darkBg : ctx.lightBg; + backgroundLabel = keyLower.includes('button') + ? 'button background' + : keyLower.includes('dark') + ? 'dark background' + : 'light background'; + } + if (background?.startsWith('#') && isValidHex(background)) { + return {foreground: color, background, label: `${key} (${color}) on ${backgroundLabel} (${background})`}; + } + return null; +} + +function tryComboForEntry(key: string, color: string, ctx: ComboContext): ColorCombination | null { + const keyLower = key.toLowerCase(); + const textCombo = tryTextCombo(key, color, keyLower, ctx); + if (textCombo) return textCombo; + if (keyLower.includes('background') || keyLower.includes('bg')) return tryBackgroundCombo(key, color, ctx); + if (keyLower.includes('text') || keyLower.includes('foreground')) + return tryTextForegroundCombo(key, color, keyLower, ctx); + return null; +} + +/** + * Builds foreground/background color combinations from a semantic color mapping. + * Derives pairs for text-on-background, button text, links, etc. + */ +export function buildColorCombinations(colorMapping: Record): ColorCombination[] { + const ctx: ComboContext = { + colorMapping, + lightBg: colorMapping.lightBackground || colorMapping.background || '#FFFFFF', + darkBg: colorMapping.darkBackground || '#18181B', + buttonBg: colorMapping.buttonBackground || colorMapping.primary || '#0A2540', + }; + const combinations: ColorCombination[] = []; + + for (const [key, color] of Object.entries(colorMapping)) { + if (!color || !color.startsWith('#') || !isValidHex(color)) continue; + const combo = tryComboForEntry(key, color, ctx); + if (combo) combinations.push(combo); + } + + if (combinations.length === 0) { + const whiteBg = '#FFFFFF'; + const darkBgFallback = '#18181B'; + for (const [key, color] of Object.entries(colorMapping)) { + if (!color || !color.startsWith('#') || !isValidHex(color)) continue; + if (key.toLowerCase().includes('background') || key.toLowerCase().includes('bg')) continue; + combinations.push( + {foreground: color, background: whiteBg, label: `${key} (${color}) on white background`}, + {foreground: color, background: darkBgFallback, label: `${key} (${color}) on dark background`}, + ); + } + } + return combinations; +} + +/** + * Appends WCAG color contrast validation results to the given instructions string. + */ +export function appendValidationSection(internalInstructions: string, combinations: ColorCombination[]): string { + if (combinations.length === 0) { + return internalInstructions; + } + const results = validateColorCombinations(combinations); + let output = internalInstructions; + + for (const result of results) { + output += formatValidationResult(result); + output += '\n'; + } + + const hasIssues = results.some( + (r) => !r.passesAA || r.visualAssessment === 'poor' || r.visualAssessment === 'acceptable', + ); + if (hasIssues) { + output += '### ⚠️ VALIDATION SUMMARY\n\n'; + output += '**Issues found that should be addressed:**\n\n'; + for (const result of results.filter( + (r) => !r.passesAA || r.visualAssessment === 'poor' || r.visualAssessment === 'acceptable', + )) { + output += `- ${result.label || 'Color combination'}: ${result.recommendation || 'Needs improvement'}\n`; + } + output += '\n'; + output += + '**You MUST present these findings to the user BEFORE implementing and wait for their confirmation.**\n\n'; + } else { + output += '### ✅ VALIDATION SUMMARY\n\n'; + output += 'All color combinations meet WCAG AA standards and have good visual assessment.\n\n'; + } + return output; +} diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/guidance-merger.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/guidance-merger.ts new file mode 100644 index 00000000..69420ddc --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/guidance-merger.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Merges multiple ThemingGuidance objects from different theming files. + * + * @module tools/storefrontnext/site-theming/guidance-merger + */ + +import type {ThemingGuidance} from './theming-store.js'; + +function mergeWorkflows(guidanceArray: ThemingGuidance[]): ThemingGuidance['workflow'] { + const workflows = guidanceArray.filter((g) => g.workflow); + if (workflows.length === 0) return undefined; + const merged: NonNullable = { + steps: [], + extractionInstructions: workflows[0].workflow?.extractionInstructions, + preImplementationChecklist: workflows[0].workflow?.preImplementationChecklist, + }; + for (const g of workflows) { + if (g.workflow?.steps) merged.steps.push(...g.workflow.steps); + if (!merged.extractionInstructions && g.workflow?.extractionInstructions) { + merged.extractionInstructions = g.workflow.extractionInstructions; + } + if (!merged.preImplementationChecklist && g.workflow?.preImplementationChecklist) { + merged.preImplementationChecklist = g.workflow.preImplementationChecklist; + } + } + return merged; +} + +function mergeValidations(guidanceArray: ThemingGuidance[]): ThemingGuidance['validation'] { + const validations = guidanceArray.filter((g) => g.validation); + if (validations.length === 0) return undefined; + const joinField = (field: keyof NonNullable) => + validations + .map((g) => g.validation?.[field]) + .filter((x): x is string => typeof x === 'string') + .join('\n\n'); + return { + colorValidation: joinField('colorValidation'), + fontValidation: joinField('fontValidation'), + generalValidation: joinField('generalValidation'), + requirements: joinField('requirements'), + }; +} + +function buildQuestionMap(guidanceArray: ThemingGuidance[]): Map { + const questionMap = new Map(); + for (const guidance of guidanceArray) { + for (const q of guidance.questions) { + if (!questionMap.has(q.id)) questionMap.set(q.id, q); + } + } + return questionMap; +} + +function buildMergedMetadata(guidanceArray: ThemingGuidance[]): ThemingGuidance['metadata'] { + return { + filePath: guidanceArray.map((g) => g.metadata.filePath).join(', '), + fileName: guidanceArray.map((g) => g.metadata.fileName).join(', '), + loadedAt: new Date(), + }; +} + +/** + * Merges multiple ThemingGuidance objects into one. + * Questions are deduplicated by ID; guidelines, rules, workflows, and validations are combined. + */ +export function mergeGuidance(guidanceArray: ThemingGuidance[]): ThemingGuidance { + if (guidanceArray.length === 0) throw new Error('Cannot merge empty guidance array'); + if (guidanceArray.length === 1) return guidanceArray[0]; + + const questionMap = buildQuestionMap(guidanceArray); + return { + questions: [...questionMap.values()], + guidelines: guidanceArray.flatMap((g) => g.guidelines), + rules: guidanceArray.flatMap((g) => g.rules), + metadata: buildMergedMetadata(guidanceArray), + workflow: mergeWorkflows(guidanceArray), + validation: mergeValidations(guidanceArray), + }; +} diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/index.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/index.ts new file mode 100644 index 00000000..51e15eb6 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/index.ts @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Site Theming tool for Storefront Next. + * + * Provides theming guidelines, guided questions, and automatic WCAG color contrast + * validation. Call this tool first when users request brand colors or theme changes. + * + * @module tools/storefrontnext/site-theming + */ + +import {z} from 'zod'; +import type {McpTool} from '../../../utils/index.js'; +import type {Services} from '../../../services.js'; +import {createToolAdapter, textResult, errorResult, type ToolExecutionContext} from '../../adapter.js'; +import {siteThemingStore, type ThemingGuidance} from './theming-store.js'; +import {mergeGuidance} from './guidance-merger.js'; +import {generateResponse} from './response-builder.js'; +import type {SiteThemingInput} from './types.js'; + +export type { + ColorEntry, + ColorMapping, + CollectedAnswers, + ConversationContext, + FontEntry, + SiteThemingInput, +} from './types.js'; + +/** + * Creates the site theming MCP tool for Storefront Next. + * + * The tool guides theming changes (colors, fonts, visual styling) and validates color + * combinations for WCAG accessibility. It must be called before implementing any + * theming changes. + * + * @param loadServices - Function that loads configuration and returns Services instance + * @returns The configured MCP tool + */ +export function createSiteThemingTool(loadServices: () => Services): McpTool { + return createToolAdapter( + { + name: 'storefront_next_site_theming', + description: + '⚠️ MANDATORY: Call this tool FIRST before implementing any theming changes. ' + + 'Provides theming guidelines, questions, and automatic validation. ' + + 'CRITICAL RULES: Call immediately when user requests theming (even if colors/fonts provided). ' + + 'NEVER implement without calling this tool first. NEVER skip question-answer workflow. ' + + 'MUST ask questions and WAIT for responses. ' + + 'VALIDATION GATE: After collecting answers, call tool again with colorMapping to trigger validation. ' + + 'DEFAULT FILES: theming-questions, theming-validation, theming-accessibility. ' + + 'Use fileKeys to add custom files. ' + + 'WORKFLOW: Call tool → Ask questions → Call with colorMapping (validation) → Present findings → Wait confirmation → Implement', + toolsets: ['STOREFRONTNEXT'], + isGA: false, + requiresInstance: false, + inputSchema: { + fileKeys: z + .array(z.string()) + .optional() + .describe( + 'Array of file keys to add to the default set. If provided, guidance from all specified files will be merged with defaults: theming-questions, theming-validation, theming-accessibility. Available keys can be listed by calling the tool without parameters.', + ), + conversationContext: z + .object({ + currentStep: z + .string() + .optional() + .describe('Current step in the theming conversation (e.g., "collecting-colors", "collecting-fonts")'), + collectedAnswers: z + .record(z.string(), z.any()) + .optional() + .describe('Previously collected answers from the user'), + questionsAsked: z.array(z.string()).optional().describe('List of questions that have already been asked'), + }) + .optional() + .describe('Context from previous conversation rounds'), + }, + async execute(args: SiteThemingInput, context: ToolExecutionContext) { + siteThemingStore.initialize(context.services.resolveWithProjectDirectory()); + + const defaultFileKeys = ['theming-questions', 'theming-validation', 'theming-accessibility']; + let fileKeys: string[]; + + if (args.fileKeys && args.fileKeys.length > 0) { + const allKeys = [...defaultFileKeys, ...args.fileKeys]; + fileKeys = [...new Set(allKeys)]; + } else { + fileKeys = defaultFileKeys; + } + + const hasContext = + args.conversationContext && + (args.conversationContext.collectedAnswers || + args.conversationContext.questionsAsked || + args.conversationContext.currentStep); + + if (!args.fileKeys && !hasContext) { + const availableKeys = siteThemingStore.getKeys(); + if (availableKeys.length === 0) { + return { + text: 'No theming files have been loaded. Please ensure theming files are configured at server startup.', + isError: false, + }; + } + + return { + text: `Available theming files:\n\n${availableKeys.map((key) => `- ${key}`).join('\n')}\n\nDefault files (always used): theming-questions, theming-validation, theming-accessibility\n\nUse the \`fileKeys\` parameter to add additional files. User-provided files are merged with the defaults.`, + isError: false, + }; + } + + const guidanceArray: ThemingGuidance[] = []; + const missingKeys: string[] = []; + + for (const key of fileKeys) { + const guidance = siteThemingStore.get(key); + if (guidance) { + guidanceArray.push(guidance); + } else { + missingKeys.push(key); + } + } + + if (guidanceArray.length === 0 || missingKeys.length > 0) { + const availableKeys = siteThemingStore.getKeys(); + const keysList = fileKeys.length === 1 ? `key "${fileKeys[0]}"` : `keys: ${fileKeys.join(', ')}`; + const missingList = missingKeys.length === 1 ? `"${missingKeys[0]}"` : missingKeys.join(', '); + return { + text: `Theming file(s) with ${keysList} not found.\n\nMissing: ${missingList}\nAvailable keys: ${availableKeys.join(', ')}\n\nFiles are loaded at server startup. To add more files, configure them via the THEMING_FILES environment variable or update the server initialization.`, + isError: true, + }; + } + + const guidance = fileKeys.length > 1 ? mergeGuidance(guidanceArray) : guidanceArray[0]; + const response = generateResponse(guidance, args.conversationContext); + + return { + text: response, + isError: false, + }; + }, + formatOutput: (output: {text: string; isError?: boolean}) => + output.isError ? errorResult(output.text) : textResult(output.text), + }, + loadServices, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/response-builder.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/response-builder.ts new file mode 100644 index 00000000..73656e41 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/response-builder.ts @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Builds the theming tool response from guidance and conversation context. + * + * @module tools/storefrontnext/site-theming/response-builder + */ + +import type {ThemingGuidance} from './theming-store.js'; +import type {CollectedAnswers, ColorEntry, ConversationContext, FontEntry} from './types.js'; +import {buildColorCombinations, appendValidationSection} from './color-mapping.js'; + +function isComponentScopeQuestion(question: ThemingGuidance['questions'][0]): boolean { + const questionLower = question.question.toLowerCase(); + return ( + questionLower.includes('which components') || + questionLower.includes('component scope') || + questionLower.includes('component group') + ); +} + +/** + * Returns questions relevant to the current conversation state, filtered and sorted. + */ +export function getRelevantQuestions( + guidance: ThemingGuidance, + context?: ConversationContext, +): ThemingGuidance['questions'] { + if (!context || !context.questionsAsked || context.questionsAsked.length === 0) { + return guidance.questions + .filter((q) => !isComponentScopeQuestion(q)) + .sort((a, b) => { + if (a.required !== b.required) { + return a.required ? -1 : 1; + } + const categoryOrder = {colors: 0, typography: 1, general: 2}; + return ( + (categoryOrder[a.category as keyof typeof categoryOrder] || 2) - + (categoryOrder[b.category as keyof typeof categoryOrder] || 2) + ); + }); + } + + const askedIds = new Set(context.questionsAsked); + const remaining = guidance.questions.filter((q) => !askedIds.has(q.id) && !isComponentScopeQuestion(q)); + + if (context.collectedAnswers) { + const followUps: ThemingGuidance['questions'] = []; + for (const q of remaining) { + if (q.followUpQuestions && context.collectedAnswers?.[q.id]) { + for (const [index, followUp] of q.followUpQuestions.entries()) { + followUps.push({ + id: `${q.id}-followup-${index}`, + question: followUp, + category: q.category, + required: false, + }); + } + } + } + return [...remaining, ...followUps]; + } + + return remaining; +} + +export function hasProvidedThemingInfo(context?: ConversationContext): boolean { + if (!context?.collectedAnswers) { + return false; + } + const collectedAnswers = context.collectedAnswers; + const hasColors = Boolean(collectedAnswers.colors && Array.isArray(collectedAnswers.colors)); + const hasFonts = Boolean(collectedAnswers.fonts && Array.isArray(collectedAnswers.fonts)); + return hasColors || hasFonts; +} + +function buildExtractionResponse(extractionInstructions: string): string { + const internal = + '# ⚠️ MANDATORY: Extract User-Provided Theming Information\n\n## 🚨 CRITICAL: Information Extraction Required\n\n' + + extractionInstructions; + const user = + "I need to extract the theming information from your input first.\n\nLet me review what you've shared and structure it properly, then I'll proceed with clarifying questions.\n\n"; + return `${internal}\n\n---\n\n# USER-FACING RESPONSE (What to say to the user):\n\n${user}`; +} + +function appendValidationInstructions(out: string, validation: NonNullable): string { + let s = + out + + '## ⚠️ MANDATORY: Input Validation\n\n**BEFORE implementing, you MUST validate ALL user-provided inputs:**\n\n'; + if (validation.colorValidation) + s += + '**A. Color Combination Validation (MANDATORY if colors provided):**\n\n' + validation.colorValidation + '\n\n'; + if (validation.fontValidation) + s += '**B. Font Validation (MANDATORY if fonts provided):**\n\n' + validation.fontValidation + '\n\n'; + if (validation.generalValidation) s += '**C. General Input Validation:**\n\n' + validation.generalValidation + '\n\n'; + if (validation.requirements) s += '**IMPORTANT:**\n\n' + validation.requirements + '\n\n'; + return s; +} + +function appendCriticalAndRules(out: string, guidance: ThemingGuidance): string { + let s = out; + const critical = guidance.guidelines.filter((g) => g.critical); + if (critical.length > 0) { + s += '## ⚠️ Critical Guidelines (INTERNAL - Follow these rules)\n\n'; + for (const g of critical) s += `### ${g.title}\n\n${g.content}\n\n`; + } + if (guidance.rules.length > 0) { + s += '## Rules to Follow (INTERNAL)\n\n'; + const doRules = guidance.rules.filter((r) => r.type === 'do'); + const dontRules = guidance.rules.filter((r) => r.type === 'dont'); + if (doRules.length > 0) { + s += '### ✅ What TO Do:\n\n'; + for (const r of doRules) s += `- ${r.description}\n`; + s += '\n'; + } + if (dontRules.length > 0) { + s += '### ❌ What NOT to Do:\n\n'; + for (const r of dontRules) s += `- ${r.description}\n`; + s += '\n'; + } + } + return s; +} + +function extractColorsFromArray(colors: unknown): string[] { + if (!colors || !Array.isArray(colors)) return []; + const out: string[] = []; + for (const color of colors as ColorEntry[]) { + if (color.hex && color.type) out.push(`${color.hex} (${color.type})`); + else if (color.hex) out.push(color.hex); + } + return out; +} + +function extractFontsFromArray(fonts: unknown): string[] { + if (!fonts || !Array.isArray(fonts)) return []; + const out: string[] = []; + for (const font of fonts as FontEntry[]) { + if (font.name) out.push(font.type ? `${font.name} (${font.type})` : font.name); + } + return out; +} + +function extractColorFromValue(value: unknown): null | string { + if (typeof value === 'string') return value; + if (typeof value === 'object' && value && 'hex' in value) { + const v = value as ColorEntry; + if (v.hex === undefined) return null; + return v.type ? `${v.hex} (${v.type})` : v.hex; + } + return null; +} + +function extractFontFromValue(value: unknown): null | string { + if (typeof value === 'string') return value; + if (typeof value === 'object' && value && 'name' in value) { + const v = value as FontEntry; + if (v.name === undefined) return null; + return v.type ? `${v.name} (${v.type})` : v.name; + } + return null; +} + +function shouldSkipKeyForOtherInfo(key: string, lowerKey: string): boolean { + if (key === 'colors' || key === 'fonts') return true; + if (lowerKey.includes('question') || lowerKey.includes('step')) return true; + if (lowerKey.includes('color') || lowerKey.includes('font')) return true; + return false; +} + +function extractOtherInfoFromEntries(collectedAnswers: Record): string[] { + const otherInfo: string[] = []; + for (const key of Object.keys(collectedAnswers)) { + const lowerKey = key.toLowerCase(); + if (shouldSkipKeyForOtherInfo(key, lowerKey)) continue; + const value = collectedAnswers[key]; + if (value === null || value === undefined) continue; + otherInfo.push(`${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`); + } + return otherInfo; +} + +function collectUserInfo(collectedAnswers: CollectedAnswers): { + colorsInfo: string[]; + fontsInfo: string[]; + otherInfo: string[]; +} { + const colorsInfo = [...extractColorsFromArray(collectedAnswers.colors)]; + const fontsInfo = [...extractFontsFromArray(collectedAnswers.fonts)]; + + for (const key of Object.keys(collectedAnswers)) { + const lowerKey = key.toLowerCase(); + const value = collectedAnswers[key]; + if (key === 'colors' || key === 'fonts' || lowerKey.includes('question') || lowerKey.includes('step')) continue; + if (lowerKey.includes('color') && !key.includes('colors')) { + const c = extractColorFromValue(value); + if (c) colorsInfo.push(c); + continue; + } + if (lowerKey.includes('font') && !key.includes('fonts')) { + const f = extractFontFromValue(value); + if (f) fontsInfo.push(f); + continue; + } + } + + const otherInfo = extractOtherInfoFromEntries(collectedAnswers); + return {colorsInfo, fontsInfo, otherInfo}; +} + +function buildUserInfoSection(info: {colorsInfo: string[]; fontsInfo: string[]; otherInfo: string[]}): string { + const {colorsInfo, fontsInfo, otherInfo} = info; + if (colorsInfo.length === 0 && fontsInfo.length === 0 && otherInfo.length === 0) { + return 'Following the theming workflow. I need a few clarifications before implementing.\n\n'; + } + let s = "## Information You've Provided\n\n"; + if (colorsInfo.length > 0) { + s += '### Colors:\n'; + for (const c of colorsInfo) s += `- ${c}\n`; + s += '\n'; + } + if (fontsInfo.length > 0) { + s += '### Fonts:\n'; + for (const f of fontsInfo) s += `- ${f}\n`; + s += '\n'; + } + if (otherInfo.length > 0) { + s += '### Other Information:\n'; + for (const o of otherInfo) s += `- ${o}\n`; + s += '\n'; + } + return ( + s + + "I've noted the information above. Before implementing, I need a few clarifications to ensure everything is set up correctly.\n\n" + ); +} + +function buildInternalInstructionsBase(guidance: ThemingGuidance, context?: ConversationContext): string { + let s = '# ⚠️ MANDATORY: Site Theming Guidelines and Questions\n\n## 🚨 CRITICAL: Read This First\n\n'; + if (guidance.workflow?.steps && guidance.workflow.steps.length > 0) { + s += '**YOU MUST FOLLOW THIS WORKFLOW - NO EXCEPTIONS:**\n\n'; + for (const [i, step] of guidance.workflow.steps.entries()) s += `${i + 1}. ${step}\n`; + s += '\n**VIOLATION OF THIS WORKFLOW IS A CRITICAL ERROR.**\n\n'; + } + if (guidance.validation) s = appendValidationInstructions(s, guidance.validation); + const colorMapping = context?.collectedAnswers?.colorMapping; + if (colorMapping && Object.keys(colorMapping).length > 0) { + s += + '## 🎨 AUTOMATED COLOR VALIDATION RESULTS\n\n**The following validation has been automatically performed using built-in contrast calculation:**\n\n'; + s = appendValidationSection(s, buildColorCombinations(colorMapping)); + } + return appendCriticalAndRules(s, guidance); +} + +function appendQuestionsToResponse( + internal: string, + user: string, + nextQuestions: ThemingGuidance['questions'], + relevantQuestions: ThemingGuidance['questions'], +): {internal: string; user: string} { + let userOut = user + '## Questions\n\n'; + const categories = [ + {category: 'colors', title: 'Color Questions'}, + {category: 'typography', title: 'Font Questions'}, + {category: 'general', title: 'General Questions'}, + ] as const; + for (const {category, title} of categories) { + const qs = nextQuestions.filter((q) => q.category === category); + if (qs.length > 0) { + userOut += `### ${title}\n\n`; + for (const [i, q] of qs.entries()) userOut += `**Question ${i + 1}**: ${q.question}\n\n`; + } + } + const remaining = relevantQuestions.length - nextQuestions.length; + if (remaining > 0) + userOut += `\n_Note: I have ${remaining} more question${remaining > 1 ? 's' : ''} to ask after you answer these._\n\n`; + userOut += 'Please answer these questions so I can proceed with the implementation.\n\n'; + + let internalOut = + internal + + "## Questions to Ask the User\n\n**IMPORTANT**: Ask these questions ONE AT A TIME and WAIT for the user's response before proceeding.\n\n**CRITICAL RULE**: NEVER implement changes after asking questions without waiting for the user's response.\n\n"; + internalOut += `**You have ${relevantQuestions.length} total questions to ask. Show ${nextQuestions.length} now, then continue with the rest after user responds.**\n\n`; + for (const [i, q] of nextQuestions.entries()) { + internalOut += `### Question ${i + 1} (${q.category}): ${q.id}\n\n${q.question}\n\n`; + if (q.required) internalOut += '**Required**: Yes\n\n'; + } + return {internal: internalOut, user: userOut}; +} + +function appendReadyOrWarningToResponse( + internal: string, + user: string, + guidance: ThemingGuidance, + context: NonNullable, +): {internal: string; user: string} { + const required = guidance.questions.filter((q) => q.required); + const answered = required.filter((q) => context.collectedAnswers?.[q.id] !== undefined); + if (answered.length < required.length) { + return { + internal: + internal + + '## ⚠️ WARNING: Not all required questions have been answered!\n\n**DO NOT implement yet. Continue asking questions.**\n\n', + user: user + 'I still need answers to some required questions before I can proceed.\n\n', + }; + } + let internalOut = internal; + if (guidance.workflow?.preImplementationChecklist) { + internalOut += + '## ⚠️ MANDATORY PRE-IMPLEMENTATION CHECKLIST\n\n' + guidance.workflow.preImplementationChecklist + '\n\n'; + } + const userOut = + user + + '## Ready to Implement\n\nI have collected all necessary information. Before implementing, I will validate all provided inputs (colors, fonts, etc.) for accessibility, availability, and best practices.\n\n'; + return {internal: internalOut, user: userOut}; +} + +/** + * Generates the full theming tool response from guidance and conversation context. + */ +export function generateResponse(guidance: ThemingGuidance, context?: ConversationContext): string { + const isFirstCall = !context || !context.questionsAsked || context.questionsAsked.length === 0; + if (isFirstCall && !hasProvidedThemingInfo(context) && guidance.workflow?.extractionInstructions) { + return buildExtractionResponse(guidance.workflow.extractionInstructions); + } + + const relevantQuestions = getRelevantQuestions(guidance, context); + const questionLimit = !context || context.questionsAsked?.length === 0 ? 5 : 3; + const nextQuestions = relevantQuestions.slice(0, questionLimit); + + let internalInstructions = buildInternalInstructionsBase(guidance, context); + const info = context?.collectedAnswers + ? collectUserInfo(context.collectedAnswers) + : {colorsInfo: [], fontsInfo: [], otherInfo: []}; + let userResponse = buildUserInfoSection(info); + + if (nextQuestions.length > 0) { + const appended = appendQuestionsToResponse(internalInstructions, userResponse, nextQuestions, relevantQuestions); + internalInstructions = appended.internal; + userResponse = appended.user; + } else if (context?.collectedAnswers && Object.keys(context.collectedAnswers).length > 0) { + const appended = appendReadyOrWarningToResponse(internalInstructions, userResponse, guidance, context); + internalInstructions = appended.internal; + userResponse = appended.user; + } + + return `${internalInstructions}\n\n---\n\n# USER-FACING RESPONSE (What to say to the user):\n\n${userResponse}`; +} diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/theming-store.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/theming-store.ts new file mode 100644 index 00000000..15d9bb0f --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/theming-store.ts @@ -0,0 +1,563 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {readFileSync, existsSync} from 'node:fs'; +import {join, dirname, basename} from 'node:path'; +import {createRequire} from 'node:module'; +import {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; + +// Resolve the site-theming content directory from the package root +const require = createRequire(import.meta.url); +const packageRoot = dirname(require.resolve('@salesforce/b2c-dx-mcp/package.json')); +const SITE_THEMING_CONTENT_DIR = join(packageRoot, 'content', 'site-theming'); + +const logger = getLogger(); + +export interface ThemingGuidance { + questions: Array<{ + id: string; + question: string; + category: string; + required: boolean; + followUpQuestions?: string[]; + }>; + guidelines: Array<{ + category: string; + title: string; + content: string; + critical: boolean; + }>; + rules: Array<{ + type: 'do' | 'dont'; + description: string; + examples?: string[]; + }>; + workflow?: { + steps: string[]; + extractionInstructions?: string; + preImplementationChecklist?: string; + }; + validation?: { + colorValidation?: string; + fontValidation?: string; + generalValidation?: string; + requirements?: string; + }; + metadata: { + filePath: string; + fileName: string; + loadedAt: Date; + }; +} + +type ParsedQuestion = {id: string; question: string; category: string; required: boolean}; + +function parseWorkflowSection(content: string): ThemingGuidance['workflow'] { + const workflowMatch = content.match(/##\s*🔄\s*WORKFLOW[^#]*(?=##|$)/is); + if (!workflowMatch) return undefined; + + const workflowContent = workflowMatch[0].replace(/##\s*🔄\s*WORKFLOW[^\n]*\n?/i, '').trim(); + const stepMatches = workflowContent.match(/^\d+\.\s+(.+)$/gm); + const steps = stepMatches ? stepMatches.map((step) => step.replace(/^\d+\.\s+/, '').trim()) : []; + + const extractionMatch = workflowContent.match(/###\s*📝\s*EXTRACTION[^#]*(?=###|$)/is); + const extractionInstructions = extractionMatch + ? extractionMatch[0].replace(/###\s*📝\s*EXTRACTION[^\n]*\n?/i, '').trim() + : undefined; + + const checklistMatch = workflowContent.match(/###\s*✅\s*PRE-IMPLEMENTATION[^#]*(?=###|$)/is); + const preImplementationChecklist = checklistMatch + ? checklistMatch[0].replace(/###\s*✅\s*PRE-IMPLEMENTATION[^\n]*\n?/i, '').trim() + : undefined; + + if (steps.length > 0 || extractionInstructions || preImplementationChecklist) { + return {steps, extractionInstructions, preImplementationChecklist}; + } + return undefined; +} + +function parseValidationSection(content: string): ThemingGuidance['validation'] { + const validationMatch = content.match(/##\s*✅\s*VALIDATION[^#]*(?=##|$)/is); + if (!validationMatch) return undefined; + + const validationContent = validationMatch[0].replace(/##\s*✅\s*VALIDATION[^\n]*\n?/i, '').trim(); + + const colorValidationMatch = validationContent.match(/###\s*A\.\s*Color[^#]*(?=###|$)/is); + const colorValidation = colorValidationMatch + ? colorValidationMatch[0].replace(/###\s*A\.\s*Color[^\n]*\n?/i, '').trim() + : undefined; + + const fontValidationMatch = validationContent.match(/###\s*B\.\s*Font[^#]*(?=###|$)/is); + const fontValidation = fontValidationMatch + ? fontValidationMatch[0].replace(/###\s*B\.\s*Font[^\n]*\n?/i, '').trim() + : undefined; + + const generalValidationMatch = validationContent.match(/###\s*C\.\s*General[^#]*(?=###|$)/is); + const generalValidation = generalValidationMatch + ? generalValidationMatch[0].replace(/###\s*C\.\s*General[^\n]*\n?/i, '').trim() + : undefined; + + const requirementsMatch = validationContent.match(/###\s*IMPORTANT[^#]*(?=###|$)/is); + const requirements = requirementsMatch + ? requirementsMatch[0].replace(/###\s*IMPORTANT[^\n]*\n?/i, '').trim() + : undefined; + + if (colorValidation || fontValidation || generalValidation || requirements) { + return {colorValidation, fontValidation, generalValidation, requirements}; + } + return undefined; +} + +function extractRuleItems(content: string, pattern: RegExp, type: 'do' | 'dont'): ThemingGuidance['rules'] { + const rules: ThemingGuidance['rules'] = []; + let match; + while ((match = pattern.exec(content)) !== null) { + const items = match[1] + .split('\n') + .filter((line) => line.trim().startsWith('-')) + .map((line) => line.replace(/^-\s*/, '').trim()); + for (const item of items) { + rules.push({type, description: item}); + } + } + return rules; +} + +function generateColorQuestions( + guidance: ThemingGuidance, + content: string, + generateId: (cat: string) => string, +): ParsedQuestion[] { + const allGuidelines = guidance.guidelines; + const allRules = guidance.rules; + const colorGuidelines = allGuidelines.filter( + (g) => + g.content.toLowerCase().includes('color') || + g.content.toLowerCase().includes('hex') || + g.title.toLowerCase().includes('color'), + ); + const colorRules = allRules.filter( + (r) => + r.description.toLowerCase().includes('color') || + r.description.toLowerCase().includes('background-color') || + r.description.toLowerCase().includes('border-color'), + ); + if (colorGuidelines.length === 0 && colorRules.length === 0) return []; + + const questions: ParsedQuestion[] = []; + if ( + allGuidelines.some( + (g) => g.content.toLowerCase().includes('exact hex') || g.content.toLowerCase().includes('hex code'), + ) + ) { + questions.push({ + id: generateId('color'), + question: 'What are the exact hex color values you want to use? (Please provide hex codes, e.g., #635BFF)', + category: 'colors', + required: true, + }); + } + if ( + allGuidelines.some( + (g) => + g.content.toLowerCase().includes('color type mapping') || + g.content.toLowerCase().includes('color mapping') || + g.content.toLowerCase().includes('primary vs secondary') || + g.content.toLowerCase().includes('brand vs accent'), + ) + ) { + questions.push({ + id: generateId('color'), + question: + 'How should these colors be mapped? (e.g., which color should be primary vs secondary, brand vs accent)', + category: 'colors', + required: true, + }); + } + if ( + allGuidelines.some( + (g) => + g.content.toLowerCase().includes('color combinations') || g.content.toLowerCase().includes('propose color'), + ) + ) { + questions.push( + { + id: generateId('color'), + question: 'Which color should be used for primary actions vs secondary actions?', + category: 'colors', + required: false, + }, + { + id: generateId('color'), + question: 'What should be the hover state colors?', + category: 'colors', + required: false, + }, + ); + } + if (content.toLowerCase().includes('dark') && content.toLowerCase().includes('light')) { + questions.push({ + id: generateId('color'), + question: 'Do you want to support both light and dark themes? If yes, what colors should be used for each?', + category: 'colors', + required: false, + }); + } + return questions; +} + +function generateFontQuestions(guidance: ThemingGuidance, generateId: (cat: string) => string): ParsedQuestion[] { + const allGuidelines = guidance.guidelines; + const fontGuidelines = allGuidelines.filter( + (g) => + g.content.toLowerCase().includes('font') || + g.content.toLowerCase().includes('typography') || + g.title.toLowerCase().includes('font'), + ); + const fontRules = guidance.rules.filter( + (r) => + r.description.toLowerCase().includes('font') || + r.description.toLowerCase().includes('font-weight') || + r.description.toLowerCase().includes('font-size'), + ); + if (fontGuidelines.length === 0 && fontRules.length === 0) return []; + + const questions: ParsedQuestion[] = []; + if ( + allGuidelines.some( + (g) => g.content.toLowerCase().includes('exact font') || g.content.toLowerCase().includes('font name'), + ) + ) { + questions.push({ + id: generateId('font'), + question: 'What is the exact font family name you want to use? (e.g., "sohne-var")', + category: 'typography', + required: true, + }); + } + if ( + allGuidelines.some( + (g) => + g.content.toLowerCase().includes('font availability') || + g.content.toLowerCase().includes('custom font') || + g.content.toLowerCase().includes('google fonts'), + ) + ) { + questions.push({ + id: generateId('font'), + question: 'Is this a custom font that needs to be loaded, or should I use a Google Fonts equivalent?', + category: 'typography', + required: true, + }); + } + if ( + allGuidelines.some( + (g) => + g.content.toLowerCase().includes('headings and body') || + g.content.toLowerCase().includes('font apply') || + g.content.toLowerCase().includes('font usage'), + ) + ) { + questions.push({ + id: generateId('font'), + question: 'Should this font apply to both headings and body text, or just one?', + category: 'typography', + required: false, + }); + } + return questions; +} + +function generateLayoutQuestions( + guidance: ThemingGuidance, + content: string, + generateId: (cat: string) => string, +): ParsedQuestion[] { + const layoutGuidelines = guidance.guidelines.filter( + (g) => + g.content.toLowerCase().includes('layout') || + g.content.toLowerCase().includes('positioning') || + g.title.toLowerCase().includes('layout'), + ); + if (layoutGuidelines.length === 0) return []; + const allowsLayout = + content.toLowerCase().includes('layout changes') && content.toLowerCase().includes('explicitly requested'); + if (!allowsLayout) return []; + + return [ + { + id: generateId('general'), + question: 'Do you need any layout changes, or only visual styling (colors, fonts, etc.)?', + category: 'general', + required: false, + }, + ]; +} + +function generateQuestionsFromGuidelines(guidance: ThemingGuidance, content: string): ParsedQuestion[] { + let counter = 0; + const generateId = (cat: string) => `${cat}-${++counter}`; + return [ + ...generateColorQuestions(guidance, content, generateId), + ...generateFontQuestions(guidance, generateId), + ...generateLayoutQuestions(guidance, content, generateId), + ]; +} + +function extractQuestionLines(content: string): string[] { + const lines = content.split('\n').filter((line) => { + const t = line.trim(); + return t.endsWith('?') && t.length > 10; + }); + return lines + .map((line) => + line + .replace(/^[-*•]\s*/, '') + .replace(/^\d+\.\s*/, '') + .trim(), + ) + .filter((c) => c.length > 10 && c.endsWith('?')); +} + +function mergeQuestionsIntoGuidance( + guidance: ThemingGuidance, + content: string, + generated: ParsedQuestion[], + extracted: string[], +): void { + const colorQs = extracted.filter( + (q) => + q.toLowerCase().includes('color') || + q.toLowerCase().includes('primary') || + q.toLowerCase().includes('accent') || + q.toLowerCase().includes('brand') || + q.toLowerCase().includes('theme'), + ); + const fontQs = extracted.filter((q) => q.toLowerCase().includes('font') || q.toLowerCase().includes('typography')); + const generalQs = extracted.filter( + (q) => + !q.toLowerCase().includes('color') && + !q.toLowerCase().includes('primary') && + !q.toLowerCase().includes('accent') && + !q.toLowerCase().includes('brand') && + !q.toLowerCase().includes('theme') && + !q.toLowerCase().includes('font') && + !q.toLowerCase().includes('typography'), + ); + + let counter = generated.length; + const genId = (cat: string) => `${cat}-${++counter}`; + + guidance.questions.push(...generated); + for (const [i, q] of colorQs.entries()) { + guidance.questions.push({ + id: genId('color'), + question: q, + category: 'colors', + required: i === 0 && generated.filter((x) => x.category === 'colors').length === 0, + }); + } + for (const [i, q] of fontQs.entries()) { + guidance.questions.push({ + id: genId('font'), + question: q, + category: 'typography', + required: i === 0 && generated.filter((x) => x.category === 'typography').length === 0, + }); + } + for (const q of generalQs) { + guidance.questions.push({id: genId('general'), question: q, category: 'general', required: false}); + } + + if (guidance.questions.length === 0) { + const lower = content.toLowerCase(); + if (lower.includes('color')) { + guidance.questions.push({ + id: 'color-primary', + question: 'What colors should be used for theming? (Please provide hex codes)', + category: 'colors', + required: true, + }); + } + if (lower.includes('font') || lower.includes('typography')) { + guidance.questions.push({ + id: 'font-family', + question: 'What font family should be used? (Please provide exact font name)', + category: 'typography', + required: true, + }); + } + } +} + +/** + * Parses an .md/.mdc file and extracts theming questions and guidelines + */ +function parseThemingMDC(content: string, filePath: string): ThemingGuidance { + const guidance: ThemingGuidance = { + questions: [], + guidelines: [], + rules: [], + metadata: { + filePath, + fileName: basename(filePath), + loadedAt: new Date(), + }, + }; + + const workflow = parseWorkflowSection(content); + if (workflow) guidance.workflow = workflow; + + const validation = parseValidationSection(content); + if (validation) guidance.validation = validation; + + const criticalSections = content.match(/##\s*⚠️\s*CRITICAL[^#]*/gi) || []; + const specificationSections = content.match(/##\s*📋[^#]*/gi) || []; + + for (const section of criticalSections) { + const titleMatch = section.match(/##\s*⚠️\s*CRITICAL:\s*(.+?)\n/); + const title = titleMatch ? titleMatch[1].trim() : 'Critical Rule'; + guidance.guidelines.push({ + category: 'critical', + title, + content: section.replace(/##\s*⚠️\s*CRITICAL[^\n]*\n/, '').trim(), + critical: true, + }); + } + + for (const section of specificationSections) { + const titleMatch = section.match(/##\s*📋\s*(.+?)\n/); + const title = titleMatch ? titleMatch[1].trim() : 'Specification Rule'; + guidance.guidelines.push({ + category: 'specification', + title, + content: section.replace(/##\s*📋[^\n]*\n/, '').trim(), + critical: false, + }); + } + + const doRules = extractRuleItems(content, /###\s*What\s+TO\s+Change:([^#]*)/gi, 'do'); + const dontRules = extractRuleItems(content, /###\s*What\s+NOT\s+to\s+Change:([^#]*)/gi, 'dont'); + guidance.rules.push(...doRules, ...dontRules); + + const generatedQuestions = generateQuestionsFromGuidelines(guidance, content); + const extractedQuestions = extractQuestionLines(content); + mergeQuestionsIntoGuidance(guidance, content, generatedQuestions, extractedQuestions); + + return guidance; +} + +/** + * Theming Data Store + * Loads and caches theming guidance from .md/.mdc files + */ +export interface InitializeOptions { + /** Override content directory for default files (used in tests). */ + contentDirOverride?: string; +} + +class ThemingStore { + private initializedForRoot: null | string = null; + private store: Map = new Map(); + + get(fileKey: string): ThemingGuidance | undefined { + return this.store.get(fileKey); + } + + getKeys(): string[] { + return [...this.store.keys()]; + } + + has(fileKey: string): boolean { + return this.store.has(fileKey); + } + + /** + * Initialize store with default files from content/site-theming. + * Uses workspaceRoot for THEMING_FILES env paths (relative to project). + * Skips re-loading when already initialized for the same root. + */ + initialize(workspaceRoot?: string, options?: InitializeOptions): void { + const root = workspaceRoot ?? process.cwd(); + if (this.initializedForRoot === root) { + return; + } + if (this.initializedForRoot !== null) { + this.store.clear(); + } + this.initializedForRoot = root; + + const contentDir = options?.contentDirOverride ?? SITE_THEMING_CONTENT_DIR; + const defaultFileKeys = ['theming-questions', 'theming-validation', 'theming-accessibility']; + const extensions = ['.md', '.mdc']; + + for (const key of defaultFileKeys) { + let filePath: null | string = null; + for (const ext of extensions) { + const candidate = join(contentDir, `${key}${ext}`); + if (existsSync(candidate)) { + filePath = candidate; + break; + } + } + if (filePath) { + try { + this.loadFile(key, filePath); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`Could not load theming file ${filePath}: ${errorMessage}`); + } + } + } + + const themingFilesEnv = process.env.THEMING_FILES; + if (themingFilesEnv) { + try { + this.loadThemingFilesFromEnv(themingFilesEnv, root); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`Could not parse THEMING_FILES environment variable: ${errorMessage}`); + } + } + } + + loadFile(fileKey: string, filePath: string): void { + try { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + const content = readFileSync(filePath, 'utf8'); + const guidance = parseThemingMDC(content, filePath); + this.store.set(fileKey, guidance); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load theming file ${filePath}: ${errorMessage}`); + } + } + + private loadThemingFilesFromEnv(envValue: string, root: string): void { + const files = JSON.parse(envValue) as Array<{key: string; path: string}>; + for (const {key, path: filePath} of files) { + const fullPath = filePath.startsWith('/') ? filePath : join(root, filePath); + this.tryLoadEnvFile(key, fullPath); + } + } + + private tryLoadEnvFile(key: string, fullPath: string): void { + if (!existsSync(fullPath)) { + logger.warn(`Theming file not found: ${fullPath} (key: ${key})`); + return; + } + try { + this.loadFile(key, fullPath); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`Could not load theming file ${fullPath} (key: ${key}): ${errorMessage}`); + } + } +} + +export const siteThemingStore = new ThemingStore(); diff --git a/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/types.ts b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/types.ts new file mode 100644 index 00000000..d9fd83f6 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/storefrontnext/site-theming/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Shared types for the site theming tool. + * + * @module tools/storefrontnext/site-theming/types + */ + +/** Mapping of semantic color roles (e.g. lightText, buttonBackground) to hex values */ +export type ColorMapping = Record; + +/** User-provided color with optional type label */ +export interface ColorEntry { + hex?: string; + type?: string; +} + +/** User-provided font with optional type label */ +export interface FontEntry { + name?: string; + type?: string; +} + +/** Collected answers from the theming conversation */ +export interface CollectedAnswers { + colors?: ColorEntry[]; + fonts?: FontEntry[]; + colorMapping?: ColorMapping; + [questionId: string]: unknown; +} + +/** Conversation context passed to the theming tool */ +export interface ConversationContext { + currentStep?: string; + collectedAnswers?: CollectedAnswers; + questionsAsked?: string[]; +} + +/** Input schema for the site theming tool */ +export interface SiteThemingInput { + fileKeys?: string[]; + conversationContext?: ConversationContext; +} diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/README.md b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/README.md new file mode 100644 index 00000000..4ba44cd7 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/README.md @@ -0,0 +1,178 @@ +# Testing Site Theming Tool + +## Test Status + +The site-theming tool has comprehensive unit tests covering: + +- **Tool metadata**: Name, description, toolsets, inputSchema +- **Tool behavior**: List files, retrieve guidance, error handling, question filtering +- **Color validation**: Automated WCAG contrast validation when `colorMapping` is provided +- **File merging**: `fileKeys` array, `fileKeys` with defaults, missing file errors +- **Edge cases**: Ready to Implement flow, validation summary for failing contrast +- **color-contrast.ts**: Luminance, contrast ratio, WCAG levels, validateContrast, formatValidationResult +- **theming-store.ts**: Initialize, loadFile, get/getKeys, THEMING_FILES env, workflow/validation parsing + +All tests use the standard Mocha test framework and run with `pnpm test`. + +## Testing Approaches + +### 1. Unit Tests (Automated) + +Run the test suite: + +```bash +cd packages/b2c-dx-mcp +pnpm run test:agent -- test/tools/storefrontnext/site-theming/ +``` + +### 2. MCP Inspector (Interactive Testing) + +Use the MCP Inspector to test the tool interactively: + +```bash +cd packages/b2c-dx-mcp +pnpm run inspect:dev +``` + +Then in the inspector: + +1. Click **Connect** +2. Click **List Tools** - you should see `storefront_next_site_theming` +3. Click on the tool to test it with real inputs + +### 3. CLI Testing + +Test via command line: + +```bash +# List all tools (should include storefront_next_site_theming) +npx mcp-inspector --cli node bin/dev.js --toolsets STOREFRONTNEXT --allow-non-ga-tools --method tools/list + +# Call the tool - list available files +npx mcp-inspector --cli node bin/dev.js --toolsets STOREFRONTNEXT --allow-non-ga-tools \ + --method tools/call \ + --tool-name storefront_next_site_theming \ + --args '{}' + +# Call with conversation context - get guidelines and questions +npx mcp-inspector --cli node bin/dev.js --toolsets STOREFRONTNEXT --allow-non-ga-tools \ + --method tools/call \ + --tool-name storefront_next_site_theming \ + --args '{"conversationContext":{"collectedAnswers":{"colors":[],"fonts":[]}}}' +``` + +### 4. Manual Test Scenarios + +#### List Available Files + +```json +{} +``` + +Expected: Returns list of available theming files (theming-questions, theming-validation, theming-accessibility) + +#### First Call - Get Guidelines and Questions + +```json +{ + "conversationContext": { + "collectedAnswers": { + "colors": [], + "fonts": [] + } + } +} +``` + +Expected: Returns theming guidelines, critical rules, and questions to ask the user + +#### With Collected Colors and Fonts + +```json +{ + "conversationContext": { + "questionsAsked": ["color-1"], + "collectedAnswers": { + "colors": [{"hex": "#635BFF", "type": "primary"}, {"hex": "#0A2540", "type": "secondary"}], + "fonts": [{"name": "sohne-var", "type": "body"}] + } + } +} +``` + +Expected: Returns "Information You've Provided" section with colors and fonts, plus next questions + +#### Validation Call - Trigger Color Contrast Check + +```json +{ + "conversationContext": { + "collectedAnswers": { + "colors": [{"hex": "#635BFF", "type": "primary"}], + "colorMapping": { + "lightText": "#000000", + "lightBackground": "#FFFFFF", + "darkText": "#FFFFFF", + "darkBackground": "#18181B", + "buttonText": "#FFFFFF", + "buttonBackground": "#0A2540" + } + } + } +} +``` + +Expected: Returns "AUTOMATED COLOR VALIDATION RESULTS" with contrast ratios and WCAG status + +#### Merge Multiple Files + +```json +{ + "fileKeys": ["theming-questions", "theming-validation"], + "conversationContext": { + "collectedAnswers": {"colors": [], "fonts": []} + } +} +``` + +Expected: Returns merged guidance from both files + +#### Non-Existent File Key + +```json +{ + "fileKeys": ["non-existent"], + "conversationContext": { + "collectedAnswers": {"colors": [], "fonts": []} + } +} +``` + +Expected: Returns error with "not found" and lists available keys + +### 5. Custom Theming Files (THEMING_FILES) + +To test with custom content, set the `THEMING_FILES` environment variable: + +```bash +export THEMING_FILES='[{"key":"custom-theming","path":"/path/to/custom-theming.md"}]' +``` + +The path is relative to the project directory (or absolute). The custom file is merged with the default files. + +## Troubleshooting + +### "No theming files have been loaded" + +- Ensure the MCP server was started from a directory where the package's `content/site-theming/` files are available +- Default files are loaded from the installed package at runtime + +### "File not found" for custom THEMING_FILES + +- Verify the path in `THEMING_FILES` is correct (relative to project dir or absolute) +- Ensure the JSON is valid: `[{"key":"my-key","path":"path/to/file.md"}]` + +### Validation Not Appearing + +- `colorMapping` must be present in `conversationContext.collectedAnswers` +- `collectedAnswers.colors` must be a non-empty array for validation to run diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/color-contrast.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/color-contrast.test.ts new file mode 100644 index 00000000..2ef12599 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/color-contrast.test.ts @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + getLuminance, + getContrastRatio, + getWCAGLevel, + validateContrast, + validateColorCombinations, + formatValidationResult, + isValidHex, + WCAGLevel, +} from '../../../../src/tools/storefrontnext/site-theming/color-contrast.js'; + +describe('tools/storefrontnext/site-theming/color-contrast', () => { + describe('getLuminance', () => { + it('should return 0 for pure black', () => { + expect(getLuminance('#000000')).to.equal(0); + expect(getLuminance('000000')).to.equal(0); + }); + + it('should return 1 for pure white', () => { + expect(getLuminance('#FFFFFF')).to.equal(1); + expect(getLuminance('FFFFFF')).to.equal(1); + }); + + it('should return correct luminance for mid-gray', () => { + const luminance = getLuminance('#808080'); + expect(luminance).to.be.greaterThan(0.2); + expect(luminance).to.be.lessThan(0.6); + }); + + it('should handle hex with # prefix', () => { + expect(getLuminance('#635BFF')).to.be.a('number'); + expect(getLuminance('#635BFF')).to.be.greaterThan(0); + expect(getLuminance('#635BFF')).to.be.lessThan(1); + }); + + it('should throw for invalid hex format', () => { + expect(() => getLuminance('#GG')).to.throw(/Invalid hex color/); + expect(() => getLuminance('xyz')).to.throw(/Invalid hex color/); + expect(() => getLuminance('#12345')).to.throw(/Invalid hex color/); + expect(() => getLuminance('#1234567')).to.throw(/Invalid hex color/); + }); + }); + + describe('isValidHex', () => { + it('should return true for valid 6-digit hex', () => { + expect(isValidHex('#635BFF')).to.be.true; + expect(isValidHex('635BFF')).to.be.true; + expect(isValidHex('#000000')).to.be.true; + expect(isValidHex('#FFFFFF')).to.be.true; + }); + + it('should return false for invalid hex', () => { + expect(isValidHex('#GG')).to.be.false; + expect(isValidHex('xyz')).to.be.false; + expect(isValidHex('#12345')).to.be.false; + expect(isValidHex('')).to.be.false; + }); + }); + + describe('getContrastRatio', () => { + it('should return 21 for black on white', () => { + const ratio = getContrastRatio('#000000', '#FFFFFF'); + expect(ratio).to.be.closeTo(21, 0.1); + }); + + it('should return 21 for white on black', () => { + const ratio = getContrastRatio('#FFFFFF', '#000000'); + expect(ratio).to.be.closeTo(21, 0.1); + }); + + it('should return 1 for same color', () => { + expect(getContrastRatio('#635BFF', '#635BFF')).to.equal(1); + }); + + it('should return same ratio regardless of order', () => { + const r1 = getContrastRatio('#000000', '#FFFFFF'); + const r2 = getContrastRatio('#FFFFFF', '#000000'); + expect(r1).to.equal(r2); + }); + }); + + describe('getWCAGLevel', () => { + describe('normal text', () => { + it('should return AAA for ratio >= 7', () => { + expect(getWCAGLevel(7)).to.equal(WCAGLevel.AAA); + expect(getWCAGLevel(10)).to.equal(WCAGLevel.AAA); + }); + + it('should return AA for ratio >= 4.5 and < 7', () => { + expect(getWCAGLevel(4.5)).to.equal(WCAGLevel.AA); + expect(getWCAGLevel(5.5)).to.equal(WCAGLevel.AA); + }); + + it('should return FAIL for ratio < 4.5', () => { + expect(getWCAGLevel(4.4)).to.equal(WCAGLevel.FAIL); + expect(getWCAGLevel(2)).to.equal(WCAGLevel.FAIL); + }); + }); + + describe('large text', () => { + it('should return AAA_LARGE for ratio >= 4.5', () => { + expect(getWCAGLevel(4.5, true)).to.equal(WCAGLevel.AAA_LARGE); + expect(getWCAGLevel(7, true)).to.equal(WCAGLevel.AAA_LARGE); + }); + + it('should return AA_LARGE for ratio >= 3 and < 4.5', () => { + expect(getWCAGLevel(3, true)).to.equal(WCAGLevel.AA_LARGE); + expect(getWCAGLevel(4, true)).to.equal(WCAGLevel.AA_LARGE); + }); + + it('should return FAIL for ratio < 3', () => { + expect(getWCAGLevel(2.9, true)).to.equal(WCAGLevel.FAIL); + }); + }); + }); + + describe('validateContrast', () => { + it('should return excellent for high contrast (black on white)', () => { + const result = validateContrast('#000000', '#FFFFFF'); + expect(result.passesAA).to.be.true; + expect(result.passesAAA).to.be.true; + expect(result.visualAssessment).to.equal('excellent'); + expect(result.ratio).to.be.closeTo(21, 0.1); + expect(result.wcagLevel).to.equal(WCAGLevel.AAA); + }); + + it('should return good for ratio between 5 and 7', () => { + // #6B6B6B on white gives ~5.3:1 (good range, 5-7) + const result = validateContrast('#6B6B6B', '#FFFFFF'); + expect(result.passesAA).to.be.true; + expect(result.visualAssessment).to.equal('good'); + }); + + it('should return acceptable for ratio at 4.5 threshold', () => { + const result = validateContrast('#767676', '#FFFFFF'); + expect(result.passesAA).to.be.true; + expect(result.visualAssessment).to.equal('acceptable'); + expect(result.recommendation).to.include('WCAG AA'); + }); + + it('should return poor with recommendation for failing contrast', () => { + const result = validateContrast('#CCCCCC', '#FFFFFF'); + expect(result.passesAA).to.be.false; + expect(result.visualAssessment).to.equal('poor'); + expect(result.recommendation).to.include('WCAG AA'); + }); + + it('should handle large text threshold', () => { + // #888888 on white gives ~3.9:1 - AA_LARGE (3:1) but not AAA_LARGE (4.5:1) + const result = validateContrast('#888888', '#FFFFFF', true); + expect(result.isLargeText).to.be.true; + expect(result.passesAA).to.be.true; + expect(result.wcagLevel).to.equal(WCAGLevel.AA_LARGE); + }); + + it('should throw for invalid hex', () => { + expect(() => validateContrast('#GG', '#FFFFFF')).to.throw(/Invalid hex color/); + expect(() => validateContrast('#000000', 'xyz')).to.throw(/Invalid hex color/); + }); + }); + + describe('validateColorCombinations', () => { + it('should validate multiple combinations', () => { + const results = validateColorCombinations([ + {foreground: '#000000', background: '#FFFFFF', label: 'Black on white'}, + {foreground: '#FFFFFF', background: '#000000', label: 'White on black'}, + ]); + + expect(results).to.have.lengthOf(2); + expect(results[0].label).to.equal('Black on white'); + expect(results[0].passesAA).to.be.true; + expect(results[1].label).to.equal('White on black'); + expect(results[1].passesAA).to.be.true; + }); + + it('should pass isLargeText to validateContrast', () => { + const results = validateColorCombinations([{foreground: '#767676', background: '#FFFFFF', isLargeText: true}]); + + expect(results[0].isLargeText).to.be.true; + expect(results[0].passesAA).to.be.true; + }); + + it('should throw when combination contains invalid hex', () => { + expect(() => + validateColorCombinations([ + {foreground: '#000000', background: '#FFFFFF'}, + {foreground: '#GG', background: '#FFFFFF'}, + ]), + ).to.throw(/Invalid hex color/); + }); + }); + + describe('formatValidationResult', () => { + it('should format passing result with label', () => { + const result = { + color1: '#000000', + color2: '#FFFFFF', + ratio: 21, + wcagLevel: WCAGLevel.AAA, + passesAA: true, + passesAAA: true, + isLargeText: false, + visualAssessment: 'excellent' as const, + label: 'Primary text', + }; + + const output = formatValidationResult(result); + + expect(output).to.include('Primary text:'); + expect(output).to.include('#000000 on #FFFFFF'); + expect(output).to.include('Contrast Ratio: 21.00:1'); + expect(output).to.include('WCAG normal text'); + expect(output).to.include('✅ AAA'); + expect(output).to.include('EXCELLENT'); + }); + + it('should format result with recommendation when failing', () => { + const result = { + color1: '#CCCCCC', + color2: '#FFFFFF', + ratio: 1.6, + wcagLevel: WCAGLevel.FAIL, + passesAA: false, + passesAAA: false, + isLargeText: false, + visualAssessment: 'poor' as const, + recommendation: 'Does not meet WCAG AA standards.', + }; + + const output = formatValidationResult(result); + + expect(output).to.include('❌ FAIL'); + expect(output).to.include('POOR'); + expect(output).to.include('Does not meet WCAG AA standards.'); + }); + + it('should format result without label when not provided', () => { + const result = { + color1: '#000000', + color2: '#FFFFFF', + ratio: 21, + wcagLevel: WCAGLevel.AAA, + passesAA: true, + passesAAA: true, + isLargeText: false, + visualAssessment: 'excellent' as const, + }; + + const output = formatValidationResult(result); + + expect(output).to.not.match(/^[^:]+: #/); + expect(output).to.include('#000000 on #FFFFFF'); + }); + + it('should format AA (not AAA) result with WCAG AA status', () => { + const result = { + color1: '#6B6B6B', + color2: '#FFFFFF', + ratio: 5.3, + wcagLevel: WCAGLevel.AA, + passesAA: true, + passesAAA: false, + isLargeText: false, + visualAssessment: 'good' as const, + }; + + const output = formatValidationResult(result); + + expect(output).to.include('✅ AA'); + expect(output).to.include('WCAG normal text'); + }); + + it('should format large text result', () => { + const result = { + color1: '#888888', + color2: '#FFFFFF', + ratio: 3.9, + wcagLevel: WCAGLevel.AA_LARGE, + passesAA: true, + passesAAA: false, + isLargeText: true, + visualAssessment: 'acceptable' as const, + }; + + const output = formatValidationResult(result); + + expect(output).to.include('WCAG large text'); + expect(output).to.include('✅ AA'); + }); + }); +}); diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/color-mapping.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/color-mapping.test.ts new file mode 100644 index 00000000..8e08ca8e --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/color-mapping.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + buildColorCombinations, + appendValidationSection, + type ColorCombination, +} from '../../../../src/tools/storefrontnext/site-theming/color-mapping.js'; + +describe('tools/storefrontnext/site-theming/color-mapping', () => { + describe('buildColorCombinations', () => { + it('should derive text-on-background combinations from semantic mapping', () => { + const mapping = { + lightText: '#000000', + lightBackground: '#FFFFFF', + buttonText: '#FFFFFF', + buttonBackground: '#0A2540', + }; + const combos = buildColorCombinations(mapping); + expect(combos).to.have.lengthOf.at.least(2); + expect(combos.some((c) => c.label.includes('light') && c.label.includes('light background'))).to.be.true; + expect(combos.some((c) => c.label.includes('button') && c.label.includes('button background'))).to.be.true; + }); + + it('should use fallback white/dark backgrounds when no text-background pairs found', () => { + const mapping = {accent: '#635BFF', primary: '#0A2540'}; + const combos = buildColorCombinations(mapping); + expect(combos).to.have.lengthOf(4); + expect(combos.filter((c) => c.background === '#FFFFFF')).to.have.lengthOf(2); + expect(combos.filter((c) => c.background === '#18181B')).to.have.lengthOf(2); + expect(combos.some((c) => c.label.includes('accent') && c.label.includes('white background'))).to.be.true; + expect(combos.some((c) => c.label.includes('primary') && c.label.includes('dark background'))).to.be.true; + }); + + it('should skip invalid hex values', () => { + const mapping = {lightText: '#000000', invalid: 'nothex', lightBackground: '#FFFFFF'}; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('invalid'))).to.be.false; + }); + + it('should skip non-hex values', () => { + const mapping = {lightText: 'rgb(0,0,0)', lightBackground: '#FFFFFF'}; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.foreground === 'rgb(0,0,0)')).to.be.false; + }); + + it('should derive link color on light background combination', () => { + const mapping = {linkColor: '#0A2540', lightBackground: '#FFFFFF'}; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('link') && c.label.includes('light background'))).to.be.true; + }); + + it('should derive background combo when foreground key exists in mapping', () => { + const mapping = { + lightText: '#000000', + lightBackground: '#FFFFFF', + darkText: '#FFFFFF', + darkBackground: '#18181B', + }; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('lightText') && c.background === '#FFFFFF')).to.be.true; + expect(combos.some((c) => c.label.includes('darkText') && c.background === '#18181B')).to.be.true; + }); + + it('should derive text-on-background using button/dark/light fallback when background key missing', () => { + const mapping = { + buttonText: '#FFFFFF', + buttonBackground: '#0A2540', + }; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('button') && c.label.includes('button background'))).to.be.true; + }); + + it('should use dark background fallback for darkForeground when darkBackground missing', () => { + const mapping = {darkForeground: '#FFFFFF'}; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('dark') && c.label.includes('dark background'))).to.be.true; + expect(combos.some((c) => c.background === '#18181B')).to.be.true; + }); + + it('should use button background fallback for buttonForeground when buttonBackground missing', () => { + const mapping = {buttonForeground: '#FFFFFF'}; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('button') && c.label.includes('button background'))).to.be.true; + }); + + it('should use light background fallback for primaryText when background key missing', () => { + const mapping = {primaryText: '#000000', lightBackground: '#FFFFFF'}; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('light background') && c.background === '#FFFFFF')).to.be.true; + }); + + it('should use fallback when background is invalid hex (tryTextForegroundCombo returns null)', () => { + const mapping = {primaryText: '#000000', lightBackground: 'rgb(255,255,255)'}; + const combos = buildColorCombinations(mapping); + expect(combos.some((c) => c.label.includes('white background'))).to.be.true; + }); + }); + + describe('appendValidationSection', () => { + it('should return instructions unchanged when combinations are empty', () => { + const instructions = '# Test\nSome content.'; + const result = appendValidationSection(instructions, []); + expect(result).to.equal(instructions); + }); + + it('should append validation results and summary when combinations provided', () => { + const combos: ColorCombination[] = [{foreground: '#000000', background: '#FFFFFF', label: 'Test combo'}]; + const result = appendValidationSection('# Base\n', combos); + expect(result).to.include('# Base'); + expect(result).to.include('VALIDATION SUMMARY'); + expect(result).to.include('Test combo'); + }); + + it('should append issues summary when contrast fails', () => { + const combos: ColorCombination[] = [{foreground: '#888888', background: '#999999', label: 'Low contrast'}]; + const result = appendValidationSection('', combos); + expect(result).to.include('Issues found that should be addressed'); + expect(result).to.include('Low contrast'); + }); + + it('should append issues summary when visual assessment is acceptable', () => { + // Ratio 4.5-5 produces "acceptable" (meets AA but borderline readability) + const combos: ColorCombination[] = [{foreground: '#737373', background: '#FFFFFF', label: 'Borderline'}]; + const result = appendValidationSection('', combos); + expect(result).to.include('Issues found that should be addressed'); + expect(result).to.include('Borderline'); + }); + + it('should append success summary when all pass', () => { + const combos: ColorCombination[] = [{foreground: '#000000', background: '#FFFFFF', label: 'Good contrast'}]; + const result = appendValidationSection('', combos); + expect(result).to.include('All color combinations meet WCAG AA'); + }); + }); +}); diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/guidance-merger.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/guidance-merger.test.ts new file mode 100644 index 00000000..9954dc48 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/guidance-merger.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {mergeGuidance} from '../../../../src/tools/storefrontnext/site-theming/guidance-merger.js'; +import type {ThemingGuidance} from '../../../../src/tools/storefrontnext/site-theming/theming-store.js'; + +function createGuidance(overrides: Partial = {}): ThemingGuidance { + return { + questions: [], + guidelines: [], + rules: [], + metadata: {filePath: '', fileName: '', loadedAt: new Date()}, + ...overrides, + }; +} + +describe('tools/storefrontnext/site-theming/guidance-merger', () => { + it('should throw when merging empty array', () => { + expect(() => mergeGuidance([])).to.throw('Cannot merge empty guidance array'); + }); + + it('should return single item unchanged', () => { + const g = createGuidance({questions: [{id: 'q1', question: 'Q?', category: 'colors', required: true}]}); + expect(mergeGuidance([g])).to.equal(g); + }); + + it('should merge workflows and use extractionInstructions from second when first lacks it', () => { + const g1 = createGuidance({ + workflow: {steps: ['Step 1'], extractionInstructions: undefined, preImplementationChecklist: undefined}, + }); + const g2 = createGuidance({ + workflow: { + steps: ['Step 2'], + extractionInstructions: 'Extract colors and fonts.', + preImplementationChecklist: 'Check all items.', + }, + }); + const merged = mergeGuidance([g1, g2]); + expect(merged.workflow?.extractionInstructions).to.equal('Extract colors and fonts.'); + expect(merged.workflow?.preImplementationChecklist).to.equal('Check all items.'); + expect(merged.workflow?.steps).to.deep.equal(['Step 1', 'Step 2']); + }); + + it('should merge validations from multiple guidance objects', () => { + const g1 = createGuidance({ + validation: { + colorValidation: 'Check colors.', + fontValidation: undefined, + generalValidation: undefined, + requirements: undefined, + }, + }); + const g2 = createGuidance({ + validation: { + colorValidation: undefined, + fontValidation: 'Check fonts.', + generalValidation: 'Check general.', + requirements: 'Important.', + }, + }); + const merged = mergeGuidance([g1, g2]); + expect(merged.validation?.colorValidation).to.include('Check colors.'); + expect(merged.validation?.fontValidation).to.include('Check fonts.'); + expect(merged.validation?.generalValidation).to.include('Check general.'); + expect(merged.validation?.requirements).to.include('Important.'); + }); + + it('should deduplicate questions by id', () => { + const g1 = createGuidance({ + questions: [{id: 'q1', question: 'First?', category: 'colors', required: true}], + }); + const g2 = createGuidance({ + questions: [{id: 'q1', question: 'Override?', category: 'colors', required: false}], + }); + const merged = mergeGuidance([g1, g2]); + expect(merged.questions).to.have.lengthOf(1); + expect(merged.questions[0].question).to.equal('First?'); + }); + + it('should concatenate guidelines and rules', () => { + const g1 = createGuidance({ + guidelines: [{category: 'c1', title: 'T1', content: 'C1', critical: true}], + rules: [{type: 'do', description: 'Do X'}], + }); + const g2 = createGuidance({ + guidelines: [{category: 'c2', title: 'T2', content: 'C2', critical: false}], + rules: [{type: 'dont', description: "Don't Y"}], + }); + const merged = mergeGuidance([g1, g2]); + expect(merged.guidelines).to.have.lengthOf(2); + expect(merged.rules).to.have.lengthOf(2); + }); +}); diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/index.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/index.test.ts new file mode 100644 index 00000000..f27dc947 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/index.test.ts @@ -0,0 +1,401 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {createSiteThemingTool} from '../../../../src/tools/storefrontnext/site-theming/index.js'; +import {Services} from '../../../../src/services.js'; +import type {ToolResult} from '../../../../src/utils/types.js'; +import {createMockResolvedConfig, createMockLoadServices} from '../../../test-helpers.js'; + +/** + * Helper to extract text from a ToolResult. + * Throws if the first content item is not a text type. + */ +function getResultText(result: ToolResult): string { + const content = result.content[0]; + if (content.type !== 'text') { + throw new Error(`Expected text content, got ${content.type}`); + } + return content.text; +} + +/** + * Type guard for string (used to satisfy unicorn/prefer-native-coercion-functions). + */ +function isString(x: unknown): x is string { + return typeof x === 'string'; +} + +/** + * Create a mock services instance for testing. + */ +function createMockServices(): Services { + return new Services({resolvedConfig: createMockResolvedConfig()}); +} + +describe('tools/storefrontnext/site-theming', () => { + let services: Services; + + const defaultContext = { + collectedAnswers: { + colors: [] as Array<{hex?: string; type?: string}>, + fonts: [] as Array<{name?: string; type?: string}>, + }, + }; + + beforeEach(() => { + services = createMockServices(); + }); + + describe('tool metadata', () => { + it('should have correct structure', () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + expect(tool).to.have.property('name', 'storefront_next_site_theming'); + expect(tool.description).to.include('theming guidelines'); + expect(tool).to.have.property('inputSchema'); + expect(tool).to.have.property('handler'); + expect(tool.handler).to.be.a('function'); + }); + + it('should be in STOREFRONTNEXT toolset', () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + expect(tool.toolsets).to.include('STOREFRONTNEXT'); + }); + }); + + describe('tool behavior', () => { + it('should list available files when called without parameters', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({}); + + expect(result.content).to.exist; + const text = getResultText(result); + expect(text).to.include('Available theming files'); + expect(text).to.include('theming-questions'); + expect(text).to.include('theming-validation'); + expect(text).to.include('theming-accessibility'); + }); + + it('should retrieve and parse theming file from store', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: defaultContext, + }); + + expect(result.content).to.exist; + const text = getResultText(result); + expect(text).to.include('Layout Preservation'); + expect(text).to.include('Critical Guidelines'); + expect(text).to.include('Questions to Ask the User'); + }); + + it('should return error when file key does not exist', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['non-existent'], + }); + + expect(result.isError).to.equal(true); + expect(getResultText(result)).to.include('not found'); + expect(getResultText(result)).to.include('non-existent'); + }); + + it('should extract questions from content', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: defaultContext, + }); + + const text = getResultText(result); + expect(text).to.include('Questions to Ask the User'); + expect(text).to.match(/color|font/i); + }); + + it('should filter questions based on conversation context', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + + const firstResult = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: defaultContext, + }); + + const firstText = getResultText(firstResult); + expect(firstText).to.include('Questions to Ask the User'); + + const secondResult = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: { + questionsAsked: ['color-primary'], + collectedAnswers: { + colors: [], + fonts: [], + 'color-primary': '#635BFF', + }, + }, + }); + + const secondText = getResultText(secondResult); + expect(secondText).to.be.a('string'); + }); + + it('should include collected theming info in response', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: { + questionsAsked: ['color-1', 'color-2'], + collectedAnswers: { + colors: [ + {hex: '#635BFF', type: 'primary'}, + {hex: '#0A2540', type: 'secondary'}, + ], + fonts: [{name: 'sohne-var', type: 'body'}], + 'color-1': 'primary', + 'color-2': 'accent', + }, + }, + }); + + const text = getResultText(result); + expect(text).to.include("Information You've Provided"); + expect(text).to.include('#635BFF'); + expect(text).to.include('sohne-var'); + }); + + it('should include critical guidelines in response', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: defaultContext, + }); + + const text = getResultText(result); + expect(text).to.include('Critical Guidelines'); + expect(text).to.include('Layout Preservation'); + }); + + it("should include DO and DON'T rules", async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: defaultContext, + }); + + const text = getResultText(result); + expect(text).to.include('What TO Do'); + expect(text).to.include('What NOT to Do'); + expect(text).to.match(/position|color/); + }); + + it('should use default files when conversationContext provided without fileKeys', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + conversationContext: defaultContext, + }); + + expect(result.content).to.exist; + expect(result.isError).to.not.equal(true); + const text = getResultText(result); + expect(text).to.include('Questions to Ask the User'); + }); + + it('should run automated color validation when colorMapping is provided', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: { + questionsAsked: ['color-1'], + collectedAnswers: { + colors: [{hex: '#635BFF', type: 'primary'}], + fonts: [], + colorMapping: { + lightText: '#000000', + lightBackground: '#FFFFFF', + darkText: '#FFFFFF', + darkBackground: '#18181B', + buttonText: '#FFFFFF', + buttonBackground: '#0A2540', + }, + }, + }, + }); + + const text = getResultText(result); + expect(text).to.include('AUTOMATED COLOR VALIDATION RESULTS'); + expect(text).to.include('Contrast Ratio'); + expect(text).to.match(/WCAG|AAA|AA|FAIL/); + }); + + it('should run validation when only colorMapping is provided (no colors array)', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: { + collectedAnswers: { + colorMapping: { + lightText: '#000000', + lightBackground: '#FFFFFF', + buttonText: '#FFFFFF', + buttonBackground: '#0A2540', + }, + }, + }, + }); + + const text = getResultText(result); + expect(text).to.include('AUTOMATED COLOR VALIDATION RESULTS'); + expect(text).to.include('Contrast Ratio'); + expect(text).to.match(/WCAG|AAA|AA|FAIL/); + }); + + it('should merge guidance when fileKeys array is provided', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions', 'theming-validation'], + conversationContext: defaultContext, + }); + + expect(result.isError).to.not.equal(true); + const text = getResultText(result); + // Merged content should include from both files + expect(text).to.include('Questions to Ask the User'); + expect(text).to.satisfy( + (t: string) => + t.includes('Input Validation') || t.includes('VALIDATION') || t.includes('Color') || t.includes('contrast'), + ); + }); + + it('should combine fileKeys with default files', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-accessibility'], + conversationContext: defaultContext, + }); + + expect(result.isError).to.not.equal(true); + const text = getResultText(result); + expect(text).to.be.a('string'); + expect(text.length).to.be.greaterThan(0); + }); + + it('should return error when fileKeys contains non-existent key', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions', 'non-existent-key'], + conversationContext: defaultContext, + }); + + expect(result.isError).to.equal(true); + const text = getResultText(result); + expect(text).to.include('not found'); + expect(text).to.include('non-existent-key'); + }); + }); + + describe('edge cases', () => { + it('should show Ready to Implement when all required questions answered', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + + // First call to get initial questions - then simulate answering all + const firstResult = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: defaultContext, + }); + const firstText = getResultText(firstResult); + + // Extract question IDs from the response (they appear in "Question N (category): id" format) + const questionIdMatches = firstText.match(/\((\w+)\):\s*(color-\d+|font-\d+|general-\d+)/g); + const questionIds: string[] = questionIdMatches + ? [...new Set(questionIdMatches.map((m) => m.split(':').pop()?.trim()).filter((x) => isString(x)))] + : ['color-1', 'font-1', 'general-1']; + + const collectedAnswers: Record = { + colors: [{hex: '#635BFF', type: 'primary'}], + fonts: [{name: 'sohne-var', type: 'body'}], + }; + for (const id of questionIds) { + if (id) { + collectedAnswers[id] = 'answered'; + } + } + + const secondResult = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: { + questionsAsked: questionIds, + collectedAnswers, + }, + }); + + const secondText = getResultText(secondResult); + // Should show Ready to Implement, pre-implementation checklist, or continue with workflow + expect(secondText).to.satisfy( + (t: string) => + t.includes('Ready to Implement') || + t.includes('PRE-IMPLEMENTATION') || + t.includes('validate all provided inputs') || + t.includes('Questions to Ask') || + t.includes('MANDATORY'), + ); + }); + + it('should show validation summary when color combinations fail WCAG', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: { + questionsAsked: ['color-1'], + collectedAnswers: { + colors: [{hex: '#CCCCCC', type: 'primary'}], + fonts: [], + colorMapping: { + lightText: '#DDDDDD', + lightBackground: '#FFFFFF', + }, + }, + }, + }); + + const text = getResultText(result); + // Poor contrast should trigger validation summary + expect(text).to.satisfy( + (t: string) => + t.includes('VALIDATION SUMMARY') || + t.includes('Issues found') || + t.includes('WCAG') || + t.includes('contrast'), + ); + }); + + it('should skip invalid hex in colorMapping without error', async () => { + const tool = createSiteThemingTool(createMockLoadServices(services)); + const result = await tool.handler({ + fileKeys: ['theming-questions'], + conversationContext: { + questionsAsked: ['color-1'], + collectedAnswers: { + colors: [{hex: '#635BFF', type: 'primary'}], + fonts: [], + colorMapping: { + lightText: '#000000', + lightBackground: '#FFFFFF', + invalidKey: '#GG', + anotherInvalid: 'not-hex', + }, + }, + }, + }); + + expect(result.isError).to.not.equal(true); + const text = getResultText(result); + // Should still run validation for valid colors; invalid hex is filtered out + expect(text).to.include('AUTOMATED COLOR VALIDATION RESULTS'); + expect(text).to.include('Contrast Ratio'); + }); + }); +}); diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/response-builder.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/response-builder.test.ts new file mode 100644 index 00000000..9cd81699 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/response-builder.test.ts @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + generateResponse, + getRelevantQuestions, + hasProvidedThemingInfo, +} from '../../../../src/tools/storefrontnext/site-theming/response-builder.js'; +import type {ThemingGuidance} from '../../../../src/tools/storefrontnext/site-theming/theming-store.js'; + +function createGuidance(overrides: Partial = {}): ThemingGuidance { + return { + questions: [], + guidelines: [], + rules: [], + metadata: {filePath: '', fileName: '', loadedAt: new Date()}, + ...overrides, + }; +} + +describe('tools/storefrontnext/site-theming/response-builder', () => { + describe('hasProvidedThemingInfo', () => { + it('should return false when no context', () => { + expect(hasProvidedThemingInfo(undefined)).to.be.false; + }); + + it('should return true when colors array provided', () => { + expect(hasProvidedThemingInfo({collectedAnswers: {colors: [{hex: '#000'}]}})).to.be.true; + }); + + it('should return true when fonts array provided', () => { + expect(hasProvidedThemingInfo({collectedAnswers: {fonts: [{name: 'Arial'}]}})).to.be.true; + }); + }); + + describe('getRelevantQuestions', () => { + it('should filter component scope questions', () => { + const g = createGuidance({ + questions: [ + {id: 'q1', question: 'Which components?', category: 'general', required: false}, + {id: 'q2', question: 'What colors?', category: 'colors', required: true}, + ], + }); + const qs = getRelevantQuestions(g); + expect(qs).to.have.lengthOf(1); + expect(qs[0].question).to.equal('What colors?'); + }); + + it('should sort required before optional', () => { + const g = createGuidance({ + questions: [ + {id: 'q1', question: 'Optional?', category: 'general', required: false}, + {id: 'q2', question: 'Required?', category: 'colors', required: true}, + ], + }); + const qs = getRelevantQuestions(g); + expect(qs[0].required).to.be.true; + }); + + it('should exclude already-asked questions', () => { + const g = createGuidance({ + questions: [ + {id: 'q1', question: 'Q1?', category: 'colors', required: true}, + {id: 'q2', question: 'Q2?', category: 'colors', required: false}, + ], + }); + const qs = getRelevantQuestions(g, {questionsAsked: ['q1']}); + expect(qs).to.have.lengthOf(1); + expect(qs[0].id).to.equal('q2'); + }); + + it('should return remaining questions when questionsAsked but no collectedAnswers', () => { + const g = createGuidance({ + questions: [ + {id: 'q1', question: 'Q1?', category: 'colors', required: true}, + {id: 'q2', question: 'Q2?', category: 'general', required: false}, + ], + }); + const qs = getRelevantQuestions(g, {questionsAsked: ['q1']}); + expect(qs).to.have.lengthOf(1); + expect(qs[0].id).to.equal('q2'); + }); + + it('should add follow-up questions when answer provided', () => { + const g = createGuidance({ + questions: [ + { + id: 'q1', + question: 'Q1?', + category: 'colors', + required: false, + followUpQuestions: ['Follow-up 1?', 'Follow-up 2?'], + }, + {id: 'q2', question: 'Q2?', category: 'colors', required: true}, + ], + }); + // q2 asked first; q1 in remaining with proactive answer triggers follow-ups + const qs = getRelevantQuestions(g, { + questionsAsked: ['q2'], + collectedAnswers: {q1: 'yes'}, + }); + expect(qs.some((q) => q.question === 'Follow-up 1?')).to.be.true; + }); + }); + + describe('generateResponse', () => { + it('should return extraction response on first call with no theming info', () => { + const g = createGuidance({ + workflow: {steps: [], extractionInstructions: 'Extract colors and fonts from user input.'}, + }); + const result = generateResponse(g, {collectedAnswers: {}}); + expect(result).to.include('Extract User-Provided Theming Information'); + expect(result).to.include('Extract colors and fonts from user input.'); + expect(result).to.include('USER-FACING RESPONSE'); + }); + + it('should show ready to implement when all required questions answered', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: true}], + workflow: {steps: [], preImplementationChecklist: '- Item 1\n- Item 2'}, + }); + const result = generateResponse(g, { + collectedAnswers: {q1: '#000000', colors: [{hex: '#000000'}], colorMapping: {text: '#000000', bg: '#FFFFFF'}}, + questionsAsked: ['q1'], + }); + expect(result).to.include('Ready to Implement'); + expect(result).to.include('MANDATORY PRE-IMPLEMENTATION CHECKLIST'); + expect(result).to.include('Item 1'); + }); + + it('should show warning when required questions not all answered', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: true}], + }); + const result = generateResponse(g, { + collectedAnswers: {colors: [{hex: '#000000'}]}, + questionsAsked: ['q1'], + }); + expect(result).to.include('WARNING'); + expect(result).to.include('Not all required questions have been answered'); + expect(result).to.include('still need answers'); + }); + + it('should use empty info when context has no collectedAnswers', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: false}], + }); + const result = generateResponse(g, {questionsAsked: ['q1']}); + expect(result).to.include('USER-FACING RESPONSE'); + expect(result).not.to.include("Information You've Provided"); + }); + + it('should show empty workflow message when colors and fonts arrays are empty', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: {colors: [], fonts: []}, + questionsAsked: [], + }); + expect(result).to.include('Following the theming workflow'); + expect(result).to.include('I need a few clarifications before implementing'); + }); + + it('should include otherInfo from non-color non-font keys', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: {colors: [], spacing: {desktop: 8}, brand: 'acme'}, + questionsAsked: [], + }); + expect(result).to.include('Other Information'); + expect(result).to.include('spacing:'); + expect(result).to.include('brand: acme'); + }); + + it('should skip colorMapping and question keys in otherInfo', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: { + colors: [], + colorMapping: {lightText: '#000', lightBackground: '#FFF'}, + questionsAsked: ['q1'], + brand: 'acme', + }, + questionsAsked: [], + }); + expect(result).to.include('brand: acme'); + expect(result).not.to.include('colorMapping:'); + expect(result).not.to.include('questionsAsked:'); + }); + + it('should extract color from color-like keys (accentColor, primaryColor)', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: {colors: [], accentColor: {hex: '#635BFF', type: 'accent'}}, + questionsAsked: [], + }); + expect(result).to.include("Information You've Provided"); + expect(result).to.include('#635BFF'); + }); + + it('should extract font from font-like keys (headingFont, bodyFont)', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Font?', category: 'typography', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: {fonts: [], headingFont: {name: 'Sohne', type: 'title'}}, + questionsAsked: [], + }); + expect(result).to.include("Information You've Provided"); + expect(result).to.include('Sohne'); + }); + + it('should extract font without type from font-like keys', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Font?', category: 'typography', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: {fonts: [], bodyFont: {name: 'Arial'}}, + questionsAsked: [], + }); + expect(result).to.include("Information You've Provided"); + expect(result).to.include('Arial'); + }); + + it('should show singular remaining when exactly one more question', () => { + const g = createGuidance({ + questions: [ + {id: 'q1', question: 'Colors?', category: 'colors', required: true}, + {id: 'q2', question: 'Font?', category: 'typography', required: false}, + {id: 'q3', question: 'Dark?', category: 'general', required: false}, + {id: 'q4', question: 'Spacing?', category: 'general', required: false}, + {id: 'q5', question: 'Radius?', category: 'general', required: false}, + ], + }); + const result = generateResponse(g, { + collectedAnswers: {colors: [{hex: '#000'}], q1: 'done'}, + questionsAsked: ['q1'], + }); + expect(result).to.match(/1 more question\b/); + expect(result).not.to.include('1 more questions'); + }); + + it('should show plural remaining when multiple more questions', () => { + const g = createGuidance({ + questions: [ + {id: 'q1', question: 'Colors?', category: 'colors', required: true}, + {id: 'q2', question: 'Font?', category: 'typography', required: false}, + {id: 'q3', question: 'Dark mode?', category: 'general', required: false}, + {id: 'q4', question: 'Spacing?', category: 'general', required: false}, + {id: 'q5', question: 'Radius?', category: 'general', required: false}, + {id: 'q6', question: 'Shadows?', category: 'general', required: false}, + ], + }); + const result = generateResponse(g, { + collectedAnswers: {colors: [{hex: '#000'}], q1: 'done'}, + questionsAsked: ['q1'], + }); + expect(result).to.match(/[2-9] more questions\b/); + }); + + it('should handle color-like keys with hex undefined without error', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: {colors: [], accentColor: {hex: undefined, type: 'primary'}}, + questionsAsked: [], + }); + expect(result).to.include('USER-FACING RESPONSE'); + }); + + it('should handle font-like keys with name undefined without error', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Font?', category: 'typography', required: false}], + }); + const result = generateResponse(g, { + collectedAnswers: {fonts: [], headingFont: {name: undefined, type: 'title'}}, + questionsAsked: [], + }); + expect(result).to.include('USER-FACING RESPONSE'); + }); + + it('should include validation instructions when guidance has validation', () => { + const g = createGuidance({ + questions: [{id: 'q1', question: 'Colors?', category: 'colors', required: false}], + validation: { + colorValidation: 'Check contrast ratios.', + fontValidation: 'Verify font availability.', + generalValidation: 'Validate other inputs.', + requirements: 'Always validate before implementing.', + }, + }); + const result = generateResponse(g, { + collectedAnswers: {colors: []}, + questionsAsked: [], + }); + expect(result).to.include('MANDATORY: Input Validation'); + expect(result).to.include('Color Combination Validation'); + expect(result).to.include('Check contrast ratios'); + expect(result).to.include('Font Validation'); + expect(result).to.include('Verify font availability'); + expect(result).to.include('General Input Validation'); + expect(result).to.include('Validate other inputs'); + expect(result).to.include('IMPORTANT'); + expect(result).to.include('Always validate before implementing'); + }); + }); +}); diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/theming-store.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/theming-store.test.ts new file mode 100644 index 00000000..0fa9e28e --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/theming-store.test.ts @@ -0,0 +1,624 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {existsSync, mkdirSync, writeFileSync, rmSync, copyFileSync} from 'node:fs'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {createRequire} from 'node:module'; +import {siteThemingStore} from '../../../../src/tools/storefrontnext/site-theming/theming-store.js'; + +const require = createRequire(import.meta.url); +const packageRoot = path.dirname(require.resolve('@salesforce/b2c-dx-mcp/package.json')); +const defaultContentDir = path.join(packageRoot, 'content', 'site-theming'); + +describe('tools/storefrontnext/site-theming/theming-store', () => { + let testDir: string; + let originalThemingFiles: string | undefined; + + beforeEach(() => { + testDir = path.join(tmpdir(), `b2c-theming-store-test-${Date.now()}`); + mkdirSync(testDir, {recursive: true}); + originalThemingFiles = process.env.THEMING_FILES; + }); + + afterEach(() => { + process.env.THEMING_FILES = originalThemingFiles; + if (existsSync(testDir)) { + rmSync(testDir, {recursive: true, force: true}); + } + }); + + describe('initialize', () => { + it('should load default theming files from package content', () => { + siteThemingStore.initialize(testDir); + + const keys = siteThemingStore.getKeys(); + expect(keys).to.include('theming-questions'); + expect(keys).to.include('theming-validation'); + expect(keys).to.include('theming-accessibility'); + }); + + it('should load custom file via THEMING_FILES env', () => { + const customPath = path.join(testDir, 'custom-theming.md'); + writeFileSync( + customPath, + `# Custom Theming + +## ⚠️ CRITICAL: Test Rule +Test content for custom theming file. + +### What TO Change: +- custom-color + +### What NOT to Change: +- custom-layout + +What are the exact hex color values?`, + 'utf8', + ); + + process.env.THEMING_FILES = JSON.stringify([{key: 'custom-theming', path: customPath}]); + + siteThemingStore.initialize(testDir); + + expect(siteThemingStore.has('custom-theming')).to.be.true; + const guidance = siteThemingStore.get('custom-theming'); + expect(guidance).to.exist; + expect(guidance!.metadata.fileName).to.equal('custom-theming.md'); + expect(guidance!.guidelines.length).to.be.greaterThan(0); + expect(guidance!.rules.length).to.be.greaterThan(0); + }); + + it('should resolve relative paths from workspace root', () => { + const customPath = path.join(testDir, 'relative-theming.md'); + writeFileSync( + customPath, + `# Relative Theming +## ⚠️ CRITICAL: Relative +Test. +### What TO Change: +- color +### What NOT to Change: +- layout`, + 'utf8', + ); + + const relativePath = path.relative(testDir, customPath); + process.env.THEMING_FILES = JSON.stringify([{key: 'relative-theming', path: relativePath}]); + + siteThemingStore.initialize(testDir); + + expect(siteThemingStore.has('relative-theming')).to.be.true; + }); + }); + + describe('get and getKeys', () => { + beforeEach(() => { + siteThemingStore.initialize(testDir); + }); + + it('should return guidance for existing key', () => { + const guidance = siteThemingStore.get('theming-questions'); + expect(guidance).to.exist; + expect(guidance!.questions).to.be.an('array'); + expect(guidance!.guidelines).to.be.an('array'); + expect(guidance!.rules).to.be.an('array'); + expect(guidance!.metadata).to.have.property('filePath'); + expect(guidance!.metadata).to.have.property('fileName'); + }); + + it('should return undefined for non-existent key', () => { + expect(siteThemingStore.get('non-existent')).to.be.undefined; + }); + + it('should return all loaded keys from getKeys', () => { + const keys = siteThemingStore.getKeys(); + expect(keys).to.be.an('array'); + expect(keys.length).to.be.greaterThanOrEqual(3); + }); + }); + + describe('loadFile', () => { + it('should parse workflow section from markdown', () => { + const filePath = path.join(testDir, 'workflow-test.md'); + const content = [ + '# Workflow Test', + '', + '## 🔄 WORKFLOW', + '1. First step', + '2. Second step', + '', + '### 📝 EXTRACTION', + 'Extract color values from user input.', + '', + '### ✅ PRE-IMPLEMENTATION', + 'Verify all colors meet WCAG AA.', + ].join('\n'); + writeFileSync(filePath, content, 'utf8'); + + siteThemingStore.loadFile('workflow-test', filePath); + const guidance = siteThemingStore.get('workflow-test'); + + expect(guidance).to.exist; + expect(guidance!.metadata.fileName).to.equal('workflow-test.md'); + // Workflow is parsed when steps, extraction, or checklist exist + if (guidance!.workflow) { + expect(guidance!.workflow!.steps).to.be.an('array'); + if (guidance!.workflow!.steps!.length > 0) { + expect(guidance!.workflow!.steps).to.include('First step'); + } + if (guidance!.workflow!.extractionInstructions) { + expect(guidance!.workflow!.extractionInstructions).to.include('Extract color values'); + } + if (guidance!.workflow!.preImplementationChecklist) { + expect(guidance!.workflow!.preImplementationChecklist).to.include('WCAG AA'); + } + } + }); + + it('should not add validation when section has no sub-sections', () => { + const filePath = path.join(testDir, 'validation-empty.md'); + writeFileSync( + filePath, + `# Validation Empty +## ✅ VALIDATION +No validation sub-sections here. +### What TO Change: +- opacity +### What NOT to Change: +- display`, + 'utf8', + ); + + siteThemingStore.loadFile('validation-empty', filePath); + const guidance = siteThemingStore.get('validation-empty'); + + expect(guidance).to.exist; + expect(guidance!.validation).to.be.undefined; + }); + + it('should return undefined validation when section has no A/B/C/IMPORTANT sub-sections', () => { + const filePath = path.join(testDir, 'validation-no-subsections.md'); + writeFileSync( + filePath, + `# Validation No Subsections +## ✅ VALIDATION +Only plain text, no A. Color, B. Font, C. General, or IMPORTANT.`, + 'utf8', + ); + + siteThemingStore.loadFile('validation-no-subsections', filePath); + const guidance = siteThemingStore.get('validation-no-subsections'); + + expect(guidance).to.exist; + expect(guidance!.validation).to.be.undefined; + }); + + it('should generate color mapping question when content has brand vs accent', () => { + const filePath = path.join(testDir, 'color-mapping-q.md'); + writeFileSync( + filePath, + `# Color Mapping +## ⚠️ CRITICAL: Colors +Ask for clarification on color type mapping. Use exact hex. Primary vs secondary, brand vs accent. +### What TO Change: +- color +- background-color +### What NOT to Change: +- margin`, + 'utf8', + ); + + siteThemingStore.loadFile('color-mapping-q', filePath); + const guidance = siteThemingStore.get('color-mapping-q'); + + expect(guidance).to.exist; + const mappingQ = guidance!.questions.find((q) => q.question.includes('primary vs secondary')); + expect(mappingQ).to.exist; + }); + + it('should parse validation section from markdown', () => { + const filePath = path.join(testDir, 'validation-test.md'); + const content = [ + '# Validation Test', + '', + '## ✅ VALIDATION', + '', + '### A. Color Combination Validation', + 'Check contrast ratios for all color combinations.', + '', + '### B. Font Validation', + 'Verify font availability.', + '', + '### C. General Input Validation', + 'General validation rules.', + '', + '### IMPORTANT', + 'All validations must pass.', + ].join('\n'); + writeFileSync(filePath, content, 'utf8'); + + siteThemingStore.loadFile('validation-test', filePath); + const guidance = siteThemingStore.get('validation-test'); + + expect(guidance).to.exist; + expect(guidance!.metadata.fileName).to.equal('validation-test.md'); + // Validation is parsed when color, font, general, or requirements exist + if (guidance!.validation) { + if (guidance!.validation!.colorValidation) { + expect(guidance!.validation!.colorValidation).to.include('contrast ratios'); + } + if (guidance!.validation!.fontValidation) { + expect(guidance!.validation!.fontValidation).to.include('font availability'); + } + if (guidance!.validation!.generalValidation) { + expect(guidance!.validation!.generalValidation).to.include('General validation'); + } + if (guidance!.validation!.requirements) { + expect(guidance!.validation!.requirements).to.include('All validations'); + } + } + }); + + it('should throw when file does not exist', () => { + expect(() => siteThemingStore.loadFile('missing', path.join(testDir, 'does-not-exist.md'))).to.throw( + /File not found|Failed to load/, + ); + }); + + it('should parse file without workflow or validation sections', () => { + const filePath = path.join(testDir, 'minimal.md'); + writeFileSync( + filePath, + `# Minimal Theming +No workflow or validation sections. +## ⚠️ CRITICAL: Test +Some critical content. +### What TO Change: +- color +### What NOT to Change: +- layout`, + 'utf8', + ); + + siteThemingStore.loadFile('minimal', filePath); + const guidance = siteThemingStore.get('minimal'); + + expect(guidance).to.exist; + expect(guidance!.guidelines).to.have.lengthOf.at.least(1); + expect(guidance!.rules).to.have.lengthOf.at.least(1); + }); + + it('should generate fallback questions when no questions extracted', () => { + const filePath = path.join(testDir, 'color-only.md'); + writeFileSync( + filePath, + `# Color Only +Content about color theming. No explicit questions. +## ⚠️ CRITICAL: Colors +Use exact hex values. +### What TO Change: +- background-color +### What NOT to Change: +- margin`, + 'utf8', + ); + + siteThemingStore.loadFile('color-only', filePath); + const guidance = siteThemingStore.get('color-only'); + + expect(guidance).to.exist; + expect(guidance!.questions.length).to.be.greaterThan(0); + const colorQ = guidance!.questions.find((q) => q.category === 'colors'); + expect(colorQ).to.exist; + }); + + it('should generate font fallback question when content has font', () => { + const filePath = path.join(testDir, 'font-only.md'); + writeFileSync( + filePath, + `# Font Only +Typography and font styling. No workflow. +### What TO Change: +- font-size +### What NOT to Change: +- width`, + 'utf8', + ); + + siteThemingStore.loadFile('font-only', filePath); + const guidance = siteThemingStore.get('font-only'); + + expect(guidance).to.exist; + const fontQ = guidance!.questions.find((q) => q.category === 'typography'); + expect(fontQ).to.exist; + }); + + it('should handle THEMING_FILES with non-existent path', () => { + process.env.THEMING_FILES = JSON.stringify([{key: 'missing-env', path: 'does-not-exist.md'}]); + + siteThemingStore.initialize(testDir); + + expect(siteThemingStore.has('missing-env')).to.be.false; + }); + + it('should handle THEMING_FILES with invalid JSON', () => { + const customPath = path.join(testDir, 'valid.md'); + writeFileSync(customPath, '# Valid\n### What TO Change:\n- x\n### What NOT to Change:\n- y', 'utf8'); + process.env.THEMING_FILES = 'invalid-json'; + + siteThemingStore.initialize(testDir); + + expect(siteThemingStore.has('valid')).to.be.false; + }); + + it('should resolve absolute paths in THEMING_FILES', () => { + const customPath = path.join(testDir, 'absolute-theming.md'); + writeFileSync( + customPath, + `# Absolute Path Test +## ⚠️ CRITICAL: Test +Content. +### What TO Change: +- color +### What NOT to Change: +- layout`, + 'utf8', + ); + + process.env.THEMING_FILES = JSON.stringify([{key: 'absolute-theming', path: customPath}]); + + siteThemingStore.initialize(testDir); + + expect(siteThemingStore.has('absolute-theming')).to.be.true; + }); + + it('should extract and merge questions from content lines ending with ?', () => { + const filePath = path.join(testDir, 'questions-extracted.md'); + writeFileSync( + filePath, + `# Questions Test +## ⚠️ CRITICAL: Use exact hex +Use exact hex code values. +### What TO Change: +- color +- background-color +### What NOT to Change: +- margin + +- What are your primary brand colors? +- What font family for headings and body? +- Do you need dark mode support?`, + 'utf8', + ); + + siteThemingStore.loadFile('questions-extracted', filePath); + const guidance = siteThemingStore.get('questions-extracted'); + + expect(guidance).to.exist; + expect(guidance!.questions.length).to.be.greaterThan(0); + const hasColorQ = guidance!.questions.some((q) => q.question.includes('brand colors')); + const hasFontQ = guidance!.questions.some((q) => q.question.includes('font family')); + const hasGeneralQ = guidance!.questions.some((q) => q.question.includes('dark mode')); + expect(hasColorQ || hasFontQ || hasGeneralQ).to.be.true; + }); + + it('should handle THEMING_FILES path that exists but cannot be read', () => { + const subDir = path.join(testDir, 'subdir'); + mkdirSync(subDir, {recursive: true}); + process.env.THEMING_FILES = JSON.stringify([{key: 'dir-as-file', path: subDir}]); + + siteThemingStore.initialize(testDir); + + expect(siteThemingStore.has('dir-as-file')).to.be.false; + }); + + it('should generate layout question when content allows layout changes', () => { + const filePath = path.join(testDir, 'layout-changes.md'); + writeFileSync( + filePath, + `# Layout Test +## ⚠️ CRITICAL: Layout +Layout changes are allowed when explicitly requested. +### What TO Change: +- color +### What NOT to Change: +- position + +When layout modifications are needed, they should be explicitly requested by the user.`, + 'utf8', + ); + + siteThemingStore.loadFile('layout-changes', filePath); + const guidance = siteThemingStore.get('layout-changes'); + + expect(guidance).to.exist; + const layoutQ = guidance!.questions.find((q) => q.category === 'general' && q.question.includes('layout')); + expect(layoutQ).to.exist; + }); + + it('should generate font question for headings and body when content has font usage', () => { + const filePath = path.join(testDir, 'font-usage.md'); + writeFileSync( + filePath, + `# Font Usage +## ⚠️ CRITICAL: Typography +Use exact font name. Font apply to headings and body. +### What TO Change: +- font-size +- font-weight +### What NOT to Change: +- width`, + 'utf8', + ); + + siteThemingStore.loadFile('font-usage', filePath); + const guidance = siteThemingStore.get('font-usage'); + + expect(guidance).to.exist; + const fontQ = guidance!.questions.find( + (q) => q.category === 'typography' && q.question.includes('headings and body'), + ); + expect(fontQ).to.exist; + }); + + it('should generate color questions for color combinations and dark/light when content has both', () => { + const filePath = path.join(testDir, 'color-combos.md'); + writeFileSync( + filePath, + `# Color Combos +## ⚠️ CRITICAL: Colors +Propose color combinations. Use exact hex. Dark and light themes. +### What TO Change: +- color +- background-color +### What NOT to Change: +- margin`, + 'utf8', + ); + + siteThemingStore.loadFile('color-combos', filePath); + const guidance = siteThemingStore.get('color-combos'); + + expect(guidance).to.exist; + const primaryQ = guidance!.questions.find((q) => q.question.includes('primary actions')); + const hoverQ = guidance!.questions.find((q) => q.question.includes('hover state')); + const darkLightQ = guidance!.questions.find((q) => q.question.includes('light and dark')); + expect(primaryQ || hoverQ || darkLightQ).to.exist; + }); + + it('should generate primary/hover questions when content has color combinations', () => { + const filePath = path.join(testDir, 'color-combos-only.md'); + writeFileSync( + filePath, + `# Color Combos Only +## ⚠️ CRITICAL: Colors +Propose color combinations for buttons and links. +### What TO Change: +- color +- background-color +### What NOT to Change: +- margin`, + 'utf8', + ); + + siteThemingStore.loadFile('color-combos-only', filePath); + const guidance = siteThemingStore.get('color-combos-only'); + + expect(guidance).to.exist; + const primaryQ = guidance!.questions.find((q) => q.question.includes('primary actions')); + const hoverQ = guidance!.questions.find((q) => q.question.includes('hover state')); + expect(primaryQ).to.exist; + expect(hoverQ).to.exist; + }); + + it('should generate font question for Google Fonts when content has font availability', () => { + const filePath = path.join(testDir, 'font-availability.md'); + writeFileSync( + filePath, + `# Font Availability +## ⚠️ CRITICAL: Fonts +Use exact font name. Check font availability and Google Fonts. +### What TO Change: +- font-size +### What NOT to Change: +- width`, + 'utf8', + ); + + siteThemingStore.loadFile('font-availability', filePath); + const guidance = siteThemingStore.get('font-availability'); + + expect(guidance).to.exist; + const fontQ = guidance!.questions.find((q) => q.question.includes('Google Fonts')); + expect(fontQ).to.exist; + }); + + it('should assign required to first extracted color/font question when no generated questions', () => { + const filePath = path.join(testDir, 'extracted-only.md'); + writeFileSync( + filePath, + `# Extracted Only +## 📋 Specification +Follow user specs exactly. +### What TO Change: +- opacity +### What NOT to Change: +- display + +- What are your primary brand colors? +- What font family for headings?`, + 'utf8', + ); + + siteThemingStore.loadFile('extracted-only', filePath); + const guidance = siteThemingStore.get('extracted-only'); + + expect(guidance).to.exist; + const colorQ = guidance!.questions.find((q) => q.question.includes('brand colors')); + const fontQ = guidance!.questions.find((q) => q.question.includes('font family')); + expect(colorQ?.required).to.be.true; + expect(fontQ?.required).to.be.true; + }); + + it('should skip re-initialization when same root', () => { + siteThemingStore.initialize(testDir); + const keysFirst = siteThemingStore.getKeys(); + + siteThemingStore.initialize(testDir); + const keysSecond = siteThemingStore.getKeys(); + + expect(keysFirst).to.deep.equal(keysSecond); + }); + + it('should clear and re-load when root changes', () => { + delete process.env.THEMING_FILES; + const otherDir = path.join(tmpdir(), `b2c-theming-other-${Date.now()}`); + mkdirSync(otherDir, {recursive: true}); + try { + const customPath = path.join(testDir, 'first-root.md'); + writeFileSync(customPath, '# First\n### What TO Change:\n- x\n### What NOT to Change:\n- y', 'utf8'); + process.env.THEMING_FILES = JSON.stringify([{key: 'first-root', path: path.relative(testDir, customPath)}]); + siteThemingStore.initialize(testDir); + + expect(siteThemingStore.has('first-root')).to.be.true; + + const customPath2 = path.join(otherDir, 'second-root.md'); + writeFileSync(customPath2, '# Second\n### What TO Change:\n- x\n### What NOT to Change:\n- y', 'utf8'); + process.env.THEMING_FILES = JSON.stringify([{key: 'second-root', path: customPath2}]); + siteThemingStore.initialize(otherDir); + + expect(siteThemingStore.has('first-root')).to.be.false; + expect(siteThemingStore.has('second-root')).to.be.true; + } finally { + if (existsSync(otherDir)) { + rmSync(otherDir, {recursive: true, force: true}); + } + } + }); + + it('should log and continue when default file fails to load', () => { + const fakeContentDir = path.join(testDir, 'fake-content', 'site-theming'); + mkdirSync(fakeContentDir, {recursive: true}); + copyFileSync( + path.join(defaultContentDir, 'theming-validation.md'), + path.join(fakeContentDir, 'theming-validation.md'), + ); + copyFileSync( + path.join(defaultContentDir, 'theming-accessibility.md'), + path.join(fakeContentDir, 'theming-accessibility.md'), + ); + mkdirSync(path.join(fakeContentDir, 'theming-questions.md'), {recursive: true}); + + siteThemingStore.initialize(testDir, {contentDirOverride: fakeContentDir}); + + expect(siteThemingStore.has('theming-validation')).to.be.true; + expect(siteThemingStore.has('theming-accessibility')).to.be.true; + expect(siteThemingStore.has('theming-questions')).to.be.false; + }); + }); +});