Skip to content

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.md content 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 - explicit setText/setMorse entry points and normalized state.
  • docs/.vitepress/theme/components/Convertor.vue - reuse MorseTextEditor while retaining converter buttons and Player.
  • docs/.vitepress/theme/card/generators.ts - self-contained brand.mark scene generator.
  • docs/.vitepress/theme/card/templates.ts - permanent brand mark/text and no Brand visibility control.
  • docs/.vitepress/theme/card/query.ts - never serialize brand as 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, no ClientOnly, 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 by morse-text-editor.test.ts.

Task 1: Preserve Explicit Line Breaks End to End

Files:

  • Modify: docs/.vitepress/theme/morse.ts:27-91

  • Modify: docs/.vitepress/theme/card/text.ts:1-105

  • Modify: docs/.vitepress/theme/card/useCardGenerator.ts:16-71

  • Test: tests/unit/morse.test.ts

  • Test: tests/unit/card-text.test.ts

  • Test: tests/unit/card-state.test.ts

  • Test: tests/unit/card-query.test.ts

  • [ ] Step 1: Write failing conversion tests

Add these cases to tests/unit/morse.test.ts:

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:

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:

ts
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:

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:

ts
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:

ts
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:

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
bash
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.vue

  • Create: tests/components/morse-text-editor.test.ts

  • Modify: docs/.vitepress/theme/components/Convertor.vue:1-165

  • Modify: docs/.vitepress/theme/components/card/CardGenerator.vue:29-33,105-172,505-524

  • Modify: docs/.vitepress/theme/card/useCardGenerator.ts:33-71

  • Delete: docs/.vitepress/theme/components/card/CardInput.vue

  • Delete: tests/components/card-input.test.ts

  • Test: tests/components/card-generator.test.ts

  • [ ] Step 1: Write the failing controlled-editor test

Create tests/components/morse-text-editor.test.ts:

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:

vue
<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:

ts
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:

vue
<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:

vue
<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
bash
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-182

  • Modify: docs/.vitepress/theme/card/templates.ts:8-375

  • Modify: docs/.vitepress/theme/card/query.ts:220-269

  • Test: tests/unit/card-generators.test.ts

  • Test: tests/unit/card-templates.test.ts

  • Test: tests/unit/card-query.test.ts

  • Test: tests/unit/card-state.test.ts

  • Test: tests/components/card-settings.test.ts

  • [ ] Step 1: Write failing permanent-brand tests

Add to card-generators.test.ts:

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:

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:

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:

ts
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
bash
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"

Files:

  • Modify: docs/.vitepress/theme/components/card/TemplateGallery.vue:1-134

  • Modify: docs/.vitepress/theme/components/card/CardGenerator.vue:84-91,526-533

  • Test: tests/components/template-gallery.test.ts

  • Test: 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:

ts
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:

ts
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:

ts
state: CardState
validContent: boolean
descriptions: Record<string, string>

Implement:

ts
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:

vue
<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:

ts
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
bash
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.vue

  • Create: tests/components/card-lightbox.test.ts

  • Modify: docs/.vitepress/theme/components/card/CardPreview.vue:1-99

  • Modify: docs/.vitepress/theme/components/card/CardGenerator.vue:69-77,119-144,280-325,498-575

  • Test: 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:

ts
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:

ts
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:

vue
<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:

ts
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:

ts
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:

vue
<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:

ts
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
bash
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.ts

  • Create: 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:

ts
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:

ts
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:

ts
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
bash
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-315

  • Modify: docs/.vitepress/theme/components/card/CardGenerator.vue:1-665

  • Test: tests/components/card-actions.test.ts

  • Test: tests/components/card-generator.test.ts

  • [ ] Step 1: Write failing action and layout tests

Add to card-actions.test.ts:

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:

ts
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:

ts
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:

vue
<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:

css
: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
bash
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.vue

  • Modify: docs/.vitepress/theme/components/card/CardGenerator.vue

  • Modify: docs/.vitepress/i18n/{en,zh,ar,ru,ja,es,fr,de,pt}.mjs

  • Modify: docs/{card,zh/card,ar/card,ru/card,ja/card,es/card,fr/card,de/card,pt/card}.md

  • Test: 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:

ts
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 const

Change 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:

Keyenzhjafrdeesruptar
textStageText文本テキストTexteTextTextoТекстTextoالنص
themeStageTheme模板テーマThèmeVorlageTemaШаблонTemaالقالب
styleStageStyle样式スタイルStyleStilEstiloСтильEstiloالنمط
previousTemplatePrevious template上一个模板前のテンプレートModèle précédentVorherige VorlagePlantilla anteriorПредыдущий шаблонModelo anteriorالقالب السابق
nextTemplateNext template下一个模板次のテンプレートModèle suivantNächste VorlagePlantilla siguienteСледующий шаблонPróximo modeloالقالب التالي
adjustStyleAdjust style调整样式スタイルを調整Ajuster le styleStil anpassenAjustar estiloНастроить стильAjustar estiloتعديل النمط
openPreviewEnlarge preview放大预览プレビューを拡大Agrandir l’aperçuVorschau vergrößernAmpliar vista previaУвеличить предпросмотрAmpliar préviaتكبير المعاينة
enlargedPreviewEnlarged card preview放大的卡片预览拡大カードプレビューAperçu agrandi de la carteVergrößerte KartenvorschauVista previa ampliadaУвеличенный предпросмотрPrévia ampliada do cartãoمعاينة البطاقة المكبرة
closePreviewClose enlarged preview关闭放大预览拡大プレビューを閉じるFermer l’aperçu agrandiVergrößerte Vorschau schließenCerrar vista ampliadaЗакрыть предпросмотрFechar prévia ampliadaإغلاق المعاينة المكبرة

Add these exact localized SEO strings; do not reuse the English strings in localized files:

ts
// 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:

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:

md
---
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:

bash
rg -n 'id="text"|id="theme"|id="style"|about-morse-code-cards' docs/.vitepress/dist/card.html

Expected: 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
bash
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:

ts
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:

ts
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__canvas to aspect-ratio: 3 / 5 and 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
bash
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.