commit 280d358cb69c0268128f325da6656f6ba2c5750a Author: Zhongwei Li Date: Sat Nov 29 18:46:28 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..cf26d07 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "markdown-editor-integrator", + "description": "This skill should be used when installing and configuring markdown editor functionality using @uiw/react-md-editor. Applies when adding rich text editing, markdown support, WYSIWYG editors, content editing with preview, or text formatting features. Trigger terms include markdown editor, rich text editor, text editor, add markdown, install markdown editor, markdown component, WYSIWYG, content editor, text formatting, editor preview.", + "version": "1.0.0", + "author": { + "name": "Hope Overture", + "email": "support@worldbuilding-app-skills.dev" + }, + "skills": [ + "./skills" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0710f77 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# markdown-editor-integrator + +This skill should be used when installing and configuring markdown editor functionality using @uiw/react-md-editor. Applies when adding rich text editing, markdown support, WYSIWYG editors, content editing with preview, or text formatting features. Trigger terms include markdown editor, rich text editor, text editor, add markdown, install markdown editor, markdown component, WYSIWYG, content editor, text formatting, editor preview. diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..ca64437 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,61 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:hopeoverture/worldbuilding-app-skills:plugins/markdown-editor-integrator", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "f96d521eee98f1e28aaa4d8500db4c0a7dce930c", + "treeHash": "8d21d930bcec85893a4dc9b9c0f634e0c6d800a2bd38fe292d11e7062163afc3", + "generatedAt": "2025-11-28T10:17:34.760894Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "markdown-editor-integrator", + "description": "This skill should be used when installing and configuring markdown editor functionality using @uiw/react-md-editor. Applies when adding rich text editing, markdown support, WYSIWYG editors, content editing with preview, or text formatting features. Trigger terms include markdown editor, rich text editor, text editor, add markdown, install markdown editor, markdown component, WYSIWYG, content editor, text formatting, editor preview.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "29b330a75dc8e48d0a159956f0c8b6ae929a6ccba1640740e284a77fba81f3c0" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "651fb76b513703395f014dfbc1b3bddda4643b545b047ddc23358af0cdf4b476" + }, + { + "path": "skills/markdown-editor-integrator/SKILL.md", + "sha256": "ecff7d669db1abcd8362feb4ec21443a3615acf0f82131bc2597975e78b3550a" + }, + { + "path": "skills/markdown-editor-integrator/references/theme-integration.md", + "sha256": "932d1cd24c298b6c307b192793da6ab4215052a38dd971e911b170fe29bd88de" + }, + { + "path": "skills/markdown-editor-integrator/references/sanitization.md", + "sha256": "a32301c08715a3865cae2dc633092f58775391f01b6b8fa31d0d0f03082edbe7" + }, + { + "path": "skills/markdown-editor-integrator/assets/MarkdownEditor.tsx", + "sha256": "0cf7dac8d1b6e1c26a2b8df96e6525698d92db3fab1c225cb4427845d7b3d4da" + }, + { + "path": "skills/markdown-editor-integrator/assets/MarkdownPreview.tsx", + "sha256": "4e46cb5edca463cdfd6da0a4bf1bb9898554db63d3dc131739186463b399d49c" + } + ], + "dirSha256": "8d21d930bcec85893a4dc9b9c0f634e0c6d800a2bd38fe292d11e7062163afc3" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/markdown-editor-integrator/SKILL.md b/skills/markdown-editor-integrator/SKILL.md new file mode 100644 index 0000000..c0a2a3b --- /dev/null +++ b/skills/markdown-editor-integrator/SKILL.md @@ -0,0 +1,1002 @@ +--- +name: markdown-editor-integrator +description: This skill should be used when installing and configuring markdown editor functionality using @uiw/react-md-editor. Applies when adding rich text editing, markdown support, WYSIWYG editors, content editing with preview, or text formatting features. Trigger terms include markdown editor, rich text editor, text editor, add markdown, install markdown editor, markdown component, WYSIWYG, content editor, text formatting, editor preview. +--- + +# Markdown Editor Integrator + +Install and configure @uiw/react-md-editor with theme integration, server-side sanitization, controlled/uncontrolled modes, and proper persistence for worldbuilding content. + +## When to Use This Skill + +Apply this skill when: +- Adding markdown editing capability to forms +- Creating rich text editing for entity descriptions +- Building content management features +- Adding WYSIWYG editing with markdown preview +- Implementing text formatting for character bios, location descriptions, lore entries +- Setting up markdown support for notes and documentation +- Creating editing interfaces for narrative content + +## Overview + +@uiw/react-md-editor is a React markdown editor with: +- Live preview with split/edit/preview modes +- Syntax highlighting +- Markdown shortcuts and toolbar +- Theme customization +- No SSR issues +- TypeScript support + +## Installation Process + +### Step 1: Install Dependencies + +```bash +npm install @uiw/react-md-editor +``` + +For sanitization (security): +```bash +npm install rehype-sanitize +``` + +### Step 2: Configure Next.js (if using) + +Add to next.config.js to avoid SSR issues: + +```javascript +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Other config... + webpack: (config) => { + config.resolve.alias = { + ...config.resolve.alias, + '@uiw/react-md-editor': '@uiw/react-md-editor', + } + return config + }, +} + +module.exports = nextConfig +``` + +### Step 3: Create Editor Component + +Create wrapper component at `components/MarkdownEditor.tsx`: + +See assets/MarkdownEditor.tsx for full implementation. + +### Step 4: Create Preview Component + +Create preview component at `components/MarkdownPreview.tsx`: + +See assets/MarkdownPreview.tsx for full implementation. + +### Step 5: Integrate Theme Styling + +Configure editor to match shadcn/ui theme: + +See references/theme-integration.md for detailed theming. + +### Step 6: Add Server-Side Sanitization + +Implement sanitization for security: + +See references/sanitization.md for implementation details. + +## Basic Usage Patterns + +### Controlled Mode (Recommended for Forms) + +```tsx +'use client' + +import { useState } from 'react' +import { MarkdownEditor } from '@/components/MarkdownEditor' +import { Button } from '@/components/ui/button' + +export function CharacterBioForm() { + const [bio, setBio] = useState('') + + async function handleSubmit() { + await saveCharacter({ bio }) + } + + return ( +
+
+ + setBio(value || '')} + height={400} + /> +
+ +
+ ) +} +``` + +### With React Hook Form + +```tsx +'use client' + +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { MarkdownEditor } from '@/components/MarkdownEditor' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' + +const schema = z.object({ + description: z.string().min(1, 'Description required').max(10000) +}) + +type FormValues = z.infer + +export function LocationForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + description: '' + } + }) + + function onSubmit(values: FormValues) { + console.log(values) + } + + return ( +
+ + ( + + Description + + field.onChange(value || '')} + height={400} + /> + + + + )} + /> + + + + ) +} +``` + +### Preview Mode (Display Only) + +```tsx +import { MarkdownPreview } from '@/components/MarkdownPreview' + +export function CharacterProfile({ character }) { + return ( +
+

{character.name}

+
+ +
+
+ ) +} +``` + +### Uncontrolled Mode + +```tsx +'use client' + +import { useRef } from 'react' +import MDEditor from '@uiw/react-md-editor' + +export function QuickNoteEditor() { + const editorRef = useRef(null) + + function handleSave() { + // Access value from ref if needed + } + + return ( + + ) +} +``` + +## Configuration Options + +### Height Control + +```tsx +// Fixed height + + +// Dynamic height + + +// Auto height + +``` + +### Hide Toolbar + +```tsx + +``` + +### Preview Mode + +```tsx + +``` + +### Disable Preview + +```tsx + +``` + +### Custom Commands + +```tsx +import { commands } from '@uiw/react-md-editor' + + +``` + +### Extra Commands + +```tsx + +``` + +## Theme Integration + +### Match shadcn/ui Theme + +```tsx +'use client' + +import { useTheme } from 'next-themes' +import MDEditor from '@uiw/react-md-editor' + +export function ThemedMarkdownEditor({ value, onChange }) { + const { theme } = useTheme() + + return ( +
+ +
+ ) +} +``` + +### Custom Styling + +```css +/* globals.css or component CSS */ + +.w-md-editor { + @apply rounded-md border border-input bg-background; +} + +.w-md-editor-toolbar { + @apply border-b border-border bg-muted/50; +} + +.w-md-editor-toolbar button { + @apply text-foreground hover:bg-accent hover:text-accent-foreground; +} + +.w-md-editor-content { + @apply text-foreground; +} + +.w-md-editor-preview { + @apply prose prose-sm dark:prose-invert max-w-none; +} + +.wmde-markdown { + @apply bg-background text-foreground; +} + +/* Code blocks */ +.w-md-editor-preview pre { + @apply bg-muted; +} + +.w-md-editor-preview code { + @apply text-primary; +} +``` + +## Sanitization for Security + +### Client-Side Sanitization + +```tsx +import MDEditor from '@uiw/react-md-editor' +import rehypeSanitize from 'rehype-sanitize' + + +``` + +### Server-Side Sanitization + +```typescript +// lib/sanitize-markdown.ts +import { remark } from 'remark' +import remarkHtml from 'remark-html' +import { sanitize } from 'isomorphic-dompurify' + +export async function sanitizeMarkdown(markdown: string): Promise { + // Convert markdown to HTML + const result = await remark() + .use(remarkHtml) + .process(markdown) + + const html = result.toString() + + // Sanitize HTML + const clean = sanitize(html, { + ALLOWED_TAGS: [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'strong', 'em', 'u', 's', + 'ul', 'ol', 'li', + 'blockquote', 'code', 'pre', + 'a', 'img', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'hr', 'div', 'span' + ], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'], + ALLOW_DATA_ATTR: false, + }) + + return clean +} + +// Server action +'use server' + +export async function saveEntityDescription(entityId: string, markdown: string) { + // Sanitize before saving + const sanitized = await sanitizeMarkdown(markdown) + + await db.entity.update({ + where: { id: entityId }, + data: { description: sanitized } + }) + + return { success: true } +} +``` + +## Persistence Patterns + +### Auto-Save Draft + +```tsx +'use client' + +import { useEffect } from 'react' +import { useDebouncedCallback } from 'use-debounce' +import { MarkdownEditor } from '@/components/MarkdownEditor' + +export function DraftEditor({ entityId, initialContent }) { + const [content, setContent] = useState(initialContent) + + const saveDraft = useDebouncedCallback(async (value: string) => { + await fetch(`/api/drafts/${entityId}`, { + method: 'POST', + body: JSON.stringify({ content: value }) + }) + }, 1000) + + useEffect(() => { + if (content !== initialContent) { + saveDraft(content) + } + }, [content]) + + return ( +
+ setContent(val || '')} + height={500} + /> +

+ Auto-saving drafts... +

+
+ ) +} +``` + +### Local Storage Persistence + +```tsx +'use client' + +import { useEffect, useState } from 'react' +import { MarkdownEditor } from '@/components/MarkdownEditor' + +export function LocalEditor({ storageKey = 'editor-content' }) { + const [content, setContent] = useState('') + const [loaded, setLoaded] = useState(false) + + // Load from localStorage on mount + useEffect(() => { + const saved = localStorage.getItem(storageKey) + if (saved) { + setContent(saved) + } + setLoaded(true) + }, [storageKey]) + + // Save to localStorage on change + useEffect(() => { + if (loaded) { + localStorage.setItem(storageKey, content) + } + }, [content, loaded, storageKey]) + + if (!loaded) { + return
Loading...
+ } + + return ( + setContent(val || '')} + height={400} + /> + ) +} +``` + +### Database Persistence with Optimistic Update + +```tsx +'use client' + +import { useState } from 'react' +import { MarkdownEditor } from '@/components/MarkdownEditor' +import { Button } from '@/components/ui/button' +import { toast } from 'sonner' + +export function EntityDescriptionEditor({ entityId, initialDescription }) { + const [description, setDescription] = useState(initialDescription) + const [isSaving, setIsSaving] = useState(false) + + async function handleSave() { + setIsSaving(true) + + try { + const result = await saveDescription(entityId, description) + + if (result.success) { + toast.success('Saved successfully') + } else { + toast.error('Failed to save') + } + } catch (error) { + toast.error('An error occurred') + } finally { + setIsSaving(false) + } + } + + return ( +
+ setDescription(val || '')} + height={500} + /> +
+ + +
+
+ ) +} +``` + +## Worldbuilding-Specific Use Cases + +### Character Biography Editor + +```tsx +export function CharacterBiographyEditor({ characterId, initialBio }) { + return ( +
+

Biography

+

+ Write the character's backstory, personality, and key events. + Supports markdown formatting. +

+ updateCharacterBio(characterId, val)} + height={600} + /> +
+ ) +} +``` + +### Location Description Editor + +```tsx +export function LocationDescriptionEditor({ locationId, initialDesc }) { + return ( +
+

Description

+ updateLocationDesc(locationId, val)} + height={500} + commands={[ + // Customize toolbar for location descriptions + commands.bold, + commands.italic, + commands.hr, + commands.link, + commands.quote, + commands.unorderedListCommand, + commands.orderedListCommand, + ]} + /> +
+ ) +} +``` + +### Lore Entry Editor + +```tsx +export function LoreEntryEditor() { + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + const [tags, setTags] = useState([]) + + return ( +
+ setTitle(e.target.value)} + /> + + + +
+ + setContent(val || '')} + height={500} + /> +
+ + +
+ ) +} +``` + +### Timeline Event Description + +```tsx +export function EventDescriptionEditor({ eventId, initialDesc }) { + return ( + ( + + Event Description + + field.onChange(val || '')} + height={350} + /> + + + Describe what happened during this event + + + + )} + /> + ) +} +``` + +### Item/Artifact History + +```tsx +export function ArtifactHistoryEditor({ artifactId, history }) { + return ( +
+

Artifact History

+ updateArtifactHistory(artifactId, val)} + height={400} + preview="live" + /> +
+ ) +} +``` + +## Advanced Features + +### Custom Toolbar Buttons + +```tsx +import { commands, ICommand } from '@uiw/react-md-editor' + +const customCommand: ICommand = { + name: 'custom', + keyCommand: 'custom', + buttonProps: { 'aria-label': 'Insert custom text' }, + icon: ( + Custom + ), + execute: (state, api) => { + const modifyText = `Custom text: ${state.selectedText}` + api.replaceSelection(modifyText) + }, +} + + +``` + +### Image Upload Handler + +```tsx +'use client' + +import { useState } from 'react' +import MDEditor from '@uiw/react-md-editor' + +export function EditorWithImageUpload() { + const [content, setContent] = useState('') + + async function handlePaste(event: ClipboardEvent) { + const items = event.clipboardData?.items + if (!items) return + + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + event.preventDefault() + + const file = items[i].getAsFile() + if (!file) continue + + // Upload image + const formData = new FormData() + formData.append('file', file) + + const response = await fetch('/api/upload', { + method: 'POST', + body: formData, + }) + + const { url } = await response.json() + + // Insert markdown image + setContent(prev => `${prev}\n![Image](${url})\n`) + } + } + } + + return ( +
+ setContent(val || '')} + height={500} + /> +
+ ) +} +``` + +### Word Count Display + +```tsx +'use client' + +import { useMemo } from 'react' +import { MarkdownEditor } from '@/components/MarkdownEditor' + +export function EditorWithWordCount({ value, onChange }) { + const wordCount = useMemo(() => { + return value.trim().split(/\s+/).filter(Boolean).length + }, [value]) + + const charCount = value.length + + return ( +
+ +
+ {wordCount} words + {charCount} characters +
+
+ ) +} +``` + +### Version History + +```tsx +'use client' + +import { useState } from 'react' +import { MarkdownEditor } from '@/components/MarkdownEditor' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +interface Version { + id: string + content: string + createdAt: Date + author: string +} + +export function EditorWithHistory({ versions }: { versions: Version[] }) { + const [current, setCurrent] = useState(versions[0]?.content || '') + const [selectedVersion, setSelectedVersion] = useState(null) + + function loadVersion(versionId: string) { + const version = versions.find(v => v.id === versionId) + if (version) { + setCurrent(version.content) + setSelectedVersion(versionId) + } + } + + return ( +
+
+ Version History: + +
+ + setCurrent(val || '')} + height={500} + /> +
+ ) +} +``` + +## Troubleshooting + +### Issue: Hydration Mismatch in Next.js + +**Solution:** Use dynamic import with ssr: false + +```tsx +import dynamic from 'next/dynamic' + +const MDEditor = dynamic( + () => import('@uiw/react-md-editor'), + { ssr: false } +) +``` + +### Issue: Theme Not Updating + +**Solution:** Wrap in div with data-color-mode + +```tsx +
+ +
+``` + +### Issue: Toolbar Not Visible + +**Solution:** Import CSS in layout or page + +```tsx +import '@uiw/react-md-editor/dist/markdown-editor.css' +import '@uiw/react-markdown-preview/dist/markdown.css' +``` + +### Issue: onChange Not Firing + +**Solution:** Ensure using controlled mode with value prop + +```tsx +// Correct + + +// Incorrect + +``` + +## Testing + +### Unit Testing + +```tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MarkdownEditor } from './MarkdownEditor' + +describe('MarkdownEditor', () => { + it('renders with initial value', () => { + render( {}} />) + expect(screen.getByText('Initial content')).toBeInTheDocument() + }) + + it('calls onChange when content changes', async () => { + const onChange = vi.fn() + render() + + const textarea = screen.getByRole('textbox') + await userEvent.type(textarea, 'New content') + + expect(onChange).toHaveBeenCalled() + }) +}) +``` + +## Performance Considerations + +- Use dynamic import for Next.js SSR +- Debounce onChange for auto-save +- Memoize preview rendering for large documents +- Lazy load editor for tabs/modals +- Consider virtualization for very long documents + +## Resources + +- **assets/MarkdownEditor.tsx** - Complete editor component +- **assets/MarkdownPreview.tsx** - Preview component +- **references/theme-integration.md** - Detailed theming guide +- **references/sanitization.md** - Security best practices + +## Implementation Checklist + +- [ ] Install @uiw/react-md-editor +- [ ] Install rehype-sanitize for security +- [ ] Create MarkdownEditor wrapper component +- [ ] Create MarkdownPreview component +- [ ] Integrate with shadcn/ui theme +- [ ] Add CSS imports in layout +- [ ] Configure Next.js if needed +- [ ] Implement server-side sanitization +- [ ] Add to form components +- [ ] Test in different themes +- [ ] Add persistence (auto-save/draft) +- [ ] Test accessibility +- [ ] Document custom commands if needed diff --git a/skills/markdown-editor-integrator/assets/MarkdownEditor.tsx b/skills/markdown-editor-integrator/assets/MarkdownEditor.tsx new file mode 100644 index 0000000..32ba550 --- /dev/null +++ b/skills/markdown-editor-integrator/assets/MarkdownEditor.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useTheme } from 'next-themes' +import dynamic from 'next/dynamic' +import { ComponentProps } from 'react' +import rehypeSanitize from 'rehype-sanitize' +import '@uiw/react-md-editor/markdown-editor.css' +import '@uiw/react-markdown-preview/markdown.css' + +const MDEditor = dynamic( + () => import('@uiw/react-md-editor'), + { ssr: false } +) + +type MDEditorProps = ComponentProps + +interface MarkdownEditorProps extends Omit { + value: string + onChange?: (value?: string) => void + height?: number | string + hideToolbar?: boolean + enablePreview?: boolean + preview?: 'edit' | 'live' | 'preview' +} + +/** + * Markdown Editor component with theme integration and sanitization + * + * Features: + * - Automatic theme switching (light/dark) + * - XSS protection with rehype-sanitize + * - Controlled component for forms + * - Customizable toolbar and preview + * - Next.js SSR compatible + * + * @example + * ```tsx + * const [content, setContent] = useState('') + * + * + * ``` + */ +export function MarkdownEditor({ + value, + onChange, + height = 400, + hideToolbar = false, + enablePreview = true, + preview = 'live', + ...props +}: MarkdownEditorProps) { + const { theme } = useTheme() + + return ( +
+ +
+ ) +} + +// CSS for theme integration (add to globals.css) +const editorStyles = ` +/* Markdown Editor Theme Integration */ + +.markdown-editor-wrapper { + @apply rounded-md border border-input; +} + +.w-md-editor { + @apply bg-background text-foreground shadow-none; + border: none !important; +} + +.w-md-editor-toolbar { + @apply border-b border-border bg-muted/30; +} + +.w-md-editor-toolbar button, +.w-md-editor-toolbar li button { + @apply text-foreground hover:bg-accent hover:text-accent-foreground; +} + +.w-md-editor-toolbar li.active button { + @apply bg-accent text-accent-foreground; +} + +.w-md-editor-content { + @apply text-foreground; +} + +.w-md-editor-preview { + @apply prose prose-sm dark:prose-invert max-w-none p-4; +} + +.w-md-editor-text-pre, +.w-md-editor-text-input { + @apply text-foreground; +} + +.w-md-editor-text-pre > code, +.w-md-editor-text-input > code { + @apply text-foreground; +} + +/* Code blocks in preview */ +.wmde-markdown { + @apply bg-transparent text-foreground; +} + +.wmde-markdown pre { + @apply bg-muted; +} + +.wmde-markdown code { + @apply text-primary; +} + +.wmde-markdown blockquote { + @apply border-l-primary/50; +} + +.wmde-markdown a { + @apply text-primary hover:underline; +} + +/* Tables in preview */ +.wmde-markdown table { + @apply border-border; +} + +.wmde-markdown th, +.wmde-markdown td { + @apply border-border; +} + +.wmde-markdown th { + @apply bg-muted; +} +` + +// Export styles constant for documentation +export { editorStyles } diff --git a/skills/markdown-editor-integrator/assets/MarkdownPreview.tsx b/skills/markdown-editor-integrator/assets/MarkdownPreview.tsx new file mode 100644 index 0000000..f00be64 --- /dev/null +++ b/skills/markdown-editor-integrator/assets/MarkdownPreview.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useTheme } from 'next-themes' +import dynamic from 'next/dynamic' +import { ComponentProps } from 'react' +import rehypeSanitize from 'rehype-sanitize' +import '@uiw/react-markdown-preview/markdown.css' + +const MDPreview = dynamic( + () => import('@uiw/react-markdown-preview'), + { ssr: false } +) + +type MDPreviewProps = ComponentProps + +interface MarkdownPreviewProps extends Omit { + content: string + className?: string +} + +/** + * Markdown Preview component for displaying formatted markdown + * + * Features: + * - Automatic theme switching + * - XSS protection with rehype-sanitize + * - GitHub-flavored markdown support + * - Syntax highlighting for code blocks + * - Next.js SSR compatible + * + * @example + * ```tsx + * + * ``` + */ +export function MarkdownPreview({ + content, + className = '', + ...props +}: MarkdownPreviewProps) { + const { theme } = useTheme() + + return ( +
+ +
+ ) +} + +// CSS for preview theme integration (add to globals.css) +const previewStyles = ` +/* Markdown Preview Theme Integration */ + +.markdown-preview-wrapper { + @apply rounded-md; +} + +.markdown-preview-wrapper .wmde-markdown { + @apply bg-transparent text-foreground; + font-family: inherit; +} + +/* Typography */ +.markdown-preview-wrapper .wmde-markdown h1, +.markdown-preview-wrapper .wmde-markdown h2, +.markdown-preview-wrapper .wmde-markdown h3, +.markdown-preview-wrapper .wmde-markdown h4, +.markdown-preview-wrapper .wmde-markdown h5, +.markdown-preview-wrapper .wmde-markdown h6 { + @apply text-foreground font-semibold; + border-bottom: none; +} + +.markdown-preview-wrapper .wmde-markdown p { + @apply text-foreground leading-7; +} + +/* Links */ +.markdown-preview-wrapper .wmde-markdown a { + @apply text-primary hover:underline; +} + +/* Lists */ +.markdown-preview-wrapper .wmde-markdown ul, +.markdown-preview-wrapper .wmde-markdown ol { + @apply text-foreground; +} + +/* Blockquotes */ +.markdown-preview-wrapper .wmde-markdown blockquote { + @apply border-l-4 border-primary/50 bg-muted/50 text-muted-foreground italic; +} + +/* Code */ +.markdown-preview-wrapper .wmde-markdown code { + @apply bg-muted text-primary px-1.5 py-0.5 rounded text-sm; +} + +.markdown-preview-wrapper .wmde-markdown pre { + @apply bg-muted rounded-md p-4 overflow-x-auto; +} + +.markdown-preview-wrapper .wmde-markdown pre code { + @apply bg-transparent p-0; +} + +/* Tables */ +.markdown-preview-wrapper .wmde-markdown table { + @apply border-collapse w-full border border-border; +} + +.markdown-preview-wrapper .wmde-markdown th { + @apply bg-muted font-semibold text-left p-2 border border-border; +} + +.markdown-preview-wrapper .wmde-markdown td { + @apply p-2 border border-border; +} + +.markdown-preview-wrapper .wmde-markdown tr:nth-child(even) { + @apply bg-muted/30; +} + +/* Horizontal Rule */ +.markdown-preview-wrapper .wmde-markdown hr { + @apply border-border my-4; +} + +/* Images */ +.markdown-preview-wrapper .wmde-markdown img { + @apply rounded-md max-w-full h-auto; +} + +/* Task Lists */ +.markdown-preview-wrapper .wmde-markdown input[type="checkbox"] { + @apply mr-2; +} +` + +// Export styles constant for documentation +export { previewStyles } diff --git a/skills/markdown-editor-integrator/references/sanitization.md b/skills/markdown-editor-integrator/references/sanitization.md new file mode 100644 index 0000000..c4dda33 --- /dev/null +++ b/skills/markdown-editor-integrator/references/sanitization.md @@ -0,0 +1,573 @@ +# Markdown Sanitization Security Guide + +## Why Sanitization is Critical + +Markdown editors can be exploited for XSS attacks through: +- Malicious JavaScript in HTML tags +- Script injection via event handlers +- Data exfiltration through image sources +- Link-based phishing attacks +- Iframe injection + +## Client-Side Sanitization + +### Using rehype-sanitize + +```bash +npm install rehype-sanitize +``` + +```tsx +import MDEditor from '@uiw/react-md-editor' +import rehypeSanitize from 'rehype-sanitize' + + +``` + +### Custom Sanitization Schema + +```tsx +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' +import { deepmerge } from 'deepmerge-ts' + +const customSchema = deepmerge(defaultSchema, { + attributes: { + '*': ['className'], // Allow className on all elements + a: ['href', 'title', 'target', 'rel'], + img: ['src', 'alt', 'title', 'width', 'height'], + code: ['className'], // For syntax highlighting + }, + tagNames: [ + // Standard markdown + 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'strong', 'em', 'u', 's', 'del', 'ins', + 'ul', 'ol', 'li', + 'blockquote', 'code', 'pre', + 'a', 'img', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'hr', 'div', 'span', + // Additional if needed + 'kbd', 'mark', 'abbr', + ], + protocols: { + href: ['http', 'https', 'mailto'], + src: ['http', 'https'], + }, +}) + + +``` + +### Strict Sanitization (No HTML) + +```tsx +import rehypeSanitize from 'rehype-sanitize' + +const strictSchema = { + tagNames: [ + 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'strong', 'em', 'code', 'pre', + 'ul', 'ol', 'li', + 'blockquote', + 'a', + 'hr', + ], + attributes: { + a: ['href'], + code: ['className'], + }, + protocols: { + href: ['https'], // Only HTTPS links + }, +} + + +``` + +## Server-Side Sanitization + +### Using DOMPurify + +```bash +npm install isomorphic-dompurify +``` + +```typescript +// lib/sanitize-markdown.ts +import DOMPurify from 'isomorphic-dompurify' + +export function sanitizeMarkdown(markdown: string): string { + return DOMPurify.sanitize(markdown, { + ALLOWED_TAGS: [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'strong', 'em', 'u', 's', + 'ul', 'ol', 'li', + 'blockquote', 'code', 'pre', + 'a', 'img', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'hr', 'div', 'span', + ], + ALLOWED_ATTR: [ + 'href', 'src', 'alt', 'title', + 'class', 'id', + 'width', 'height', + ], + ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i, + KEEP_CONTENT: true, + RETURN_DOM: false, + RETURN_DOM_FRAGMENT: false, + RETURN_DOM_IMPORT: false, + }) +} +``` + +### Server Action with Sanitization + +```typescript +'use server' + +import { sanitizeMarkdown } from '@/lib/sanitize-markdown' +import { db } from '@/lib/db' +import { auth } from '@/lib/auth' + +export async function saveCharacterBio( + characterId: string, + biography: string +) { + const session = await auth() + + if (!session?.user) { + return { success: false, message: 'Unauthorized' } + } + + // Sanitize before saving + const sanitizedBio = sanitizeMarkdown(biography) + + try { + await db.character.update({ + where: { id: characterId }, + data: { biography: sanitizedBio }, + }) + + return { success: true } + } catch (error) { + console.error('Failed to save biography:', error) + return { success: false, message: 'Failed to save' } + } +} +``` + +### API Route with Sanitization + +```typescript +// app/api/entities/[id]/description/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { sanitizeMarkdown } from '@/lib/sanitize-markdown' +import { db } from '@/lib/db' +import { getServerSession } from 'next-auth' + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession() + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ) + } + + const { description } = await request.json() + + // Validate input + if (!description || typeof description !== 'string') { + return NextResponse.json( + { error: 'Invalid description' }, + { status: 400 } + ) + } + + // Sanitize + const sanitized = sanitizeMarkdown(description) + + // Save to database + await db.entity.update({ + where: { id: params.id }, + data: { description: sanitized }, + }) + + return NextResponse.json({ success: true }) +} +``` + +## Advanced Sanitization Patterns + +### Markdown-to-HTML with Sanitization + +```typescript +import { remark } from 'remark' +import remarkHtml from 'remark-html' +import DOMPurify from 'isomorphic-dompurify' + +export async function markdownToSafeHtml(markdown: string): Promise { + // Convert markdown to HTML + const result = await remark() + .use(remarkHtml, { sanitize: false }) // Don't double-sanitize + .process(markdown) + + const html = result.toString() + + // Sanitize HTML + const sanitized = DOMPurify.sanitize(html, { + ALLOWED_TAGS: [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'strong', 'em', 'code', 'pre', + 'ul', 'ol', 'li', + 'blockquote', 'a', 'img', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'hr', + ], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title'], + }) + + return sanitized +} + +// Usage in server action +export async function saveDescription(id: string, markdown: string) { + const html = await markdownToSafeHtml(markdown) + + await db.entity.update({ + where: { id }, + data: { + descriptionMarkdown: markdown, // Store original + descriptionHtml: html, // Store sanitized HTML + }, + }) + + return { success: true } +} +``` + +### Link Validation + +```typescript +import DOMPurify from 'isomorphic-dompurify' + +export function sanitizeWithLinkValidation(markdown: string): string { + return DOMPurify.sanitize(markdown, { + ALLOWED_TAGS: ['a', 'p', 'h1', 'h2', 'h3', 'strong', 'em', 'code'], + ALLOWED_ATTR: ['href'], + ALLOWED_URI_REGEXP: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/, + // Block certain domains + FORBID_ATTR: [], + HOOKS: { + afterSanitizeAttributes: (node) => { + if (node.tagName === 'A') { + const href = node.getAttribute('href') + if (href) { + // Block suspicious links + const suspiciousDomains = ['malicious.com', 'phishing.com'] + const url = new URL(href) + if (suspiciousDomains.some(domain => url.hostname.includes(domain))) { + node.removeAttribute('href') + } + // Force external links to open in new tab + node.setAttribute('target', '_blank') + node.setAttribute('rel', 'noopener noreferrer') + } + } + }, + }, + }) +} +``` + +### Image Source Validation + +```typescript +import DOMPurify from 'isomorphic-dompurify' + +export function sanitizeWithImageValidation(markdown: string): string { + return DOMPurify.sanitize(markdown, { + ALLOWED_TAGS: ['img', 'p', 'h1', 'h2', 'strong', 'em'], + ALLOWED_ATTR: ['src', 'alt', 'title', 'width', 'height'], + HOOKS: { + afterSanitizeAttributes: (node) => { + if (node.tagName === 'IMG') { + const src = node.getAttribute('src') + if (src) { + try { + const url = new URL(src) + // Only allow images from trusted domains + const trustedDomains = [ + 'images.example.com', + 'cdn.example.com', + 'storage.googleapis.com', + ] + if (!trustedDomains.some(domain => url.hostname === domain)) { + node.removeAttribute('src') + node.setAttribute('alt', 'Image blocked: untrusted source') + } + } catch (error) { + // Invalid URL, remove src + node.removeAttribute('src') + } + } + } + }, + }, + }) +} +``` + +## Security Best Practices + +### 1. Always Sanitize on Server + +Never trust client-side sanitization alone: + +```typescript +// [ERROR] BAD: Only client-side sanitization +function onSubmit(data: FormValues) { + await saveDescription(data.description) // Raw user input +} + +// [OK] GOOD: Server-side sanitization +'use server' +export async function saveDescription(description: string) { + const sanitized = sanitizeMarkdown(description) + await db.save(sanitized) +} +``` + +### 2. Validate Input Length + +```typescript +const MAX_MARKDOWN_LENGTH = 50000 // 50KB + +export async function saveDescription(description: string) { + if (description.length > MAX_MARKDOWN_LENGTH) { + throw new Error('Description too long') + } + + const sanitized = sanitizeMarkdown(description) + await db.save(sanitized) +} +``` + +### 3. Store Both Raw and Sanitized + +```typescript +export async function saveDescription( + entityId: string, + description: string +) { + const sanitized = sanitizeMarkdown(description) + + await db.entity.update({ + where: { id: entityId }, + data: { + descriptionRaw: description, // Original for editing + description: sanitized, // Sanitized for display + descriptionUpdatedAt: new Date(), + }, + }) +} +``` + +### 4. Implement Rate Limiting + +```typescript +import { Ratelimit } from '@upstash/ratelimit' +import { Redis } from '@upstash/redis' + +const ratelimit = new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute +}) + +export async function saveDescription( + userId: string, + description: string +) { + const { success } = await ratelimit.limit(userId) + + if (!success) { + throw new Error('Rate limit exceeded') + } + + const sanitized = sanitizeMarkdown(description) + await db.save(sanitized) +} +``` + +### 5. Log Suspicious Content + +```typescript +export function sanitizeAndLog( + markdown: string, + userId: string +): string { + const original = markdown + const sanitized = DOMPurify.sanitize(markdown) + + // If content was modified, it contained potentially malicious code + if (original !== sanitized) { + console.warn('Suspicious content detected:', { + userId, + timestamp: new Date(), + removed: original.length - sanitized.length, + }) + + // Optionally store for security review + logSecurityEvent({ + type: 'SUSPICIOUS_CONTENT', + userId, + original: original.substring(0, 1000), // First 1KB + sanitized: sanitized.substring(0, 1000), + }) + } + + return sanitized +} +``` + +## Testing Sanitization + +### Unit Tests + +```typescript +import { describe, it, expect } from 'vitest' +import { sanitizeMarkdown } from './sanitize-markdown' + +describe('sanitizeMarkdown', () => { + it('removes script tags', () => { + const input = 'Hello' + const output = sanitizeMarkdown(input) + expect(output).not.toContain('

Safe

' + const output = sanitizeMarkdown(input) + expect(output).not.toContain('Hello' + + await page.fill('[role="textbox"]', xssPayload) + await page.click('button:has-text("Save")') + + await page.waitForTimeout(1000) + + // Check that script did not execute + const xssExecuted = await page.evaluate(() => { + return (window as any).xssExecuted + }) + expect(xssExecuted).toBeUndefined() + + // Check that content was sanitized + const preview = await page.locator('.preview').textContent() + expect(preview).toContain('Hello') + expect(preview).not.toContain(' + + + + + +``` + +### Link-based Attacks +```html + +Click +Click + + +Click +``` + +### Iframe Injection +```html + + + + + +``` + +## Sanitization Checklist + +- [ ] Client-side sanitization with rehype-sanitize configured +- [ ] Server-side sanitization with DOMPurify +- [ ] Whitelist approach (allowed tags/attributes) +- [ ] Protocol restrictions (https only) +- [ ] Input length validation +- [ ] Rate limiting on save endpoints +- [ ] Logging of sanitized content +- [ ] Both raw and sanitized versions stored +- [ ] External links open in new tab with rel="noopener noreferrer" +- [ ] Image sources validated against trusted domains +- [ ] Unit tests for XSS prevention +- [ ] Integration tests with malicious payloads +- [ ] Security headers configured (CSP) +- [ ] Regular dependency updates for security patches diff --git a/skills/markdown-editor-integrator/references/theme-integration.md b/skills/markdown-editor-integrator/references/theme-integration.md new file mode 100644 index 0000000..0789d74 --- /dev/null +++ b/skills/markdown-editor-integrator/references/theme-integration.md @@ -0,0 +1,432 @@ +# Markdown Editor Theme Integration Guide + +## Overview + +Integrate @uiw/react-md-editor with shadcn/ui theming system for consistent light/dark mode support. + +## Theme Switching + +### Using next-themes + +```tsx +'use client' + +import { useTheme } from 'next-themes' +import MDEditor from '@uiw/react-md-editor' + +export function ThemedMarkdownEditor({ value, onChange }) { + const { theme } = useTheme() + + return ( +
+ +
+ ) +} +``` + +### data-color-mode Attribute + +The `data-color-mode` attribute controls the editor's theme: +- `light` - Light mode +- `dark` - Dark mode +- `auto` - System preference (default) + +## CSS Variable Mapping + +Map shadcn/ui CSS variables to editor styles: + +```css +/* globals.css */ + +/* Editor Container */ +.w-md-editor { + --md-editor-bg: hsl(var(--background)); + --md-editor-color: hsl(var(--foreground)); + --md-editor-border: hsl(var(--border)); +} + +/* Toolbar */ +.w-md-editor-toolbar { + background-color: hsl(var(--muted) / 0.3); + border-bottom-color: hsl(var(--border)); +} + +.w-md-editor-toolbar button { + color: hsl(var(--foreground)); +} + +.w-md-editor-toolbar button:hover { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +.w-md-editor-toolbar li.active button { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +/* Editor Content */ +.w-md-editor-content { + color: hsl(var(--foreground)); +} + +.w-md-editor-text-pre, +.w-md-editor-text-input { + color: hsl(var(--foreground)); + caret-color: hsl(var(--foreground)); +} + +/* Preview */ +.w-md-editor-preview { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +.wmde-markdown { + background-color: transparent; + color: hsl(var(--foreground)); +} + +/* Code Blocks */ +.wmde-markdown pre { + background-color: hsl(var(--muted)); +} + +.wmde-markdown code { + background-color: hsl(var(--muted)); + color: hsl(var(--primary)); +} + +/* Inline Code */ +.wmde-markdown :not(pre) > code { + background-color: hsl(var(--muted)); + color: hsl(var(--primary)); + padding: 0.2em 0.4em; + border-radius: 0.25rem; + font-size: 0.875em; +} + +/* Blockquotes */ +.wmde-markdown blockquote { + border-left-color: hsl(var(--primary) / 0.5); + background-color: hsl(var(--muted) / 0.5); + color: hsl(var(--muted-foreground)); +} + +/* Links */ +.wmde-markdown a { + color: hsl(var(--primary)); + text-decoration: underline; +} + +.wmde-markdown a:hover { + color: hsl(var(--primary) / 0.8); +} + +/* Tables */ +.wmde-markdown table { + border-color: hsl(var(--border)); +} + +.wmde-markdown th, +.wmde-markdown td { + border-color: hsl(var(--border)); +} + +.wmde-markdown th { + background-color: hsl(var(--muted)); +} + +.wmde-markdown tr:nth-child(even) { + background-color: hsl(var(--muted) / 0.3); +} + +/* Horizontal Rule */ +.wmde-markdown hr { + border-color: hsl(var(--border)); +} + +/* Headings */ +.wmde-markdown h1, +.wmde-markdown h2, +.wmde-markdown h3, +.wmde-markdown h4, +.wmde-markdown h5, +.wmde-markdown h6 { + color: hsl(var(--foreground)); + border-bottom: none; +} +``` + +## Tailwind Class Approach + +Use Tailwind utility classes for theming: + +```tsx +'use client' + +import { useTheme } from 'next-themes' +import MDEditor from '@uiw/react-md-editor' +import { cn } from '@/lib/utils' + +export function MarkdownEditor({ value, onChange, className }) { + const { theme } = useTheme() + + return ( +
+ +
+ ) +} +``` + +## Typography Integration + +Integrate with Tailwind Typography (prose): + +```css +/* Preview with prose styling */ +.w-md-editor-preview { + @apply prose prose-sm dark:prose-invert max-w-none p-4; +} + +/* Override prose defaults with theme colors */ +.w-md-editor-preview.prose { + --tw-prose-body: hsl(var(--foreground)); + --tw-prose-headings: hsl(var(--foreground)); + --tw-prose-links: hsl(var(--primary)); + --tw-prose-bold: hsl(var(--foreground)); + --tw-prose-code: hsl(var(--primary)); + --tw-prose-pre-bg: hsl(var(--muted)); + --tw-prose-quotes: hsl(var(--muted-foreground)); + --tw-prose-quote-borders: hsl(var(--primary) / 0.5); +} + +.w-md-editor-preview.prose.dark { + --tw-prose-body: hsl(var(--foreground)); + --tw-prose-headings: hsl(var(--foreground)); + --tw-prose-links: hsl(var(--primary)); + --tw-prose-bold: hsl(var(--foreground)); + --tw-prose-code: hsl(var(--primary)); + --tw-prose-pre-bg: hsl(var(--muted)); + --tw-prose-quotes: hsl(var(--muted-foreground)); + --tw-prose-quote-borders: hsl(var(--primary) / 0.5); +} +``` + +## Complete Component with Theme + +```tsx +'use client' + +import { useTheme } from 'next-themes' +import dynamic from 'next/dynamic' +import { ComponentProps, useEffect, useState } from 'react' +import rehypeSanitize from 'rehype-sanitize' +import { cn } from '@/lib/utils' + +const MDEditor = dynamic( + () => import('@uiw/react-md-editor'), + { ssr: false } +) + +type MDEditorProps = ComponentProps + +interface MarkdownEditorProps extends Omit { + value: string + onChange?: (value?: string) => void + height?: number | string + className?: string +} + +export function MarkdownEditor({ + value, + onChange, + height = 400, + className, + ...props +}: MarkdownEditorProps) { + const { theme, resolvedTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return ( +
+
+ Loading editor... +
+
+ ) + } + + const colorMode = (resolvedTheme || theme) === 'dark' ? 'dark' : 'light' + + return ( +
+ +
+ ) +} +``` + +## Dark Mode Specifics + +### Handle System Preference + +```tsx +import { useTheme } from 'next-themes' +import { useEffect, useState } from 'react' + +export function useEditorTheme() { + const { theme, systemTheme } = useTheme() + const [colorMode, setColorMode] = useState<'light' | 'dark'>('light') + + useEffect(() => { + const effectiveTheme = theme === 'system' ? systemTheme : theme + setColorMode(effectiveTheme === 'dark' ? 'dark' : 'light') + }, [theme, systemTheme]) + + return colorMode +} + +// Usage +const colorMode = useEditorTheme() + +
+ +
+``` + +### Handle Theme Transitions + +```css +/* Smooth theme transitions */ +.w-md-editor, +.w-md-editor-toolbar, +.w-md-editor-content, +.w-md-editor-preview, +.wmde-markdown, +.wmde-markdown * { + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} +``` + +## Custom Syntax Highlighting + +Integrate with shadcn/ui color scheme: + +```tsx +import { useTheme } from 'next-themes' +import MDEditor from '@uiw/react-md-editor' + +export function MarkdownEditor({ value, onChange }) { + const { theme } = useTheme() + + return ( +
+ { + return inline ? ( + + {children} + + ) : ( + + {children} + + ) + }, + }, + }} + /> +
+ ) +} +``` + +## Testing Theme Integration + +```tsx +import { render, screen } from '@testing-library/react' +import { ThemeProvider } from 'next-themes' +import { MarkdownEditor } from './MarkdownEditor' + +describe('MarkdownEditor Theme', () => { + it('applies light theme', () => { + render( + + {}} /> + + ) + + const wrapper = screen.getByRole('textbox').closest('[data-color-mode]') + expect(wrapper).toHaveAttribute('data-color-mode', 'light') + }) + + it('applies dark theme', () => { + render( + + {}} /> + + ) + + const wrapper = screen.getByRole('textbox').closest('[data-color-mode]') + expect(wrapper).toHaveAttribute('data-color-mode', 'dark') + }) +}) +``` + +## Troubleshooting + +**Issue:** Theme not applying immediately +**Solution:** Ensure editor is mounted after theme is resolved + +**Issue:** Flash of wrong theme +**Solution:** Add loading state while theme resolves + +**Issue:** CSS variables not working +**Solution:** Verify CSS is imported in correct order (globals.css before editor CSS) + +**Issue:** Dark mode colors not matching shadcn/ui +**Solution:** Use HSL values from CSS variables, not hardcoded colors