MorseCode Card Vertical Workspace Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the current all-at-once Card grid with an SSR-rendered Text/Theme/Style vertical workspace, while adding a horizontal live template gallery, permanent branding, a modal preview, arbitrary canvas-ratio containment, and preserved hard line breaks.
Architecture: CardGenerator.vue remains the single stateful orchestrator and owns one main CardPreview. Pure Morse/text functions preserve hard breaks first; focused Vue components handle the shared editor, gallery, lightbox, and SEO prose; useCardStages.ts owns window-level snap state and stage navigation. VitePress keeps ownership of doc width and Outline/Aside rendering.
Tech Stack: VitePress 1.6, Vue 3 Composition API, TypeScript, native CSS Scroll Snap, IntersectionObserver, native <dialog>, Lucide Vue, Vitest, Vue Test Utils, Playwright.
Execution Notes
- Start execution with
superpowers:using-git-worktrees; the current worktree already contains a user change in.gitignore. - Do not copy, revert, or commit that pre-existing change from the original worktree. In the isolated worktree, implement the intended final
docs/card.mdcontent described in Task 8. - Do not add Iconify, shadcn-vue, Tailwind, Embla, or another dependency.
- Run the narrow test listed in each task before the full suite.
- Keep every commit limited to the files listed for that task.
File Map
New files
docs/.vitepress/theme/components/MorseTextEditor.vue- controlled, reusable two-textarea editor with stable IDs and field-specific errors.docs/.vitepress/theme/components/card/CardLightbox.vue- native Dialog lifecycle, dismiss surface, animation, focus restoration, and snapshot presentation.docs/.vitepress/theme/components/card/CardSeo.vue- SSR-rendered localized explanatory prose excluded from VitePress Outline.docs/.vitepress/theme/card/useCardStages.ts- Text/Theme/Style observation, hash navigation, root snap class, and post-Style release.tests/components/morse-text-editor.test.ts- shared editor contract.tests/components/card-lightbox.test.ts- Dialog keyboard, click, animation, and focus contract.tests/unit/card-stages.test.ts- stage selection, snap class, release, and cleanup contract.
Modified files
docs/.vitepress/theme/morse.ts- line-ending normalization and bidirectional hard-break conversion.docs/.vitepress/theme/card/text.ts- hard-line-aware wrapping and fitting.docs/.vitepress/theme/card/useCardGenerator.ts- explicitsetText/setMorseentry points and normalized state.docs/.vitepress/theme/components/Convertor.vue- reuseMorseTextEditorwhile retaining converter buttons and Player.docs/.vitepress/theme/card/generators.ts- self-containedbrand.markscene generator.docs/.vitepress/theme/card/templates.ts- permanent brand mark/text and no Brand visibility control.docs/.vitepress/theme/card/query.ts- never serializebrandas hidden.docs/.vitepress/theme/components/card/TemplateGallery.vue- always-horizontal live gallery and arrow controls.docs/.vitepress/theme/components/card/CardPreview.vue- interactive contain stage with one main renderer.docs/.vitepress/theme/components/card/CardActions.vue- optional Adjust Style action.docs/.vitepress/theme/components/card/CardGenerator.vue- three semantic stages, one movable Preview region, Lightbox snapshots, SEO, and orchestration.docs/.vitepress/i18n/{en,zh,ar,ru,ja,es,fr,de,pt}.mjs- stage, gallery, Lightbox, SEO, and error labels.docs/{card,zh/card,ar/card,ru/card,ja/card,es/card,fr/card,de/card,pt/card}.md-layout: doc, noClientOnly, concise route shell.- Existing unit, component, and E2E tests listed in each task.
Removed files
docs/.vitepress/theme/components/card/CardInput.vue- replaced by the shared controlled editor.tests/components/card-input.test.ts- replaced bymorse-text-editor.test.ts.
Task 1: Preserve Explicit Line Breaks End to End
Files:
Modify:
docs/.vitepress/theme/morse.ts:27-91Modify:
docs/.vitepress/theme/card/text.ts:1-105Modify:
docs/.vitepress/theme/card/useCardGenerator.ts:16-71Test:
tests/unit/morse.test.tsTest:
tests/unit/card-text.test.tsTest:
tests/unit/card-state.test.tsTest:
tests/unit/card-query.test.ts[ ] Step 1: Write failing conversion tests
Add these cases to tests/unit/morse.test.ts:
it('normalizes line endings and preserves hard breaks in both directions', () => {
expect(encode('HELLO\r\nWORLD\rSOS')).toBe(
'.... . .-.. .-.. ---\n.-- --- .-. .-.. -..\n... --- ...',
)
expect(
decode('.... . .-.. .-.. ---\r\n.-- --- .-. .-.. -..\r... --- ...'),
).toBe('HELLO\nWORLD\nSOS')
})
it('preserves leading, trailing, and consecutive hard breaks', () => {
const source = '\nSOS\n\nTEST\n'
expect(decode(encode(source))).toBe(source)
})
it('keeps spaces as slash separators without turning newlines into slashes', () => {
expect(encode('I LOVE\nYOU')).toBe('.. / .-.. --- ...- .\n-.-- --- ..-')
})- [ ] Step 2: Run the Morse tests and verify failure
Run: npm test -- --run tests/unit/morse.test.ts
Expected: FAIL because encode() currently collapses every whitespace character to / and decode() discards line breaks.
- [ ] Step 3: Implement normalized, line-aware Morse conversion
Add and use these helpers in docs/.vitepress/theme/morse.ts:
export function normalizeLineEndings(value: string): string {
return value.replace(/\r\n?/g, '\n')
}
function encodeLine(text: string, dictionary: AlphabetMap): string {
const tokens: string[] = []
let lastWasSeparator = false
for (const char of text) {
if (/\s/.test(char)) {
if (!lastWasSeparator && tokens.length) tokens.push('/')
lastWasSeparator = tokens.length > 0
continue
}
const morse = dictionary[char.toUpperCase()]
if (!morse) continue
tokens.push(morse)
lastWasSeparator = false
}
if (tokens.at(-1) === '/') tokens.pop()
return tokens.join(' ')
}
export function encode(text: string, lang?: string): string {
if (!text?.length) return ''
const dictionary = resolveAlphabet(lang)
return normalizeLineEndings(text)
.split('\n')
.map((line) => encodeLine(line, dictionary))
.join('\n')
}
function decodeLine(code: string, reverse: Record<string, string>): string {
const letters: string[] = []
let pendingSpace = false
for (const token of tokenizeMorse(code)) {
if (isWordSeparator(token)) {
if (letters.length) pendingSpace = true
continue
}
const letter = reverse[token]
if (!letter) continue
if (pendingSpace && letters.length) letters.push(' ')
letters.push(letter)
pendingSpace = false
}
return letters.join('')
}
export function decode(code: string, lang?: string): string {
const reverse = reverseAlphabet(resolveAlphabet(lang))
return normalizeLineEndings(code)
.split('\n')
.map((line) => decodeLine(line, reverse))
.join('\n')
}Keep findInvalidTextChars() unchanged. Replace the token loop in findInvalidMorseTokens() with:
for (const line of normalizeLineEndings(code).split('\n')) {
for (const token of tokenizeMorse(line)) {
if (isWordSeparator(token)) continue
if (!reverse[token]) invalid.add(token)
}
}This leaves newline structural rather than treating it as a Morse token.
- [ ] Step 4: Write failing hard-line fitting tests
Add to tests/unit/card-text.test.ts:
it('preserves hard lines before applying automatic wrapping', () => {
expect(
fitText('HELLO WORLD\nSOS', { width: 100, height: 100 }, font, measure),
).toMatchObject({
lines: ['HELLO', 'WORLD', 'SOS'],
overflow: false,
})
})
it('counts consecutive and edge hard breaks as blank visual lines', () => {
const result = fitText('\nSOS\n\nTEST\n', { width: 200, height: 200 }, {
...font,
maxLines: 6,
}, measure)
expect(result.lines).toEqual(['', 'SOS', '', 'TEST', ''])
expect(result.overflow).toBe(false)
})
it('reports overflow when hard and automatic lines exceed maxLines', () => {
const result = fitText('HELLO WORLD\nSOS', { width: 100, height: 100 }, {
...font,
minSize: 20,
maxLines: 2,
}, measure)
expect(result.lines).toEqual(['HELLO', 'WORLD', 'SOS'])
expect(result.overflow).toBe(true)
})- [ ] Step 5: Run the fitting tests and verify failure
Run: npm test -- --run tests/unit/card-text.test.ts
Expected: FAIL because fitText() currently trims and splits all whitespace with /\s+/.
- [ ] Step 6: Implement hard-line-aware fitting
Refactor fitAtSize() in docs/.vitepress/theme/card/text.ts to accept the original string and build visual lines as follows:
import { normalizeLineEndings } from '../morse'
function visualLines(
text: string,
boxWidth: number,
font: FontStyle,
measure: TextMeasure,
): string[] {
if (!text.trim()) return []
return normalizeLineEndings(text).split('\n').flatMap((hardLine) => {
const trimmed = hardLine.trim()
return trimmed ? wrapTokens(trimmed.split(/\s+/), boxWidth, font, measure) : ['']
})
}
function fitAtSize(
text: string,
box: Size,
baseFont: FontStyle,
fontSize: number,
measure: TextMeasure,
): TextFit {
const font = { ...baseFont, size: fontSize }
const lines = visualLines(text, box.width, font, measure)
const lineHeight = fontSize * baseFont.lineHeight
const fits =
lines.length <= baseFont.maxLines &&
lines.length * lineHeight <= box.height &&
lines.every((line) => measure(line, font) <= box.width)
return { lines, fontSize, lineHeight, overflow: !fits }
}In fitText(), pass text as the first argument to fitAtSize() in minimumResult, baseResult, and the binary-search candidate call. Retain the logarithmic integer-size search and exact fractional boundary behavior.
- [ ] Step 7: Normalize Card state and add round-trip tests
In useCardGenerator.ts, import normalizeLineEndings and normalize before assigning either active field. Add tests:
it('normalizes and preserves hard breaks in controlled card state', () => {
const generator = useCardGenerator()
generator.setInput('SOS\r\nTEST')
expect(generator.state.sourceText).toBe('SOS\nTEST')
expect(generator.state.morseCode).toBe('... --- ...\n- . ... -')
generator.setInputMode('morse')
generator.setInput('... --- ...\r\n- . ... -')
expect(generator.state.sourceText).toBe('SOS\nTEST')
})Add to card-query.test.ts:
it('round-trips hard line breaks in text and Morse payloads', () => {
for (const current of [
state({ sourceText: 'SOS\nTEST' }),
state({ inputMode: 'morse', morseCode: '... --- ...\n- . ... -' }),
]) {
const url = serializeCardQuery('/card', current)
const parsed = parseCardQuery(new URL(url, 'https://example.test').searchParams)
expect(parsed[current.inputMode === 'text' ? 'text' : 'code']).toContain('\n')
}
})- [ ] Step 8: Run all pure-logic tests
Run: npm test -- --run tests/unit/morse.test.ts tests/unit/card-text.test.ts tests/unit/card-state.test.ts tests/unit/card-query.test.ts tests/unit/card-scene.test.ts tests/unit/card-generators.test.ts
Expected: PASS.
- [ ] Step 9: Commit
git add docs/.vitepress/theme/morse.ts docs/.vitepress/theme/card/text.ts docs/.vitepress/theme/card/useCardGenerator.ts tests/unit/morse.test.ts tests/unit/card-text.test.ts tests/unit/card-state.test.ts tests/unit/card-query.test.ts
git commit -m "feat: preserve card line breaks"Task 2: Extract the Shared Two-Field Editor
Files:
Create:
docs/.vitepress/theme/components/MorseTextEditor.vueCreate:
tests/components/morse-text-editor.test.tsModify:
docs/.vitepress/theme/components/Convertor.vue:1-165Modify:
docs/.vitepress/theme/components/card/CardGenerator.vue:29-33,105-172,505-524Modify:
docs/.vitepress/theme/card/useCardGenerator.ts:33-71Delete:
docs/.vitepress/theme/components/card/CardInput.vueDelete:
tests/components/card-input.test.tsTest:
tests/components/card-generator.test.ts[ ] Step 1: Write the failing controlled-editor test
Create tests/components/morse-text-editor.test.ts:
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MorseTextEditor from '../../docs/.vitepress/theme/components/MorseTextEditor.vue'
const labels = {
original: 'Original text',
morse: 'Morse code',
textPlaceholder: 'Type text',
morsePlaceholder: 'Type Morse',
}
describe('MorseTextEditor', () => {
it('renders two stable labeled fields and emits each value independently', async () => {
const wrapper = mount(MorseTextEditor, {
props: { text: 'SOS', morse: '... --- ...', labels },
})
const text = wrapper.get<HTMLTextAreaElement>('[data-input="text"]')
const morse = wrapper.get<HTMLTextAreaElement>('[data-input="morse"]')
expect(wrapper.findAll('textarea')).toHaveLength(2)
expect(wrapper.get(`label[for="${text.attributes('id')}"]`).text()).toBe('Original text')
expect(wrapper.get(`label[for="${morse.attributes('id')}"]`).text()).toBe('Morse code')
await text.setValue('HELLO')
await morse.setValue('.... ..')
expect(wrapper.emitted('update:text')).toEqual([['HELLO']])
expect(wrapper.emitted('update:morse')).toEqual([['.... ..']])
})
it('associates errors only with their owning field', () => {
const wrapper = mount(MorseTextEditor, {
props: {
text: 'SOS!',
morse: '... --- ...',
labels,
textError: 'Invalid text',
},
})
const text = wrapper.get('[data-input="text"]')
const morse = wrapper.get('[data-input="morse"]')
expect(text.attributes('aria-invalid')).toBe('true')
expect(text.attributes('aria-describedby')).toBeTruthy()
expect(morse.attributes('aria-invalid')).toBeUndefined()
expect(wrapper.get('[role="alert"]').text()).toBe('Invalid text')
})
})- [ ] Step 2: Run the test and verify failure
Run: npm test -- --run tests/components/morse-text-editor.test.ts
Expected: FAIL because MorseTextEditor.vue does not exist.
- [ ] Step 3: Implement the controlled editor
Create MorseTextEditor.vue with this public contract:
<script setup lang="ts">
import { useId } from 'vue'
defineProps<{
text: string
morse: string
labels: {
original: string
morse: string
textPlaceholder: string
morsePlaceholder: string
}
textError?: string
morseError?: string
}>()
const emit = defineEmits<{
'update:text': [value: string]
'update:morse': [value: string]
}>()
const id = useId()
const textId = `morse-text-${id}`
const morseId = `morse-code-${id}`
const textErrorId = `${textId}-error`
const morseErrorId = `${morseId}-error`
</script>
<template>
<div class="morse-text-editor">
<div class="morse-text-editor__field">
<label :for="textId">{{ labels.original }}</label>
<textarea
:id="textId"
data-input="text"
dir="ltr"
rows="4"
:value="text"
:placeholder="labels.textPlaceholder"
:aria-invalid="textError ? 'true' : undefined"
:aria-describedby="textError ? textErrorId : undefined"
@input="emit('update:text', ($event.target as HTMLTextAreaElement).value)"
/>
<p v-if="textError" :id="textErrorId" role="alert">{{ textError }}</p>
</div>
<div class="morse-text-editor__field">
<label :for="morseId">{{ labels.morse }}</label>
<textarea
:id="morseId"
data-input="morse"
dir="ltr"
rows="4"
:value="morse"
:placeholder="labels.morsePlaceholder"
:aria-invalid="morseError ? 'true' : undefined"
:aria-describedby="morseError ? morseErrorId : undefined"
@input="emit('update:morse', ($event.target as HTMLTextAreaElement).value)"
/>
<p v-if="morseError" :id="morseErrorId" role="alert">{{ morseError }}</p>
</div>
</div>
</template>Move the existing textarea, label, error, focus, resize, and VitePress color styles from CardInput.vue into the new component. Use a two-column container query only when both fields remain at least 240px wide; otherwise stack them.
- [ ] Step 4: Add explicit state setters
In useCardGenerator.ts, add:
function setText(value: string): void {
state.inputMode = 'text'
setInput(value)
}
function setMorse(value: string): void {
state.inputMode = 'morse'
setInput(value)
}Return both functions. Keep setInputMode() and setInput() for query application and backwards-compatible internal tests.
- [ ] Step 5: Reuse the editor in Convertor and CardGenerator
In Convertor.vue, replace the two hand-written field blocks with:
<MorseTextEditor
:text="textInput"
:morse="morseInput"
:labels="{
original: t.converter.text,
morse: t.converter.code,
textPlaceholder: t.converter.textPlaceholder,
morsePlaceholder: t.converter.codePlaceholder,
}"
@update:text="handleTextValue"
@update:morse="handleMorseValue"
/>Keep Convertor's encode/decode buttons and Player below the shared editor. Convert the handlers to accept strings rather than DOM events.
In CardGenerator.vue, replace CardInput with:
<MorseTextEditor
:text="model.state.sourceText"
:morse="model.state.morseCode"
:labels="labels"
:text-error="model.state.inputMode === 'text' ? inputError : undefined"
:morse-error="model.state.inputMode === 'morse' ? inputError : undefined"
@update:text="setText"
@update:morse="setMorse"
/>Implement setText() and setMorse() in the orchestrator by calling the matching model method and invalidatePrepared().
- [ ] Step 6: Remove the old component and update selectors
Delete CardInput.vue and card-input.test.ts. In card-generator.test.ts, update enterSos() and all direct textarea references to [data-input="text"] or [data-input="morse"].
- [ ] Step 7: Run shared-editor and generator tests
Run: npm test -- --run tests/components/morse-text-editor.test.ts tests/components/card-generator.test.ts tests/unit/card-state.test.ts
Expected: PASS.
- [ ] Step 8: Commit
git add docs/.vitepress/theme/components/MorseTextEditor.vue docs/.vitepress/theme/components/Convertor.vue docs/.vitepress/theme/components/card/CardGenerator.vue docs/.vitepress/theme/card/useCardGenerator.ts tests/components/morse-text-editor.test.ts tests/components/card-generator.test.ts tests/unit/card-state.test.ts
git rm docs/.vitepress/theme/components/card/CardInput.vue tests/components/card-input.test.ts
git commit -m "refactor: share the Morse text editor"Task 3: Make Brand and Logo Permanent
Files:
Modify:
docs/.vitepress/theme/card/generators.ts:1-182Modify:
docs/.vitepress/theme/card/templates.ts:8-375Modify:
docs/.vitepress/theme/card/query.ts:220-269Test:
tests/unit/card-generators.test.tsTest:
tests/unit/card-templates.test.tsTest:
tests/unit/card-query.test.tsTest:
tests/unit/card-state.test.tsTest:
tests/components/card-settings.test.ts[ ] Step 1: Write failing permanent-brand tests
Add to card-generators.test.ts:
it('expands the built-in brand mark into self-contained rect nodes', () => {
const element: GeneratedElement = {
type: 'generated',
id: 'brand-mark',
generator: 'brand.mark',
bind: 'sourceText',
options: {},
frame: { x: 0, y: 0, width: 48, height: 48 },
style: { fill: '#ffffff' },
}
const result = expandGenerated(element, context)
expect(result.issues).toEqual([])
expect(result.nodes).toHaveLength(15)
expect(result.nodes.every((node) => node.type === 'rect')).toBe(true)
expect(result.nodes.every((node) => node.style.fill === '#ffffff')).toBe(true)
})Update card-templates.test.ts so every template must have brand-mark and brand, while no template exposes a brand visibility control. Update card-query.test.ts to assert visibility: { brand: false } never serializes hidden=brand. Update card-settings.test.ts to assert no [data-visibility="brand"] exists.
- [ ] Step 2: Run brand tests and verify failure
Run: npm test -- --run tests/unit/card-generators.test.ts tests/unit/card-templates.test.ts tests/unit/card-query.test.ts tests/components/card-settings.test.ts
Expected: FAIL because brand.mark is unknown and templates still expose Brand visibility.
- [ ] Step 3: Implement the self-contained brand mark generator
Add to generators.ts:
const brandCells = [
[4, 7, 1, 1], [3, 8, 1, 9], [4, 17, 1, 1],
[7, 9, 1, 1], [6, 10, 1, 5], [7, 15, 1, 1],
[9, 11, 1, 1], [10, 13, 4, 1], [14, 11, 1, 1],
[16, 9, 1, 1], [17, 10, 1, 5], [16, 15, 1, 1],
[19, 7, 1, 1], [20, 8, 1, 9], [19, 17, 1, 1],
] as const
const brandMarkGenerator: CardElementGenerator = {
id: 'brand.mark',
validate: () => [],
measure: (element) => ({
width: element.frame.width,
height: element.frame.height,
}),
expand(element) {
const scale = Math.min(element.frame.width / 24, element.frame.height / 24)
const left = element.frame.x + (element.frame.width - 24 * scale) / 2
const top = element.frame.y + (element.frame.height - 24 * scale) / 2
return {
issues: [],
nodes: brandCells.map(([x, y, width, height], index) => ({
type: 'rect' as const,
id: `${element.id}-cell-${index}`,
frame: {
x: left + x * scale,
y: top + y * scale,
width: width * scale,
height: height * scale,
},
style: { ...element.style },
})),
}
},
}
registerGenerator(brandMarkGenerator)This reproduces docs/public/icon.svg with existing scene nodes and no external resource.
- [ ] Step 4: Add marks and remove Brand controls
Add this generated element before each existing Brand text element in templates.ts:
{
type: 'generated',
id: 'brand-mark',
generator: 'brand.mark',
bind: 'sourceText',
options: {},
frame: { x: 510, y: 892, width: 40, height: 40 },
style: { fill: { token: 'muted' } },
},Keep each existing morsecodes.net text node. Remove only { id: 'brand', ... } entries from all customization.visibility arrays. Do not add a visibility control for brand-mark.
- [ ] Step 5: Prevent new hidden Brand serialization
In query.ts, skip Brand while collecting hidden IDs:
for (const id in state.visibility) {
if (id === 'brand') continue
if (
!Object.prototype.hasOwnProperty.call(state.visibility, id) ||
state.visibility[id] !== false
) {
continue
}
assertConfigLength(id)
if (!isConfigIdentifier(id)) continue
if (hidden.length === MAX_CARD_QUERY_HIDDEN) {
throw new CardQueryLimitError('too-many-hidden')
}
hidden.push(id)
}Do not add compatibility code for old links. Because templates no longer expose a Brand control, useCardGenerator.applyQuery() naturally ignores parsed hidden=brand.
- [ ] Step 6: Update stale Brand expectations and run tests
Remove tests that uncheck Brand or expect it hidden. Replace them with assertions that brand-mark rects and morsecodes.net remain rendered after template changes and query application.
Run: npm test -- --run tests/unit/card-generators.test.ts tests/unit/card-templates.test.ts tests/unit/card-query.test.ts tests/unit/card-state.test.ts tests/components/card-settings.test.ts tests/components/card-renderer.test.ts tests/components/card-generator.test.ts
Expected: PASS.
- [ ] Step 7: Commit
git add docs/.vitepress/theme/card/generators.ts docs/.vitepress/theme/card/templates.ts docs/.vitepress/theme/card/query.ts tests/unit/card-generators.test.ts tests/unit/card-templates.test.ts tests/unit/card-query.test.ts tests/unit/card-state.test.ts tests/components/card-settings.test.ts tests/components/card-renderer.test.ts tests/components/card-generator.test.ts
git commit -m "feat: make card branding permanent"Task 4: Turn TemplateGallery into a Live Horizontal Gallery
Files:
Modify:
docs/.vitepress/theme/components/card/TemplateGallery.vue:1-134Modify:
docs/.vitepress/theme/components/card/CardGenerator.vue:84-91,526-533Test:
tests/components/template-gallery.test.tsTest:
tests/components/card-generator.test.ts[ ] Step 1: Write failing live-gallery tests
Update template-gallery.test.ts with these fixtures and the controlled render helper:
import type { CardState, CardTemplate } from '../../docs/.vitepress/theme/card/types'
const descriptions = Object.fromEntries(
templates.map((template) => [template.id, `Description: ${template.id}`]),
)
function stateFor(
template: CardTemplate,
overrides: Partial<CardState> = {},
): CardState {
return {
inputMode: 'text',
sourceText: '',
morseCode: '',
templateId: template.id,
visibility: {},
paletteId: template.customization.palettes[0],
images: {},
...overrides,
}
}
function emptyState(): CardState {
return stateFor(templates[0])
}
function render(options: {
state?: CardState
selectedId?: string
validContent?: boolean
} = {}) {
return mount(TemplateGallery, {
props: {
templates,
state: options.state ?? emptyState(),
selectedId: options.selectedId ?? templates[0].id,
validContent: options.validContent ?? false,
names,
descriptions,
labels: {
...labels,
previousTemplate: 'Previous template',
nextTemplate: 'Next template',
direction: 'ltr',
},
},
})
}Then add:
it('uses live content for every template and full state only for the selected template', () => {
const state = stateFor(templates[1], {
sourceText: 'TEST',
morseCode: '- . ... -',
paletteId: 'night',
})
const wrapper = render({ state, selectedId: templates[1].id, validContent: true })
expect(wrapper.findAll('svg').every((svg) => svg.text().includes('- . ... -'))).toBe(true)
expect(wrapper.get('[data-template="signal-dual"] [data-node-id="background"]')
.attributes('fill')).toBe('#15171A')
expect(wrapper.get('[data-template="signal-minimal"] [data-node-id="background"]')
.attributes('fill')).toBe('#F7F7F4')
})
it('falls back to SOS and exposes previous and next controls', async () => {
const wrapper = render({ state: emptyState(), validContent: false })
expect(wrapper.findAll('svg').every((svg) => svg.text().includes('... --- ...'))).toBe(true)
expect(wrapper.get('[data-gallery="previous"]').attributes('aria-label')).toBe('Previous template')
expect(wrapper.get('[data-gallery="next"]').attributes('aria-label')).toBe('Next template')
})Stub the viewport's scrollBy() and assert Next uses a positive inline delta in LTR and a negative delta in RTL.
- [ ] Step 2: Run the gallery test and verify failure
Run: npm test -- --run tests/components/template-gallery.test.ts
Expected: FAIL because the component always uses SOS, is vertical on desktop, and has no arrow controls.
- [ ] Step 3: Implement derived thumbnail state
Change the props to include:
state: CardState
validContent: boolean
descriptions: Record<string, string>Implement:
function previewState(template: CardTemplate): CardState {
const hasLive = props.validContent &&
Boolean((props.state.sourceText || props.state.morseCode).trim())
const content = hasLive
? { sourceText: props.state.sourceText, morseCode: props.state.morseCode }
: { sourceText: 'SOS', morseCode: '... --- ...' }
if (hasLive && template.id === props.selectedId) {
return { ...props.state, images: { ...props.state.images } }
}
return {
inputMode: 'text',
...content,
templateId: template.id,
visibility: {},
paletteId: template.customization.palettes[0],
images: {},
}
}Use the localized template description for each thumbnail's accessible description.
- [ ] Step 4: Implement horizontal viewport controls
Wrap template buttons in .template-gallery__viewport and .template-gallery__track. Add Lucide Chevron buttons outside the viewport:
<button
type="button"
data-gallery="previous"
:aria-label="labels.previousTemplate"
:title="labels.previousTemplate"
:disabled="!canPrevious"
@click="scrollPage(-1)"
><ChevronLeft aria-hidden="true" /></button>Mirror for Next. scrollPage(direction) must use viewport.clientWidth * 0.8, invert the physical sign when labels.direction === 'rtl', and honor reduced motion. Update canPrevious/canNext on mount, scroll, resize, and selected-item changes. Call the selected button's scrollIntoView({ block: 'nearest', inline: 'nearest' }) after a selection change.
Make the track always horizontal with grid-auto-flow: column, stable grid-auto-columns, overflow-x: auto, and scroll-snap-type: x mandatory. Remove the old @media (max-width: 899px) behavioral fork.
- [ ] Step 5: Pass live state from CardGenerator
Add this computed map next to templateNames in CardGenerator.vue:
const templateDescriptions = computed<Record<string, string>>(() =>
Object.fromEntries(
templates.map((template) => [
template.id,
card.value.templatesById[template.id]?.description ?? template.id,
]),
),
)Pass :state="model.state", :valid-content="!inputError && model.hasContent.value", :names="templateNames", :descriptions="templateDescriptions", and :labels="labels" to TemplateGallery.
- [ ] Step 6: Run gallery and generator tests
Run: npm test -- --run tests/components/template-gallery.test.ts tests/components/card-generator.test.ts
Expected: PASS.
- [ ] Step 7: Commit
git add docs/.vitepress/theme/components/card/TemplateGallery.vue docs/.vitepress/theme/components/card/CardGenerator.vue tests/components/template-gallery.test.ts tests/components/card-generator.test.ts
git commit -m "feat: add live horizontal template gallery"Task 5: Add the Modal Lightbox and Ratio-Safe Preview Stage
Files:
Create:
docs/.vitepress/theme/components/card/CardLightbox.vueCreate:
tests/components/card-lightbox.test.tsModify:
docs/.vitepress/theme/components/card/CardPreview.vue:1-99Modify:
docs/.vitepress/theme/components/card/CardGenerator.vue:69-77,119-144,280-325,498-575Test:
tests/components/card-generator.test.ts[ ] Step 1: Write the failing CardLightbox contract test
Create card-lightbox.test.ts with these stateful dialog stubs so the assertions observe native open/close state:
const showModal = vi.fn(function (this: HTMLDialogElement) {
this.setAttribute('open', '')
})
const close = vi.fn(function (this: HTMLDialogElement) {
this.removeAttribute('open')
})
beforeEach(() => {
showModal.mockClear()
close.mockClear()
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
Object.defineProperties(HTMLDialogElement.prototype, {
showModal: { configurable: true, value: showModal },
close: { configurable: true, value: close },
})
})Cover this contract:
it('opens in the top layer, focuses its dismiss surface, and closes from every supported input', async () => {
const trigger = document.createElement('button')
document.body.append(trigger)
trigger.focus()
const wrapper = mount(CardLightbox, {
attachTo: document.body,
props: {
open: true,
src: 'blob:preview',
title: 'Enlarged preview',
description: 'SOS is ... --- ...',
closeLabel: 'Close enlarged preview',
},
})
expect(showModal).toHaveBeenCalledOnce()
expect(wrapper.get('[data-lightbox="dismiss"]').element).toBe(document.activeElement)
expect(wrapper.find('[data-lightbox="close"]').exists()).toBe(false)
expect(wrapper.get('img').attributes('alt')).toBe('')
await wrapper.get('[data-lightbox="dismiss"]').trigger('click')
expect(wrapper.emitted('close')).toEqual([[]])
})Add separate cases for backdrop click (event.target === dialog), Enter, Space, native cancel/Escape, focus restoration, reduced motion, and close-before-unmount.
- [ ] Step 2: Run the Lightbox test and verify failure
Run: npm test -- --run tests/components/card-lightbox.test.ts
Expected: FAIL because CardLightbox.vue does not exist.
- [ ] Step 3: Implement CardLightbox
Create the complete native Dialog component:
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
const props = defineProps<{
open: boolean
src?: string
title: string
description: string
closeLabel: string
}>()
const emit = defineEmits<{ close: [] }>()
const id = useId()
const titleId = `card-lightbox-title-${id}`
const descriptionId = `card-lightbox-description-${id}`
const dialog = ref<HTMLDialogElement | null>(null)
const dismiss = ref<HTMLButtonElement | null>(null)
let trigger: HTMLElement | null = null
let closing = false
let disposed = false
function reducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function openDialog(): void {
const current = dialog.value
if (!current || current.open) return
trigger = document.activeElement instanceof HTMLElement
? document.activeElement
: null
current.showModal()
dismiss.value?.focus({ preventScroll: true })
if (reducedMotion()) {
current.classList.add('card-lightbox--visible')
} else {
requestAnimationFrame(() => current.classList.add('card-lightbox--visible'))
}
}
function waitForExit(current: HTMLDialogElement): Promise<void> {
if (reducedMotion()) return Promise.resolve()
return new Promise((resolve) => {
const finish = () => {
window.clearTimeout(timeout)
current.removeEventListener('transitionend', finish)
resolve()
}
const timeout = window.setTimeout(finish, 240)
current.addEventListener('transitionend', finish, { once: true })
})
}
async function requestClose(): Promise<void> {
const current = dialog.value
if (!current?.open || closing) return
closing = true
current.classList.remove('card-lightbox--visible')
await waitForExit(current)
if (disposed) return
current.close()
emit('close')
await nextTick()
if (trigger?.isConnected) trigger.focus({ preventScroll: true })
trigger = null
closing = false
}
function onDialogClick(event: MouseEvent): void {
if (event.target === dialog.value) void requestClose()
}
onMounted(() => {
if (props.open) openDialog()
})
watch(() => props.open, (open) => {
if (open) openDialog()
else void requestClose()
})
onBeforeUnmount(() => {
disposed = true
if (dialog.value?.open) dialog.value.close()
})
</script>
<template>
<dialog
ref="dialog"
class="card-lightbox"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
@click="onDialogClick"
@cancel.prevent="requestClose"
>
<h2 :id="titleId" class="card-lightbox__sr-only">{{ title }}</h2>
<p :id="descriptionId" class="card-lightbox__sr-only">{{ description }}</p>
<button
ref="dismiss"
type="button"
class="card-lightbox__dismiss"
data-lightbox="dismiss"
:aria-label="closeLabel"
@click="requestClose"
@keydown.enter.prevent="requestClose"
@keydown.space.prevent="requestClose"
>
<img v-if="src" :src="src" alt="" />
</button>
</dialog>
<style scoped>
.card-lightbox {
box-sizing: border-box;
width: min(92dvw, 1200px);
height: 92dvh;
max-width: none;
max-height: none;
margin: auto;
padding: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 6px;
opacity: 0;
background: #000;
transform: scale(.94);
transition: opacity 200ms ease, transform 200ms ease;
}
.card-lightbox--visible {
opacity: 1;
transform: scale(1);
}
.card-lightbox::backdrop {
background: rgb(0 0 0 / 76%);
}
.card-lightbox__dismiss {
display: grid;
place-items: center;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: #000;
cursor: zoom-out;
}
.card-lightbox__dismiss:focus-visible {
outline: 2px solid #fff;
outline-offset: -4px;
}
.card-lightbox__dismiss img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.card-lightbox__sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 767px) {
.card-lightbox {
inset: 0;
width: 100dvw;
height: 100dvh;
margin: 0;
padding:
env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
border: 0;
border-radius: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.card-lightbox {
transition: none;
}
}
</style>- [ ] Step 4: Write failing CardPreview contain/interaction tests
In the existing CardPreview describe block in card-generator.test.ts, add this exact fixture:
function previewProps(overrides: Record<string, unknown> = {}) {
const template = getTemplate('signal-minimal')
return {
template,
state: {
inputMode: 'text' as const,
sourceText: 'SOS',
morseCode: '... --- ...',
templateId: template.id,
visibility: {},
paletteId: template.customization.palettes[0],
images: {},
},
labels: card,
accessibleTitle: 'Title',
accessibleDescription: 'Description',
openLabel: 'Enlarge preview',
...overrides,
}
}Then extend the tests:
it('uses a stable black contain stage and emits open only when enabled', async () => {
const wrapper = mount(CardPreview, { props: previewProps({ openable: true }) })
expect(wrapper.get('.card-preview__canvas').attributes('style')).toContain(
'aspect-ratio: 1080 / 1080',
)
await wrapper.get('[data-preview="open"]').trigger('click')
expect(wrapper.emitted('open')).toEqual([[]])
await wrapper.setProps({ openable: false })
expect(wrapper.get('[data-preview="open"]').attributes('disabled')).toBeDefined()
})Mount a synthetic { width: 600, height: 1000 } template and assert aspect-ratio: 600 / 1000 without changing the outer stage dimensions.
- [ ] Step 5: Refactor CardPreview into a contain stage
Add openable?: boolean and openLabel?: string to the current props plus const emit = defineEmits<{ open: [] }>(). Keep the existing renderer, element, issues, measurementRevision, refreshMeasurements(), and defineExpose() logic. Replace the template and scoped styles with:
<template>
<div v-bind="$attrs" class="card-preview">
<button
type="button"
class="card-preview__open"
data-preview="open"
:disabled="!openable"
:aria-label="openLabel || accessibleTitle"
@click="emit('open')"
>
<span
class="card-preview__canvas"
:style="{
aspectRatio,
'--card-canvas-ratio': template.canvas.width / template.canvas.height,
}"
>
<CardRenderer
:key="measurementRevision"
ref="renderer"
:template="template"
:state="state"
:labels="labels"
:accessible-title="accessibleTitle"
:accessible-description="accessibleDescription"
/>
</span>
</button>
<p v-if="error" class="card-preview__empty" role="alert">{{ error }}</p>
<p v-else-if="empty" class="card-preview__empty">{{ emptyLabel }}</p>
</div>
</template>
<style scoped>
.card-preview {
position: relative;
display: grid;
place-items: center;
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: #000;
container-type: size;
}
.card-preview__open {
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding: 0;
border: 0;
background: #000;
cursor: zoom-in;
}
.card-preview__open:disabled {
cursor: default;
}
.card-preview__open:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: -3px;
}
.card-preview__canvas {
display: grid;
width: 100%;
max-width: 100%;
max-height: 100%;
}
@supports (width: 1cqb) {
.card-preview__canvas {
width: min(100cqi, calc(100cqb * var(--card-canvas-ratio)));
}
}
.card-preview__canvas :deep(.card-renderer) {
display: block;
width: 100%;
height: 100%;
}
.card-preview__empty {
position: absolute;
inset: 50% auto auto 50%;
z-index: 1;
max-width: calc(100% - 32px);
margin: 0;
padding: 8px 12px;
border-radius: 6px;
color: var(--vp-c-text-2);
background: var(--vp-c-bg-soft);
line-height: 1.35;
text-align: center;
overflow-wrap: anywhere;
transform: translate(-50%, -50%);
}
</style>The cqi/cqb formula is the ratio-independent contain rule: landscape canvases are width-limited and portrait canvases become height-limited and centered, leaving black side bars.
- [ ] Step 6: Integrate snapshot lifecycle in CardGenerator
Add:
const lightboxOpen = ref(false)
const lightboxUrl = ref<string>()
function releaseLightboxUrl(): void {
if (!lightboxUrl.value) return
URL.revokeObjectURL(lightboxUrl.value)
lightboxUrl.value = undefined
}
function openLightbox(): void {
if (disabled.value || busy.value) return
try {
releaseLightboxUrl()
lightboxUrl.value = URL.createObjectURL(svgBlob(currentSvg()))
lightboxOpen.value = true
} catch {
status.value = card.value.errors.exportFailed
}
}
function closeLightbox(): void {
lightboxOpen.value = false
releaseLightboxUrl()
}
onBeforeUnmount(releaseLightboxUrl)Pass :openable="!disabled && !busy", :open-label="labels.openPreview", and @open="openLightbox" to CardPreview. Render one CardLightbox with labels.enlargedPreview, accessibleDescription, labels.closePreview, lightboxUrl, and closeLightbox, matching the complete Task 7 template.
- [ ] Step 7: Run Lightbox, Preview, generator, and export tests
Run: npm test -- --run tests/components/card-lightbox.test.ts tests/components/card-generator.test.ts tests/unit/card-export.test.ts
Expected: PASS, including URL revoke after close and unmount.
- [ ] Step 8: Commit
git add docs/.vitepress/theme/components/card/CardLightbox.vue docs/.vitepress/theme/components/card/CardPreview.vue docs/.vitepress/theme/components/card/CardGenerator.vue tests/components/card-lightbox.test.ts tests/components/card-generator.test.ts
git commit -m "feat: add card preview lightbox"Task 6: Implement the Window-Level Stage Controller
Files:
Create:
docs/.vitepress/theme/card/useCardStages.tsCreate:
tests/unit/card-stages.test.ts[ ] Step 1: Write the failing stage-controller test
Create card-stages.test.ts with a mounted lifecycle harness. Mock VitePress before importing the composable:
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref, type Ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('vitepress', () => ({ getScrollOffset: () => 64 }))
import {
useCardStages,
type CardStageId,
} from '../../docs/.vitepress/theme/card/useCardStages'
let observerCallback: IntersectionObserverCallback
const disconnect = vi.fn()
class FakeIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
observerCallback = callback
}
observe() {}
disconnect() { disconnect() }
}
function positionedElement(top: number): HTMLElement {
const element = document.createElement('section')
vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => ({
top: top - window.scrollY,
bottom: top - window.scrollY + 700,
left: 0,
right: 700,
width: 700,
height: 700,
x: 0,
y: top - window.scrollY,
toJSON: () => ({}),
}))
document.body.append(element)
return element
}
function entry(target: Element, ratio: number): IntersectionObserverEntry {
return { target, intersectionRatio: ratio, isIntersecting: ratio > 0 } as IntersectionObserverEntry
}
let scrollY = 0
let api: ReturnType<typeof useCardStages>
let wrapper: ReturnType<typeof mount>
let root: Ref<HTMLElement | null>
let elements: Record<CardStageId, Ref<HTMLElement | null>>
beforeEach(() => {
scrollY = 0
Object.defineProperty(window, 'scrollY', { configurable: true, get: () => scrollY })
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.spyOn(window, 'scrollTo').mockImplementation((options) => {
scrollY = (options as ScrollToOptions).top ?? 0
})
elements = {
text: ref(positionedElement(100)),
theme: ref(positionedElement(800)),
style: ref(positionedElement(1500)),
}
root = ref(positionedElement(100))
wrapper = mount(defineComponent({
setup() {
api = useCardStages(root, elements)
return () => h('div')
},
}), { attachTo: document.body })
})
afterEach(() => {
if (wrapper.exists()) wrapper.unmount()
expect(document.documentElement.classList).not.toContain('card-snap-active')
expect(document.documentElement.style.getPropertyValue('--card-scroll-offset')).toBe('')
document.body.replaceChildren()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function emitIntersections(entries: IntersectionObserverEntry[]): void {
observerCallback(entries, {} as IntersectionObserver)
}
function dispatchScroll(nextY: number): void {
scrollY = nextY
window.dispatchEvent(new Event('scroll'))
}The cleanup block makes every test verify class and CSS-property removal. Then assert:
it('selects the most visible stage and controls root snapping', async () => {
emitIntersections([
entry(root.value!, 1),
entry(elements.text.value!, 0.2),
entry(elements.theme.value!, 0.8),
])
await nextTick()
expect(api.activeStage.value).toBe('theme')
expect(document.documentElement.classList).toContain('card-snap-active')
})
it('releases snap only after Style is aligned and restores it when returning upward', () => {
emitIntersections([entry(root.value!, 1), entry(elements.style.value!, 1)])
dispatchScroll(1436)
expect(document.documentElement.classList).not.toContain('card-snap-active')
dispatchScroll(1200)
expect(document.documentElement.classList).toContain('card-snap-active')
})
it('scrolls Outline-equivalent targets with the VitePress offset and cleans up', () => {
api.scrollToStage('style', { focus: true })
expect(window.scrollTo).toHaveBeenCalledWith({ top: 1436, behavior: 'smooth' })
expect(elements.style.value).toBe(document.activeElement)
wrapper.unmount()
expect(document.documentElement.classList).not.toContain('card-snap-active')
})- [ ] Step 2: Run the test and verify failure
Run: npm test -- --run tests/unit/card-stages.test.ts
Expected: FAIL because the composable does not exist.
- [ ] Step 3: Implement the composable public API
Create with explicit imports:
import { getScrollOffset } from 'vitepress'
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
export type CardStageId = 'text' | 'theme' | 'style'
export function useCardStages(
root: Ref<HTMLElement | null>,
elements: Record<CardStageId, Ref<HTMLElement | null>>,
) {
const activeStage = ref<CardStageId>('text')
const releasedAfterStyle = ref(false)
function scrollToStage(
id: CardStageId,
options: { focus?: boolean } = {},
): void {
const target = elements[id].value
if (!target) return
const offset = getScrollOffset()
const top = window.scrollY + target.getBoundingClientRect().top - offset
window.scrollTo({
top,
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth',
})
if (options.focus) {
target.tabIndex = -1
target.focus({ preventScroll: true })
}
}
return { activeStage, releasedAfterStyle, scrollToStage }
}In onMounted, observe all stages with multiple thresholds and choose the intersecting stage with the largest ratio. Track scroll direction and the computed Style target. Set releasedAfterStyle only when Style is active and Math.abs(window.scrollY - styleTarget) <= 2; keep it true while moving below Style; clear it when moving above the Style target.
Toggle html.card-snap-active only while the Card root intersects the viewport and releasedAfterStyle is false. Set --card-scroll-offset from getScrollOffset(). Recompute on resize and content updates. Disconnect observer, listeners, class, and CSS property in onBeforeUnmount.
- [ ] Step 4: Run the stage tests
Run: npm test -- --run tests/unit/card-stages.test.ts
Expected: PASS with no class or listener leakage between tests.
- [ ] Step 5: Commit
git add docs/.vitepress/theme/card/useCardStages.ts tests/unit/card-stages.test.ts
git commit -m "feat: add card stage navigation"Task 7: Assemble the Three Semantic Stages
Files:
Modify:
docs/.vitepress/theme/components/card/CardActions.vue:1-315Modify:
docs/.vitepress/theme/components/card/CardGenerator.vue:1-665Test:
tests/components/card-actions.test.tsTest:
tests/components/card-generator.test.ts[ ] Step 1: Write failing action and layout tests
Add to card-actions.test.ts:
it('shows and emits the optional Adjust Style action', async () => {
const wrapper = render({ showStyleAction: true })
await wrapper.get('[data-action="style"]').trigger('click')
expect(wrapper.emitted('style')).toEqual([[]])
await wrapper.setProps({ showStyleAction: false })
expect(wrapper.find('[data-action="style"]').exists()).toBe(false)
})Replace the old three-column source-regex test in card-generator.test.ts with DOM assertions:
it('renders three semantic snap stages and one movable main Preview', () => {
const wrapper = mount(CardGenerator)
expect(wrapper.findAll('h2').map((node) => node.attributes('id'))).toEqual([
'text', 'theme', 'style',
])
expect(wrapper.findAllComponents(CardPreview)).toHaveLength(1)
expect(wrapper.findAll('[data-card-stage]')).toHaveLength(3)
expect(CardGeneratorSource).toContain('scroll-snap-align: start')
expect(CardGeneratorSource).not.toMatch(/\.card-generator__preview[^}]*position:\s*sticky/s)
})Mock useCardStages() to drive activeStage through text, theme, and style. Assert the Preview region gets data-placement="theme" for Text/Theme and data-placement="style" for Style, while the same SVG element remains connected.
- [ ] Step 2: Run action and generator tests and verify failure
Run: npm test -- --run tests/components/card-actions.test.ts tests/components/card-generator.test.ts
Expected: FAIL because no stage action or semantic three-stage layout exists.
- [ ] Step 3: Add the Adjust Style action
Extend CardActions props and emits:
showStyleAction?: boolean
// emit
style: []Render a Lucide SlidersHorizontal icon plus labels.adjustStyle in a button with data-action="style". Hide it in Style. It is a navigation command, so it is not disabled by export validation; only disable it while busy.
- [ ] Step 4: Replace the workspace template with three sections
In CardGenerator.vue, create textStage, themeStage, and styleStage refs and call useCardStages(generatorRoot, { text: textStage, theme: themeStage, style: styleStage }).
Use this semantic order:
<div class="card-generator__flow" :data-stage="activeStage">
<section ref="textStage" data-card-stage="text" class="card-generator__stage card-generator__text">
<h2 id="text">{{ labels.textStage }}</h2>
<MorseTextEditor
:text="model.state.sourceText"
:morse="model.state.morseCode"
:labels="labels"
:text-error="model.state.inputMode === 'text' ? inputError : undefined"
:morse-error="model.state.inputMode === 'morse' ? inputError : undefined"
@update:text="setText"
@update:morse="setMorse"
/>
</section>
<section ref="themeStage" data-card-stage="theme" class="card-generator__stage card-generator__theme">
<h2 id="theme">{{ labels.themeStage }}</h2>
<TemplateGallery
:templates="templates"
:state="model.state"
:selected-id="model.state.templateId"
:valid-content="!inputError && model.hasContent.value"
:names="templateNames"
:descriptions="templateDescriptions"
:labels="labels"
@select="selectTemplate"
/>
<div class="card-generator__theme-preview-slot" aria-hidden="true" />
</section>
<div
class="card-generator__preview-region"
:data-placement="activeStage === 'style' ? 'style' : 'theme'"
>
<CardPreview
ref="preview"
:template="activeTemplate"
:state="model.state"
:labels="labels"
:accessible-title="accessibleTitle"
:accessible-description="accessibleDescription"
:empty="!model.hasContent.value"
:empty-label="labels.emptyPreview"
:error="previewError"
:openable="!disabled && !busy"
:open-label="labels.openPreview"
@open="openLightbox"
/>
<CardActions
:disabled="disabled"
:busy="busy"
:labels="actionLabels"
:status="status"
:show-style-action="activeStage !== 'style'"
@style="scrollToStage('style', { focus: true })"
@download="download"
@share="share"
/>
</div>
<section ref="styleStage" data-card-stage="style" class="card-generator__stage card-generator__style">
<h2 id="style">{{ labels.styleStage }}</h2>
<div class="card-generator__style-preview-slot" aria-hidden="true" />
<div class="card-generator__settings-scroll">
<CardSettings
:template="activeTemplate"
:state="model.state"
:labels="labels"
:image-errors="visibleImageErrors"
@visibility="setVisibility"
@palette="setPalette"
@font="setFont"
@background="setBackground"
@image="setImage"
@remove-image="removeImage"
@image-patch="patchImage"
/>
<p
v-for="slot in missingRequiredSlots"
:key="slot"
class="card-generator__required"
role="alert"
>
{{ labels[activeTemplate.slots[slot].i18n] }}:
{{ card.errors.requiredImage }}
</p>
</div>
</section>
<CardLightbox
:open="lightboxOpen"
:src="lightboxUrl"
:title="labels.enlargedPreview"
:description="accessibleDescription"
:close-label="labels.closePreview"
@close="closeLightbox"
/>
</div>The Preview region must remain one DOM node. Do not use Teleport, duplicate CardPreview, fixed positioning, or sticky positioning.
- [ ] Step 5: Implement stable grid tracks and root snap CSS
Use component-scoped CSS with explicit global root rules:
:global(html.card-snap-active) {
scroll-snap-type: y mandatory;
scroll-padding-block-start: var(--card-scroll-offset, 0px);
}
.card-generator__flow {
--card-viewport: calc(100svh - var(--card-scroll-offset, 0px));
--gallery-peek: clamp(132px, 24svh, 180px);
--theme-preview-size: min(100%, 58svh);
--style-preview-size: clamp(150px, 26svh, 240px);
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto auto;
}
.card-generator__stage {
min-width: 0;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.card-generator__text {
grid-row: 1;
min-height: calc(var(--card-viewport) - var(--gallery-peek));
}
.card-generator__theme {
grid-row: 2;
min-height: var(--card-viewport);
}
.card-generator__style {
grid-row: 3;
min-height: var(--card-viewport);
}
.card-generator__theme-preview-slot {
block-size: var(--theme-preview-size);
}
.card-generator__style-preview-slot {
block-size: var(--style-preview-size);
}
.card-generator__preview-region {
z-index: 1;
align-self: end;
min-width: 0;
}
.card-generator__preview-region[data-placement='theme'] {
grid-row: 2;
block-size: var(--theme-preview-size);
}
.card-generator__preview-region[data-placement='style'] {
grid-row: 3;
align-self: start;
block-size: var(--style-preview-size);
margin-block-start: 56px;
}
.card-generator__settings-scroll {
min-height: 0;
max-height: calc(var(--card-viewport) - var(--style-preview-size) - 96px);
overflow-y: auto;
}The empty slots reserve stable space while the Preview region overlays the selected row. The explicit Preview block sizes keep canvas ratios from moving headings or reducing the Style Editor. Center the .card-preview__canvas inside its black stage in both placements. Apply an opacity/scale transition only after data-placement changes. In reduced motion, disable Preview and scroll transitions. Keep all sizing within the VitePress doc column.
- [ ] Step 6: Run component tests
Run: npm test -- --run tests/components/card-actions.test.ts tests/components/card-generator.test.ts tests/components/card-settings.test.ts tests/components/template-gallery.test.ts tests/components/morse-text-editor.test.ts tests/components/card-lightbox.test.ts tests/unit/card-stages.test.ts
Expected: PASS.
- [ ] Step 7: Commit
git add docs/.vitepress/theme/components/card/CardActions.vue docs/.vitepress/theme/components/card/CardGenerator.vue tests/components/card-actions.test.ts tests/components/card-generator.test.ts
git commit -m "feat: assemble vertical card stages"Task 8: Enable SSR, VitePress Outline, Localized SEO, and All Routes
Files:
Create:
docs/.vitepress/theme/components/card/CardSeo.vueModify:
docs/.vitepress/theme/components/card/CardGenerator.vueModify:
docs/.vitepress/i18n/{en,zh,ar,ru,ja,es,fr,de,pt}.mjsModify:
docs/{card,zh/card,ar/card,ru/card,ja/card,es/card,fr/card,de/card,pt/card}.mdTest:
tests/unit/card-i18n.test.ts[ ] Step 1: Write failing i18n and route tests
Add these dedicated additions beside the current required, requiredErrors, and proseKeys declarations:
const workspaceRequired = [
'textStage', 'themeStage', 'styleStage',
'previousTemplate', 'nextTemplate', 'adjustStyle',
'openPreview', 'enlargedPreview', 'closePreview',
'seoTitle', 'seoIntro', 'seoPrivacy',
] as const
const workspaceProseKeys = [
'textStage', 'themeStage', 'styleStage',
'previousTemplate', 'nextTemplate', 'adjustStyle',
'openPreview', 'enlargedPreview', 'closePreview',
'seoTitle', 'seoIntro', 'seoPrivacy',
] as constChange the required-label and localized-prose validation loops to iterate over [...required, ...workspaceRequired] and [...proseKeys, ...workspaceProseKeys]. Keep the existing requiredErrors loop unchanged. Type naturalEnglishSpellings against (typeof proseKeys)[number] | (typeof workspaceProseKeys)[number].
Change route assertions to require layout: doc, outline: 2, one <CardGenerator />, and no ClientOnly tags. Add a raw-source assertion that CardGenerator.vue contains h2 id="text", h2 id="theme", and h2 id="style".
Because styleStage is legitimately spelled Style in French and textStage is Text in German, extend naturalEnglishSpellings with fr: ['styleStage'] and de: ['textStage'] in addition to the locale's existing exceptions.
- [ ] Step 2: Run i18n tests and verify failure
Run: npm test -- --run tests/unit/card-i18n.test.ts
Expected: FAIL for missing labels and existing layout: page/ClientOnly route shells.
- [ ] Step 3: Add the exact localized label set
Add these keys to each locale's theme.card object. Use this translation matrix; keep each locale's existing direction:
| Key | en | zh | ja | fr | de | es | ru | pt | ar |
|---|---|---|---|---|---|---|---|---|---|
textStage | Text | 文本 | テキスト | Texte | Text | Texto | Текст | Texto | النص |
themeStage | Theme | 模板 | テーマ | Thème | Vorlage | Tema | Шаблон | Tema | القالب |
styleStage | Style | 样式 | スタイル | Style | Stil | Estilo | Стиль | Estilo | النمط |
previousTemplate | Previous template | 上一个模板 | 前のテンプレート | Modèle précédent | Vorherige Vorlage | Plantilla anterior | Предыдущий шаблон | Modelo anterior | القالب السابق |
nextTemplate | Next template | 下一个模板 | 次のテンプレート | Modèle suivant | Nächste Vorlage | Plantilla siguiente | Следующий шаблон | Próximo modelo | القالب التالي |
adjustStyle | Adjust style | 调整样式 | スタイルを調整 | Ajuster le style | Stil anpassen | Ajustar estilo | Настроить стиль | Ajustar estilo | تعديل النمط |
openPreview | Enlarge preview | 放大预览 | プレビューを拡大 | Agrandir l’aperçu | Vorschau vergrößern | Ampliar vista previa | Увеличить предпросмотр | Ampliar prévia | تكبير المعاينة |
enlargedPreview | Enlarged card preview | 放大的卡片预览 | 拡大カードプレビュー | Aperçu agrandi de la carte | Vergrößerte Kartenvorschau | Vista previa ampliada | Увеличенный предпросмотр | Prévia ampliada do cartão | معاينة البطاقة المكبرة |
closePreview | Close enlarged preview | 关闭放大预览 | 拡大プレビューを閉じる | Fermer l’aperçu agrandi | Vergrößerte Vorschau schließen | Cerrar vista ampliada | Закрыть предпросмотр | Fechar prévia ampliada | إغلاق المعاينة المكبرة |
Add these exact localized SEO strings; do not reuse the English strings in localized files:
// en.mjs
seoTitle: 'About Morse code cards',
seoIntro: 'Create a Morse code card from text or Morse code, choose a template, customize its style, and export it as PNG or SVG.',
seoPrivacy: 'Your text and uploaded images stay in browser memory. Images are never included in shared URLs.',
// zh.mjs
seoTitle: '关于摩斯密码卡片',
seoIntro: '从文本或摩斯密码创建卡片,选择模板、调整样式,并导出为 PNG 或 SVG。',
seoPrivacy: '您的文本和上传图片仅保留在浏览器内存中,图片不会写入分享链接。',
// ja.mjs
seoTitle: 'モールス信号カードについて',
seoIntro: 'テキストまたはモールス信号からカードを作成し、テンプレートとスタイルを選んで PNG または SVG で書き出せます。',
seoPrivacy: 'テキストとアップロード画像はブラウザーのメモリ内だけに保持され、画像が共有 URL に含まれることはありません。',
// fr.mjs
seoTitle: 'À propos des cartes en code Morse',
seoIntro: 'Créez une carte à partir de texte ou de code Morse, choisissez un modèle, personnalisez son style et exportez-la en PNG ou SVG.',
seoPrivacy: 'Votre texte et vos images importées restent dans la mémoire du navigateur. Les images ne sont jamais ajoutées aux URL partagées.',
// de.mjs
seoTitle: 'Über Morsecode-Karten',
seoIntro: 'Erstelle eine Karte aus Text oder Morsecode, wähle eine Vorlage, passe den Stil an und exportiere sie als PNG oder SVG.',
seoPrivacy: 'Text und hochgeladene Bilder bleiben im Browserspeicher. Bilder werden nie in geteilte URLs aufgenommen.',
// es.mjs
seoTitle: 'Acerca de las tarjetas de código Morse',
seoIntro: 'Crea una tarjeta desde texto o código Morse, elige una plantilla, ajusta su estilo y expórtala como PNG o SVG.',
seoPrivacy: 'El texto y las imágenes subidas permanecen en la memoria del navegador. Las imágenes nunca se incluyen en las URL compartidas.',
// ru.mjs
seoTitle: 'О карточках азбуки Морзе',
seoIntro: 'Создайте карточку из текста или кода Морзе, выберите шаблон, настройте стиль и экспортируйте её в PNG или SVG.',
seoPrivacy: 'Текст и загруженные изображения остаются в памяти браузера. Изображения никогда не добавляются в ссылки для отправки.',
// pt.mjs
seoTitle: 'Sobre cartões em código Morse',
seoIntro: 'Crie um cartão a partir de texto ou código Morse, escolha um modelo, ajuste o estilo e exporte como PNG ou SVG.',
seoPrivacy: 'O texto e as imagens enviadas permanecem na memória do navegador. As imagens nunca são incluídas nos URLs compartilhados.',
// ar.mjs
seoTitle: 'حول بطاقات شفرة مورس',
seoIntro: 'أنشئ بطاقة من نص أو شفرة مورس، واختر قالبًا، وعدّل النمط، ثم صدّرها بصيغة PNG أو SVG.',
seoPrivacy: 'يبقى النص والصور المرفوعة في ذاكرة المتصفح، ولا تُضمّن الصور مطلقًا في روابط المشاركة.',- [ ] Step 4: Create SSR SEO prose
Create CardSeo.vue:
<script setup lang="ts">
defineProps<{
labels: {
seoTitle: string
seoIntro: string
seoPrivacy: string
}
}>()
</script>
<template>
<section class="card-seo">
<h2 id="about-morse-code-cards" class="ignore-header">
{{ labels.seoTitle }}
</h2>
<p>{{ labels.seoIntro }}</p>
<p>{{ labels.seoPrivacy }}</p>
</section>
</template>Render it after the Style stage inside CardGenerator. Do not apply scroll-snap-align to SEO content. The stage controller must already have released mandatory snap before this section is reached.
- [ ] Step 5: Replace all route shells
For all nine routes, preserve the exact current title, description, and H1 values already enumerated by the routes fixture in card-i18n.test.ts. Change layout to doc, add outline: 2, remove the outer <ClientOnly> tags, and leave exactly one <CardGenerator /> after the H1. The English file must end as:
---
title: "Morse Code Card Generator"
description: "Create a Morse code card from English text or Morse code, customize a preset template, and download or share it as PNG or SVG."
layout: doc
outline: 2
---
# Morse Code Card Generator
<CardGenerator />Apply the same mechanical shell change to zh, ar, ru, ja, es, fr, de, and pt; their route-fixture assertions prevent metadata or heading drift. This task runs in the isolated implementation worktree created before Task 1.
- [ ] Step 6: Run i18n tests and build SSR output
Run: npm test -- --run tests/unit/card-i18n.test.ts
Expected: PASS.
Run: npm run docs:build
Expected: PASS without window is not defined, hydration, or duplicate-ID errors.
Inspect generated HTML:
rg -n 'id="text"|id="theme"|id="style"|about-morse-code-cards' docs/.vitepress/dist/card.htmlExpected: all four IDs are present in static HTML; only Text/Theme/Style are eligible for Outline because SEO H2 has ignore-header.
- [ ] Step 7: Commit
git add docs/.vitepress/theme/components/card/CardSeo.vue docs/.vitepress/theme/components/card/CardGenerator.vue docs/.vitepress/i18n/en.mjs docs/.vitepress/i18n/zh.mjs docs/.vitepress/i18n/ar.mjs docs/.vitepress/i18n/ru.mjs docs/.vitepress/i18n/ja.mjs docs/.vitepress/i18n/es.mjs docs/.vitepress/i18n/fr.mjs docs/.vitepress/i18n/de.mjs docs/.vitepress/i18n/pt.mjs docs/card.md docs/zh/card.md docs/ar/card.md docs/ru/card.md docs/ja/card.md docs/es/card.md docs/fr/card.md docs/de/card.md docs/pt/card.md tests/unit/card-i18n.test.ts
git commit -m "feat: render localized card workspace with outline"Task 9: Rewrite Browser Coverage for the New Workflow
Files:
Modify:
tests/e2e/card.spec.ts[ ] Step 1: Update shared selectors and remove obsolete Brand actions
Change enterSos() to fill [data-input="text"]; locate the main SVG through .card-preview__canvas > svg.card-renderer. Remove all steps that uncheck [data-visibility="brand"] or expect Brand to disappear. Replace them with:
await expect(page.locator('.card-preview [data-node-id="brand"]')).toHaveCount(1)
await expect(page.locator('.card-preview [data-node-id^="brand-mark-cell-"]')).toHaveCount(15)
await expect(page.locator('[data-visibility="brand"]')).toHaveCount(0)Update query restoration coverage so hidden=brand has no asserted compatibility behavior.
- [ ] Step 2: Add failing Outline and snap tests
Add a desktop test that:
await openCard(page)
await expect(page.locator('.VPDocAsideOutline .outline-link')).toHaveText([
'Text', 'Theme', 'Style',
])
await page.locator('.VPDocAsideOutline a[href="#theme"]').click()
await expect.poll(() => page.locator('#theme').evaluate((el) =>
Math.round(el.getBoundingClientRect().top),
)).toBeGreaterThanOrEqual(0)Scroll between stages and assert final positions match one of the three stage targets within 2px. Click Adjust Style and assert Style lands correctly, the same main SVG remains connected, its containing stage is compact, and the settings scroller can move without changing window.scrollY.
On Pixel 7, open the VitePress top Outline dropdown and assert the same three links; assert no right-side width is reserved and no horizontal overflow exists.
- [ ] Step 3: Add failing Lightbox and ratio tests
Cover:
Click main Preview to open one visible
dialog[open].Desktop dialog/backdrop covers the viewport and does not exceed
innerWidth/innerHeight.No visible close button exists.
Clicking the image closes; reopen and click backdrop; reopen and press Escape.
Focus returns to
[data-preview="open"]each time.Pixel 7 dialog dimensions are within 1px of the visual viewport minus safe-area padding.
Temporarily set
.card-preview__canvastoaspect-ratio: 3 / 5and assert the canvas is centered, fully contained, and narrower than the black stage.[ ] Step 4: Add failing hard-break export coverage
Fill the text input with HELLO\nWORLD. Assert the Morse textarea contains a real newline. Assert the live SVG has distinct Source and Morse line nodes in the expected order. Download SVG and assert both hard lines appear as separate <text> elements. Download PNG and reuse the existing nonblank pixel checks.
- [ ] Step 5: Run the focused E2E file and fix only new-workflow failures
Run: npx playwright test tests/e2e/card.spec.ts --project="Desktop Chrome"
Expected: PASS.
Run: npx playwright test tests/e2e/card.spec.ts --project="Pixel 7"
Expected: PASS.
- [ ] Step 6: Commit
git add tests/e2e/card.spec.ts
git commit -m "test: verify vertical card workflow"Task 10: Full Regression and Visual Verification
Files:
Verify only; if a defect appears, return to its owning task, add a failing regression assertion there, and use that task's explicit file list and commit command.
[ ] Step 1: Run the full unit/component suite
Run: npm test
Expected: all unit and component tests PASS.
- [ ] Step 2: Run the production build
Run: npm run docs:build
Expected: VitePress build PASS for all locales with no SSR or hydration warnings.
- [ ] Step 3: Run both Playwright projects
Run: npm run test:e2e
Expected: Desktop Chrome and Pixel 7 PASS.
- [ ] Step 4: Capture final screenshots and inspect layout
Use Playwright screenshots at these states:
- Desktop 1440×900: Text, Theme, Style, and open Lightbox.
- Mobile 412×915: Text, Theme, Style with portrait simulation, open Lightbox, top Outline menu.
Inspect that text and controls do not overlap, Theme Gallery peeks from Text, Style Editor remains usable, portrait letterboxing is centered, Dialog has no visible close chrome, and SEO content remains reachable.
- [ ] Step 5: Verify canvas pixels and modal image
Reuse the existing PNG pixel decoder to assert downloaded PNG is nonblank. In Playwright, call createImageBitmap() for the Lightbox Blob URL and assert its dimensions match the active template canvas.
- [ ] Step 6: Check formatting and scope
Run: git diff --check
Expected: no whitespace errors in implementation changes.
Run: git status --short
Expected: a clean implementation worktree. Do not create an empty verification commit.