Skip to content

MorseCode Card Style Adjustment 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 Minimal/Photo preset split with a customizable Default theme, add schema-driven text/background/icon controls and full theme reset, keep Text and Signal as the second theme, and remove SEO prose from CardGenerator.

Architecture: Upgrade the serializable template contract to schema v2 and keep user edits as normalized overrides relative to template defaults. A new pure customization module owns resolution, dirty checks, and reset semantics; scene.ts converts templates plus resolved overrides into standard SVG nodes; CardRenderer.vue remains the only visual renderer for Preview, SVG, and PNG. CardGenerator continues to orchestrate async uploads, reset confirmation, sharing, and the single Preview while focused settings components emit structured edit intents.

Tech Stack: Vue 3 SFCs, TypeScript, VitePress 1.6, SVG, Lucide Vue icons, Vitest + Vue Test Utils, Playwright.


File Map

Serializable contract and defaults

  • Modify: docs/.vitepress/theme/card/types.ts
  • Modify: docs/.vitepress/theme/card/validate.ts
  • Modify: docs/.vitepress/theme/card/templates.ts
  • Create: docs/.vitepress/theme/card/assets.ts
  • Test: tests/unit/card-schema.test.ts
  • Test: tests/unit/card-templates.test.ts

Normalized customization state

  • Create: docs/.vitepress/theme/card/customization.ts
  • Modify: docs/.vitepress/theme/card/useCardGenerator.ts
  • Modify: docs/.vitepress/theme/card/image.ts
  • Create: tests/unit/card-customization.test.ts
  • Modify: tests/unit/card-state.test.ts
  • Modify: tests/unit/card-image.test.ts

Scene and rendering

  • Modify: docs/.vitepress/theme/card/scene.ts
  • Modify: docs/.vitepress/theme/card/generators.ts
  • Modify: docs/.vitepress/theme/components/card/CardRenderer.vue
  • Modify: docs/.vitepress/theme/components/card/TemplateGallery.vue
  • Modify: docs/.vitepress/theme/card/export.ts
  • Modify: tests/unit/card-scene.test.ts
  • Modify: tests/unit/card-generators.test.ts
  • Modify: tests/components/card-renderer.test.ts
  • Modify: tests/components/template-gallery.test.ts
  • Modify: tests/unit/card-export.test.ts

Share query

  • Modify: docs/.vitepress/theme/card/query.ts
  • Modify: tests/unit/card-query.test.ts
  • Modify: tests/unit/card-state.test.ts

Localized UI contract

  • Modify: all nine modules under docs/.vitepress/i18n/{en,zh,ar,ru,ja,es,fr,de,pt}.mjs
  • Modify: tests/unit/card-i18n.test.ts

Settings and orchestration

  • Modify: docs/.vitepress/theme/components/card/CardSettings.vue
  • Create: docs/.vitepress/theme/components/card/CardBackgroundSettings.vue
  • Create: docs/.vitepress/theme/components/card/CardIconSettings.vue
  • Create: docs/.vitepress/theme/components/card/CardResetDialog.vue
  • Modify: docs/.vitepress/theme/components/card/CardGenerator.vue
  • Delete: docs/.vitepress/theme/components/card/CardSeo.vue
  • Modify: tests/components/card-settings.test.ts
  • Create: tests/components/card-reset-dialog.test.ts
  • Modify: tests/components/card-generator.test.ts

Browser workflow

  • Modify: tests/e2e/card.spec.ts

Do not modify the nine Card Markdown routes beyond verifying that their existing frontmatter, H1, and <CardGenerator /> mount remain unchanged.

Task 1: Define and validate the schema-v2 customization contract

Files:

  • Modify: tests/unit/card-schema.test.ts

  • Modify: docs/.vitepress/theme/card/types.ts

  • Modify: docs/.vitepress/theme/card/validate.ts

  • [ ] Step 1: Replace the schema-v1 type fixture with a schema-v2 fixture

Change validTemplate() in tests/unit/card-schema.test.ts to use schemaVersion: 2, a static tokens map, and this customization shape:

ts
customization: {
  visibility: [
    {
      id: 'source',
      i18n: 'showSource',
      elements: ['source'],
      defaultVisible: false,
    },
  ],
  typography: {
    elements: ['source', 'code'],
    fonts: ['Inter', 'ui-monospace'],
    defaultFont: 'Inter',
    size: { min: 24, max: 120, step: 2, default: 64 },
    defaultColor: '#ffffff',
  },
  colorPresets: [
    {
      id: 'vaporwave',
      i18n: 'presetVaporwave',
      textColor: '#ffffff',
      background: {
        mode: 'gradient',
        value: {
          startColor: '#ff00ff',
          endColor: '#00ffff',
          angle: 135,
        },
      },
    },
  ],
  background: {
    element: 'background',
    imageElement: 'background-image',
    imageSlot: 'background',
    defaultMode: 'gradient',
    gradient: {
      startColor: '#ff00ff',
      endColor: '#00ffff',
      angle: 135,
    },
    solid: { color: '#111111' },
    image: {
      positionX: { min: 0, max: 1, step: 0.01, default: 0.5 },
      positionY: { min: 0, max: 1, step: 0.01, default: 0.5 },
      blur: { min: 0, max: 40, step: 1, default: 0 },
    },
  },
  icon: {
    element: 'icon-image',
    slot: 'icon',
    positionX: { min: 0, max: 1, step: 0.01, default: 0.5 },
    positionY: { min: 0, max: 1, step: 0.01, default: 0.5 },
    size: { min: 0.05, max: 0.6, step: 0.01, default: 0.24 },
    opacity: { min: 0, max: 1, step: 0.01, default: 0.3 },
  },
},
layoutVariants: [
  {
    id: 'source-hidden',
    when: { visibility: { source: false } },
    elements: {
      code: { frame: { x: 100, y: 290, width: 880, height: 500 } },
    },
  },
],

Add background-image and icon-image template elements and matching slots to the fixture. Add assertions that invalid defaults, non-finite ranges, unknown element/slot references, duplicate preset IDs, unsafe colors, malformed layout conditions, and out-of-canvas variant frames produce stable invalid-customization or invalid-layout-variant issues.

  • [ ] Step 2: Run the schema test and verify the old contract fails

Run:

bash
npm test -- tests/unit/card-schema.test.ts

Expected: FAIL because CardTemplate only accepts schema version 1 and the validator does not understand tokens, v2 customization, or layout variants.

  • [ ] Step 3: Replace the legacy customization types

In docs/.vitepress/theme/card/types.ts, remove PaletteDefinition, BackgroundPaintControl, allowFocalPoint, allowOpacity, the legacy palette/font/background customization fields, and the style fields paletteId, fontId, and canvasPaintOverride. Add these exact public contracts:

ts
export interface NumericControl {
  min: number
  max: number
  step: number
  default: number
}

export interface TypographyValue {
  family: string
  size: number
  color: string
}

export interface GradientValue {
  startColor: string
  endColor: string
  angle: number
}

export interface SolidValue {
  color: string
}

export type BackgroundMode = 'gradient' | 'solid' | 'image'

export interface BackgroundImageValue {
  positionX: number
  positionY: number
  blur: number
}

export interface IconValue {
  positionX: number
  positionY: number
  size: number
  opacity: number
}

export interface VisibilityControl {
  id: string
  i18n: string
  elements: string[]
  defaultVisible: boolean
}

export interface TypographyCustomization {
  elements: string[]
  fonts: string[]
  defaultFont: string
  size: NumericControl
  defaultColor: string
}

export type PresetBackground =
  | { mode: 'gradient'; value: GradientValue }
  | { mode: 'solid'; value: SolidValue }

export interface ColorPreset {
  id: string
  i18n: string
  textColor: string
  background: PresetBackground
}

export interface BackgroundCustomization {
  element: string
  imageElement: string
  imageSlot: string
  defaultMode: BackgroundMode
  gradient: GradientValue
  solid: SolidValue
  image: {
    positionX: NumericControl
    positionY: NumericControl
    blur: NumericControl
  }
}

export interface IconCustomization {
  element: string
  slot: string
  positionX: NumericControl
  positionY: NumericControl
  size: NumericControl
  opacity: NumericControl
}

export interface LayoutElementOverride {
  frame?: Frame
}

export interface LayoutVariant {
  id: string
  when: { visibility: Record<string, boolean> }
  elements: Record<string, LayoutElementOverride>
}

export interface TemplateCustomization {
  visibility: VisibilityControl[]
  typography: TypographyCustomization
  colorPresets: ColorPreset[]
  background: BackgroundCustomization
  icon: IconCustomization
}

export interface TypographyOverride {
  family?: string
  size?: number
  color?: string
}

export interface BackgroundOverrides {
  mode?: BackgroundMode
  gradient: Partial<GradientValue>
  solid: Partial<SolidValue>
  image: Partial<BackgroundImageValue>
}

export type BackgroundPatch =
  | { mode: 'gradient'; value: Partial<GradientValue> }
  | { mode: 'solid'; value: Partial<SolidValue> }
  | { mode: 'image'; value: Partial<BackgroundImageValue> }

export interface CardCustomizationState {
  visibility: Record<string, boolean>
  typography: TypographyOverride
  background: BackgroundOverrides
  icon: Partial<IconValue>
}

Update ImageElement with optional asset?: string, focalPoint?: Point, and blur?: number. An image element may declare exactly one source among slot, src, or asset; focalPoint and blur are resolved scene fields and are not set by user-upload loading. Reduce UserImage to immutable binary metadata:

ts
export interface UserImage {
  name: string
  type: RasterMimeType
  dataUrl: string
  width: number
  height: number
}

Finish the top-level types with:

ts
export interface CardTemplate {
  schemaVersion: 2
  id: string
  family?: string
  canvas: Size & { resize: 'fixed' }
  slots: Record<string, ImageSlot>
  paints: Record<string, PaintDefinition>
  tokens: Record<string, StyleValue>
  elements: CardElement[]
  customization: TemplateCustomization
  layoutVariants: LayoutVariant[]
}

export interface CardState {
  inputMode: CardInputMode
  sourceText: string
  morseCode: string
  templateId: string
  customization: CardCustomizationState
  images: Record<string, UserImage>
}
  • [ ] Step 4: Extend template validation for v2

In validate.ts, require schemaVersion === 2, validate every NumericControl with the following helper, and replace palette validation with token validation:

ts
function isValidNumericControl(value: unknown): value is NumericControl {
  if (!isRecord(value)) return false
  const { min, max, step, default: defaultValue } = value
  return (
    isFiniteNumber(min) &&
    isFiniteNumber(max) &&
    isFiniteNumber(step) &&
    isFiniteNumber(defaultValue) &&
    min < max &&
    step > 0 &&
    defaultValue >= min &&
    defaultValue <= max
  )
}

Validation must enforce all of these concrete rules:

  • typography element IDs, background element IDs, icon element ID, and layout override IDs exist;
  • background/icon slot IDs exist and point to ImageSlot entries;
  • visibility and preset IDs are non-empty and unique;
  • every visibility condition names a declared control;
  • fonts are non-empty and include defaultFont;
  • customization colors use the opaque pattern ^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$; fixed element styles may continue using the existing safe style-color pattern;
  • gradient angles are finite and normalize into 0..359;
  • layout frames pass validateFrame and stay within the fixed canvas;
  • slot, src, and asset are mutually exclusive;
  • runtime scene nodes may contain resolved src but not unresolved asset.

Use invalid-customization, invalid-layout-variant, invalid-image-source, and unresolved-image-asset as the stable issue codes asserted by the tests.

  • [ ] Step 5: Run the schema test

Run:

bash
npm test -- tests/unit/card-schema.test.ts

Expected: PASS.

  • [ ] Step 6: Commit the schema contract
bash
git add docs/.vitepress/theme/card/types.ts docs/.vitepress/theme/card/validate.ts tests/unit/card-schema.test.ts
git commit -m "refactor: define card customization schema v2"

Task 2: Replace three presets with two schema-v2 themes

Files:

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

  • Modify: docs/.vitepress/theme/card/templates.ts

  • Create: docs/.vitepress/theme/card/assets.ts

  • [ ] Step 1: Write the two-theme contract tests

Replace the three-preset expectations with:

ts
expect(templates.map(({ id }) => id)).toEqual([
  'signal-default',
  'signal-dual',
])
expect(defaultTemplateId).toBe('signal-default')
expect(getTemplate('signal-minimal').id).toBe('signal-default')
expect(getTemplate('signal-photo').id).toBe('signal-default')

The last two assertions verify generic unknown-template fallback, not compatibility aliases. Add assertions that:

ts
const defaultTheme = getTemplate('signal-default')
const dual = getTemplate('signal-dual')

expect(defaultTheme.schemaVersion).toBe(2)
expect(defaultTheme.customization.visibility).toContainEqual(
  expect.objectContaining({ id: 'source', defaultVisible: false }),
)
expect(dual.customization.visibility).toEqual(
  expect.arrayContaining([
    expect.objectContaining({ id: 'source', defaultVisible: true }),
    expect.objectContaining({ id: 'labels', defaultVisible: true }),
  ]),
)
expect(
  templates.flatMap(({ customization }) => customization.visibility)
    .some(({ id }) => id === 'code'),
).toBe(false)
expect(Object.keys(defaultTheme.slots)).toEqual(['background', 'icon'])
expect(Object.keys(dual.slots)).toEqual(['background', 'icon'])
expect(defaultTheme.elements.map(({ id }) => id)).toEqual([
  'background',
  'background-image',
  'icon-image',
  'source',
  'code',
  'brand-logo',
  'brand',
])

Also assert each template is JSON-serializable, validates without issues, has one asset: 'favicon' image directly before one permanent brand text, and declares no palettes, backgroundPaints, or showCode control.

Import cardAssets and assert cardAssets.favicon starts with data:image/svg+xml;charset=utf-8,, contains no remote URL, and is the value returned by resolveCardAsset('favicon'); resolveCardAsset('missing') must be undefined.

  • [ ] Step 2: Run the template test and verify it fails

Run:

bash
npm test -- tests/unit/card-templates.test.ts

Expected: FAIL because the template list still contains signal-minimal, signal-dual, and signal-photo schema-v1 objects.

  • [ ] Step 3: Replace templates.ts with v2 data

Keep the existing systemSans and systemMono safe stacks. Add shared slots and controls using plain serializable constants:

ts
const slots = {
  background: { type: 'image', i18n: 'backgroundImage', required: false },
  icon: { type: 'image', i18n: 'iconImage', required: false },
} as const

const background = {
  element: 'background',
  imageElement: 'background-image',
  imageSlot: 'background',
  defaultMode: 'gradient',
  gradient: {
    startColor: '#ff00ff',
    endColor: '#00ffff',
    angle: 135,
  },
  solid: { color: '#111214' },
  image: {
    positionX: { min: 0, max: 1, step: 0.01, default: 0.5 },
    positionY: { min: 0, max: 1, step: 0.01, default: 0.5 },
    blur: { min: 0, max: 40, step: 1, default: 0 },
  },
} as const

const icon = {
  element: 'icon-image',
  slot: 'icon',
  positionX: { min: 0, max: 1, step: 0.01, default: 0.5 },
  positionY: { min: 0, max: 1, step: 0.01, default: 0.5 },
  size: { min: 0.05, max: 0.6, step: 0.01, default: 0.24 },
  opacity: { min: 0, max: 1, step: 0.01, default: 0.3 },
} as const

const presets = [
  {
    id: 'vaporwave',
    i18n: 'presetVaporwave',
    textColor: '#ffffff',
    background: {
      mode: 'gradient',
      value: {
        startColor: '#ff00ff',
        endColor: '#00ffff',
        angle: 135,
      },
    },
  },
  {
    id: 'night',
    i18n: 'presetNight',
    textColor: '#f7f7f4',
    background: {
      mode: 'gradient',
      value: {
        startColor: '#111214',
        endColor: '#4c1d95',
        angle: 135,
      },
    },
  },
  {
    id: 'paper',
    i18n: 'presetPaper',
    textColor: '#151515',
    background: { mode: 'solid', value: { color: '#f7f7f4' } },
  },
] as const

Use these controlled media nodes in both themes before any content nodes:

ts
{
  type: 'rect',
  id: 'background',
  frame: { x: 0, y: 0, width: 1080, height: 1080 },
  style: { fill: { token: 'background' } },
},
{
  type: 'image',
  id: 'background-image',
  slot: 'background',
  role: 'background',
  fit: 'cover',
  frame: { x: 0, y: 0, width: 1080, height: 1080 },
  style: {},
},
{
  type: 'image',
  id: 'icon-image',
  slot: 'icon',
  role: 'icon',
  fit: 'contain',
  frame: { x: 410, y: 410, width: 260, height: 260 },
  style: {},
},

The icon frame above is a valid serializable template fallback; scene.ts replaces it with resolved percentage geometry whenever an icon image is loaded.

Build signal-default with the exact element order asserted in Step 1. Use an on-state source frame of { x: 100, y: 300, width: 880, height: 170 }, an on-state Morse frame of { x: 100, y: 535, width: 880, height: 300 }, and a source-hidden layout variant that changes the Morse frame to { x: 100, y: 275, width: 880, height: 530 }. Set source defaultVisible: false; use shared typography with defaultFont: systemMono, size range 26..120, step 2, default 64, and color #ffffff.

Build signal-dual from the current label/divider layout, inserting background-image and icon-image after the background. Its source control must own source, original-label, and divider; its labels control owns original-label and morse-label, so either false condition hides the original label. Set source and labels default visible. Give its source-hidden variant a Morse label frame { x: 80, y: 330, width: 920, height: 36 } and Morse frame { x: 80, y: 400, width: 920, height: 390 }. Its shared typography uses defaultFont: systemSans, size range 26..120, step 2, default 58, and color #151515. Make Paper its matching initial preset by copying background, changing defaultMode to solid, and setting solid.color to #f7f7f4.

Both templates must use these final brand nodes:

ts
{
  type: 'image',
  id: 'brand-logo',
  asset: 'favicon',
  role: 'decoration',
  fit: 'contain',
  frame: { x: 390, y: 950, width: 32, height: 32 },
  style: {},
},
{
  type: 'text',
  id: 'brand',
  value: 'morsecodes.net',
  frame: { x: 432, y: 950, width: 258, height: 32 },
  style: { fill: { token: 'muted' } },
  font: {
    family: systemSans,
    size: 22,
    minSize: 16,
    weight: 600,
    align: 'left',
    lineHeight: 1.2,
    letterSpacing: 1,
    maxLines: 1,
  },
},

Use tokens rather than palettes. Default tokens are { background: { paint: 'card-background' }, text: '#ffffff', muted: '#777777', accent: '#18a38f' }; Dual uses the same token names with its fixed accent and muted values. Define card-background as a two-stop paint matching each template default.

  • [ ] Step 4: Create the trusted built-in asset registry

Create docs/.vitepress/theme/card/assets.ts:

ts
import faviconSvg from '../../../public/favicon.svg?raw'

function svgDataUrl(source: string): string {
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`
}

export const cardAssets = {
  favicon: svgDataUrl(faviconSvg),
} as const

export type CardAssetId = keyof typeof cardAssets

export function resolveCardAsset(id: string): string | undefined {
  return Object.prototype.hasOwnProperty.call(cardAssets, id)
    ? cardAssets[id as CardAssetId]
    : undefined
}

export function isTrustedCardAssetUrl(value: string): boolean {
  return Object.values(cardAssets).some((url) => url === value)
}

The registry is the only path by which a template-declared SVG becomes an image Data URL. Do not add a generic SVG sanitizer or accept user SVG.

  • [ ] Step 5: Run schema and template tests

Run:

bash
npm test -- tests/unit/card-schema.test.ts tests/unit/card-templates.test.ts

Expected: PASS.

  • [ ] Step 6: Commit the two-theme catalog
bash
git add docs/.vitepress/theme/card/templates.ts docs/.vitepress/theme/card/assets.ts tests/unit/card-templates.test.ts
git commit -m "feat: consolidate card themes"

Task 3: Add normalized customization state and reset semantics

Files:

  • Create: tests/unit/card-customization.test.ts

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

  • Modify: tests/unit/card-image.test.ts

  • Create: docs/.vitepress/theme/card/customization.ts

  • Modify: docs/.vitepress/theme/card/useCardGenerator.ts

  • Modify: docs/.vitepress/theme/card/image.ts

  • [ ] Step 1: Write failing pure-state tests

Create tests/unit/card-customization.test.ts with cases that exercise these public functions:

ts
import {
  applyColorPreset,
  createEmptyCustomization,
  hasCustomization,
  patchBackground,
  patchIcon,
  patchTypography,
  resolveBackground,
  resolveIcon,
  resolveTypography,
  resolveVisibility,
  setBackgroundMode,
  setVisibility,
} from '../../docs/.vitepress/theme/card/customization'

Use getTemplate('signal-default') and assert:

ts
const state = createEmptyCustomization()
expect(resolveVisibility(template, state, 'source')).toBe(false)
expect(resolveTypography(template, state)).toEqual({
  family: template.customization.typography.defaultFont,
  size: 64,
  color: '#ffffff',
})
expect(resolveBackground(template, state).mode).toBe('gradient')
expect(resolveIcon(template, state)).toEqual({
  positionX: 0.5,
  positionY: 0.5,
  size: 0.24,
  opacity: 0.3,
})
expect(hasCustomization(state)).toBe(false)

Then verify equal-to-default writes remove overrides; numeric writes clamp to schema ranges; colors normalize to six-digit lowercase hex; an unlisted font is rejected; paper switches to Solid and writes text/background together; inactive Gradient/Solid values survive mode changes; icon values clamp position far enough to keep its square frame inside the canvas; clearBackgroundImage preserves other background modes; and clearIcon removes every icon override.

  • [ ] Step 2: Update state and image tests for the new shape

In tests/unit/card-state.test.ts, replace flat visual fields with:

ts
customization: {
  visibility: {},
  typography: {},
  background: { gradient: {}, solid: {}, image: {} },
  icon: {},
},
images: {},

Assert generator.isCustomized.value starts false, becomes true after any visibility/text/background/icon change or image assignment, and returns false after generator.resetCustomization(). Keep the existing template-switch test, but require all v2 overrides and images to clear while source text and Morse remain unchanged.

Remove the existing state-test assertions that call resolveScene() to inspect Brand nodes; Task 4 moves those rendering guarantees into tests/unit/card-scene.test.ts, while this task keeps card-state.test.ts focused on state transitions.

In tests/unit/card-image.test.ts, change the successful loadUserImage() result to:

ts
{
  name: 'signal.png',
  type: 'image/png',
  dataUrl: 'data:image/png;base64,AA==',
  width: 320,
  height: 180,
}

It must no longer contain focalPoint or opacity.

  • [ ] Step 3: Run the focused tests and verify missing APIs fail

Run:

bash
npm test -- tests/unit/card-customization.test.ts tests/unit/card-state.test.ts tests/unit/card-image.test.ts

Expected: FAIL because customization.ts and the nested state APIs do not exist.

  • [ ] Step 4: Implement the pure customization module

Create customization.ts with this public surface:

ts
export function createEmptyCustomization(): CardCustomizationState
export function normalizeCardColor(value: string): string | undefined
export function resolveVisibility(
  template: CardTemplate,
  state: CardCustomizationState,
  id: string,
): boolean
export function resolveTypography(
  template: CardTemplate,
  state: CardCustomizationState,
): TypographyValue
export function resolveBackground(
  template: CardTemplate,
  state: CardCustomizationState,
): { mode: BackgroundMode; gradient: GradientValue; solid: SolidValue; image: BackgroundImageValue }
export function resolveIcon(
  template: CardTemplate,
  state: CardCustomizationState,
): IconValue
export function setVisibility(
  template: CardTemplate,
  state: CardCustomizationState,
  id: string,
  visible: boolean,
): void
export function patchTypography(
  template: CardTemplate,
  state: CardCustomizationState,
  patch: Partial<TypographyValue>,
): void
export function setBackgroundMode(
  template: CardTemplate,
  state: CardCustomizationState,
  mode: BackgroundMode,
): void
export function patchBackground(
  template: CardTemplate,
  state: CardCustomizationState,
  patch: BackgroundPatch,
): void
export function patchIcon(
  template: CardTemplate,
  state: CardCustomizationState,
  patch: Partial<IconValue>,
): void
export function clearBackgroundImage(state: CardCustomizationState): void
export function clearIcon(state: CardCustomizationState): void
export function applyColorPreset(
  template: CardTemplate,
  state: CardCustomizationState,
  id: string,
): void
export function matchingColorPreset(
  template: CardTemplate,
  state: CardCustomizationState,
): string | undefined
export function hasCustomization(state: CardCustomizationState): boolean

Use Math.min(max, Math.max(min, value)) for every numeric clamp. Accept only /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; expand #rgb to normalized lowercase #rrggbb and reject alpha, named, functional, URL, or malformed colors without mutating state. A normalization helper must delete a property when its resolved value equals the template default:

ts
function assignOverride<T extends object, K extends keyof T>(
  target: T,
  key: K,
  value: T[K],
  defaultValue: T[K],
): void {
  if (value === defaultValue) delete target[key]
  else target[key] = value
}

resolveVisibility returns the explicit override when present, otherwise defaultVisible. matchingColorPreset compares resolved text color, active background mode, and every active background field; it must return undefined for Image mode.

  • [ ] Step 5: Refactor useCardGenerator around the pure module

Initialize state.customization with createEmptyCustomization(). Expose:

ts
const isCustomized = computed(
  () =>
    hasCustomization(state.customization) ||
    Object.keys(state.images).length > 0,
)

function resetCustomization(): void {
  state.customization = createEmptyCustomization()
  state.images = {}
}

Expose wrapper methods named setVisibility, patchTypography, setBackgroundMode, patchBackground, patchIcon, clearBackgroundImage, clearIcon, and applyColorPreset, each resolving getTemplate(state.templateId) when a template argument is needed and delegating to the pure module. clearBackgroundImage clears only customization.background.image; clearIcon clears only customization.icon. selectTemplate() must preserve content, set the new ID, and call resetCustomization().

Keep applyQuery() compiling by applying only input and template during this task; Task 6 will add the new style query fields.

  • [ ] Step 6: Remove presentation defaults from image loading

In image.ts, return only name, MIME, Data URL, width, and height. Do not assign focal point or opacity in the loader; those defaults now belong to template customization.

  • [ ] Step 7: Run the focused tests

Run:

bash
npm test -- tests/unit/card-customization.test.ts tests/unit/card-state.test.ts tests/unit/card-image.test.ts

Expected: PASS.

  • [ ] Step 8: Commit normalized customization state
bash
git add docs/.vitepress/theme/card/customization.ts docs/.vitepress/theme/card/useCardGenerator.ts docs/.vitepress/theme/card/image.ts tests/unit/card-customization.test.ts tests/unit/card-state.test.ts tests/unit/card-image.test.ts
git commit -m "feat: add normalized card customization state"

Task 4: Resolve layout variants, text styles, backgrounds, and icon geometry

Files:

  • Modify: tests/unit/card-scene.test.ts

  • Modify: docs/.vitepress/theme/card/scene.ts

  • Modify: docs/.vitepress/theme/card/validate.ts

  • [ ] Step 1: Write failing scene-resolution tests

Update stateFor() to use createEmptyCustomization(). Add tests for these exact behaviors:

ts
const hiddenDefault = resolveScene(defaultTheme, stateFor(defaultTheme), labels, measureText)
expect(hiddenDefault.nodes.some(({ id }) => id === 'source')).toBe(false)
expect(
  hiddenDefault.nodes.find(({ id }) => id.startsWith('code-line-'))?.frame.y,
).toBe(275)

const shown = stateFor(defaultTheme)
setVisibility(defaultTheme, shown.customization, 'source', true)
const shownScene = resolveScene(defaultTheme, shown, labels, measureText)
expect(shownScene.nodes.some(({ id }) => id === 'source')).toBe(true)
expect(
  shownScene.nodes.find(({ id }) => id.startsWith('code-line-'))?.frame.y,
).toBe(535)

Add a Dual test where source=false and labels=true: source, original label, and divider are absent; Morse label and Morse code remain and use the source-hidden frames. Add typography assertions proving source and generated Morse receive the same family, preferred size, and fill color while labels and brand retain template styles.

Add background assertions for:

  • default Gradient produces a resolved two-stop card-background paint;

  • Solid replaces the background token with a safe color;

  • Image omits the background rect, includes background-image only when a binary image exists, applies focal position and blur, and never applies opacity;

  • icon is absent without an upload and becomes a square frame derived from the canvas short side, clamped X/Y, and style opacity after upload;

  • final node order is background, icon, content, brand logo, brand text.

  • [ ] Step 2: Run the scene test and verify it fails

Run:

bash
npm test -- tests/unit/card-scene.test.ts

Expected: FAIL because scene resolution still consumes palettes and flat state and does not apply variants or media geometry.

  • [ ] Step 3: Refactor scene resolution in a fixed pipeline

Implement these private helpers in scene.ts:

ts
function resolvedVisibilityMap(
  template: CardTemplate,
  state: CardState,
): Record<string, boolean>

function selectedVariant(
  template: CardTemplate,
  visibility: Record<string, boolean>,
): LayoutVariant | undefined

function applyLayoutVariant(
  element: CardElement,
  variant: LayoutVariant | undefined,
): CardElement

function elementIsHidden(
  id: string,
  template: CardTemplate,
  visibility: Record<string, boolean>,
): boolean

function resolveMediaElement(
  element: ImageElement,
  template: CardTemplate,
  state: CardState,
): ImageElement | undefined

elementIsHidden must hide an element when any visibility control containing that element resolves false. This supports the overlapping Dual source/labels ownership.

Return this updated scene contract:

ts
export interface ResolvedScene {
  nodes: SceneNode[]
  issues: ValidationIssue[]
  tokens: Record<string, StyleValue>
  paints: Record<string, PaintDefinition>
}

Apply processing in this order: resolve visibility, choose one matching layout variant, resolve typography/background/icon, expand generated elements, resolve normal text, validate final nodes. For source and Morse targets declared in typography.elements, override family and preferred size and replace style.fill with the resolved text color. Do not alter localized labels or fixed brand nodes.

For Image background, copy the resolved position into ImageElement.focalPoint; do not put presentation fields back into UserImage. Resolve template asset IDs through resolveCardAsset(), write the trusted Data URL to src, and remove asset before final scene validation. Update validateSceneNodes() to accept src when isTrustedCardAssetUrl(src) while continuing to reject unresolved asset and all other SVG Data URLs. For icon, compute:

ts
const side = Math.min(template.canvas.width, template.canvas.height) * icon.size
const centerX = Math.min(1 - icon.size / 2, Math.max(icon.size / 2, icon.positionX))
const centerY = Math.min(1 - icon.size / 2, Math.max(icon.size / 2, icon.positionY))
const frame = {
  x: centerX * template.canvas.width - side / 2,
  y: centerY * template.canvas.height - side / 2,
  width: side,
  height: side,
}

When Gradient is active, clone template.paints and replace card-background with the resolved two-stop gradient. When Solid is active, set tokens.background to the resolved color. When Image is active and loaded, omit the base background node and include the background image node. If state requests Image but its binary is absent, resolve the template's default Gradient or Solid mode instead so Preview and export can never become blank.

  • [ ] Step 4: Run the focused scene and generator tests

Run:

bash
npm test -- tests/unit/card-scene.test.ts tests/unit/card-generators.test.ts

Expected: PASS.

  • [ ] Step 5: Commit scene resolution
bash
git add docs/.vitepress/theme/card/scene.ts docs/.vitepress/theme/card/validate.ts tests/unit/card-scene.test.ts
git commit -m "feat: resolve card layout and layer overrides"

Task 5: Render trusted favicon assets and blurred image backgrounds

Files:

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

  • Modify: tests/components/card-renderer.test.ts

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

  • Modify: tests/unit/card-export.test.ts

  • Modify: docs/.vitepress/theme/card/generators.ts

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

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

  • Modify: docs/.vitepress/theme/card/export.ts

  • [ ] Step 1: Write failing renderer and export tests

Update renderer fixtures to use signal-default or signal-dual and nested customization state. Add tests asserting:

ts
const brandLogo = wrapper.get('[data-node-id="brand-logo"]')
expect(brandLogo.attributes('href')).toBe(cardAssets.favicon)
expect(brandLogo.attributes('x')).toBe('390')
expect(wrapper.get('[data-node-id="brand"]').attributes('x')).toBe('432')

For a loaded Image background with blur: 12, require one instance-scoped filter and an expanded image geometry:

ts
const filter = wrapper.get('filter[data-filter-node="background-image"]')
const gaussian = filter.get('feGaussianBlur')
const image = wrapper.get('[data-node-id="background-image"]')

expect(gaussian.attributes('stdDeviation')).toBe('12')
expect(image.attributes('filter')).toBe(`url(#${filter.attributes('id')})`)
expect(Number(image.attributes('x'))).toBeLessThan(0)
expect(Number(image.attributes('width'))).toBeGreaterThan(1080)

Mount two Renderer instances and assert their gradient, clip, and filter IDs are all distinct and remain stable through SSR hydration.

In tests/unit/card-export.test.ts, import cardAssets and add:

ts
it('allows only the exact trusted built-in favicon SVG data URL', () => {
  const trusted = svg()
  const trustedImage = document.createElementNS(SVG_NS, 'image')
  trustedImage.setAttribute('href', cardAssets.favicon)
  trusted.append(trustedImage)
  expect(() => serializeSvg(trusted, 100, 100)).not.toThrow()

  const untrusted = svg()
  const untrustedImage = document.createElementNS(SVG_NS, 'image')
  untrustedImage.setAttribute(
    'href',
    'data:image/svg+xml;charset=utf-8,%3Csvg%3E%3C%2Fsvg%3E',
  )
  untrusted.append(untrustedImage)
  expect(() => serializeSvg(untrusted, 100, 100)).toThrow(
    'unresolved-image-resource',
  )
})

Keep the existing tests that reject arbitrary SVG Data URLs, remote URLs, foreignObject, scripts, styles, and event attributes.

Update tests/components/template-gallery.test.ts to construct v2 state with createEmptyCustomization(). Replace three-thumbnail expectations with two. For the selected theme, apply a Solid or Gradient override through the pure customization helpers and assert the thumbnail receives a cloned full customization state and cloned images; for the unselected theme, assert the thumbnail receives live/fallback content, its own template ID, createEmptyCustomization(), and no images. Preserve all compact/expanded, scrolling, RTL model, resize, selected-item reveal, and accessible name/description coverage.

  • [ ] Step 2: Run renderer/export tests and verify failure

Run:

bash
npm test -- tests/components/card-renderer.test.ts tests/components/template-gallery.test.ts tests/unit/card-export.test.ts tests/unit/card-generators.test.ts

Expected: FAIL because built-in SVG assets, blur filters, and v2 scene paints are not rendered or export-allowlisted.

  • [ ] Step 3: Remove the obsolete brand generator

Delete brandCells, brandMarkGenerator, and registerGenerator(brandMarkGenerator) from generators.ts. Update tests/unit/card-generators.test.ts so only morse.literal is expected as a built-in generator; retain duplicate registration, measurement, validation, fitting, and overflow coverage.

  • [ ] Step 4: Render resolved paints, trusted assets, and filters

In CardRenderer.vue:

  • derive gradients from scene.value.paints, not template.paints;
  • accept node.src only when isTrustedCardAssetUrl(node.src) and accept user image URLs only through the existing raster-prefix check;
  • read cover focal coordinates from node.focalPoint, not UserImage;
  • take opacity from resolved node.style.opacity, not UserImage;
  • add filterId(node.id) using the existing instance idPrefix;
  • output filters only for background image nodes with finite blur > 0.

Use this template inside <defs>:

vue
<filter
  v-for="node in blurredImageNodes"
  :id="filterId(node.id)"
  :key="filterId(node.id)"
  :data-filter-node="node.id"
  x="-20%"
  y="-20%"
  width="140%"
  height="140%"
  color-interpolation-filters="sRGB"
>
  <feGaussianBlur :stdDeviation="node.blur" />
</filter>

For blurred background geometry, expand the frame by node.blur * 3 on all sides before applying the existing cover calculation. Keep the clip path at the original template frame so the root canvas remains clean. Add filter: url(#...) only for nodes in blurredImageNodes.

In TemplateGallery.vue, import createEmptyCustomization and replace the unselected preview state with:

ts
return {
  inputMode: 'text',
  ...content,
  templateId: template.id,
  customization: createEmptyCustomization(),
  images: {},
}

Keep the selected preview branch cloning props.state, current content, and images. Clone nested customization explicitly:

ts
customization: {
  visibility: { ...props.state.customization.visibility },
  typography: { ...props.state.customization.typography },
  background: {
    ...props.state.customization.background,
    gradient: { ...props.state.customization.background.gradient },
    solid: { ...props.state.customization.background.solid },
    image: { ...props.state.customization.background.image },
  },
  icon: { ...props.state.customization.icon },
},
images: { ...props.state.images },

This keeps thumbnail rendering read-only with respect to the live editor state.

  • [ ] Step 5: Allowlist only the trusted asset during export

Import isTrustedCardAssetUrl in export.ts and replace the image resource condition with:

ts
if (
  hrefs.length === 0 ||
  hrefs.some(
    ({ value }) =>
      !isSupportedRasterDataUrl(value) &&
      !isTrustedCardAssetUrl(value),
  )
) {
  throw new Error('unresolved-image-resource')
}

Do not weaken any other export URL or element checks.

  • [ ] Step 6: Run renderer, export, and scene tests

Run:

bash
npm test -- tests/unit/card-scene.test.ts tests/components/card-renderer.test.ts tests/components/template-gallery.test.ts tests/unit/card-export.test.ts tests/unit/card-generators.test.ts

Expected: PASS.

  • [ ] Step 7: Commit the rendering changes
bash
git add docs/.vitepress/theme/card/generators.ts docs/.vitepress/theme/components/card/CardRenderer.vue docs/.vitepress/theme/components/card/TemplateGallery.vue docs/.vitepress/theme/card/export.ts tests/unit/card-generators.test.ts tests/components/card-renderer.test.ts tests/components/template-gallery.test.ts tests/unit/card-export.test.ts
git commit -m "feat: render card media layers and favicon"

Task 6: Replace legacy palette queries with structured v2 overrides

Files:

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

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

  • Modify: docs/.vitepress/theme/card/query.ts

  • Modify: docs/.vitepress/theme/card/useCardGenerator.ts

  • [ ] Step 1: Rewrite query round-trip tests

Replace palette, paint, and legacy background assertions with this v2 parsed contract:

ts
export interface ParsedCardQuery {
  mode?: CardInputMode
  text?: string
  code?: string
  template?: string
  shown?: string[]
  hidden?: string[]
  typography?: Partial<TypographyValue>
  background?:
    | { mode: 'gradient'; value: Partial<GradientValue> }
    | { mode: 'solid'; value: Partial<SolidValue> }
  issues?: QueryIssue[]
}

Use these URL keys in tests:

ts
const cardQueryKeys = [
  'mode',
  'text',
  'code',
  'template',
  'shown',
  'hidden',
  'font',
  'fontSize',
  'textColor',
  'backgroundMode',
  'gradientStart',
  'gradientEnd',
  'gradientAngle',
  'solidColor',
  'palette',
  'paint',
] as const

The last two keys are removed when serializing an existing URL but are never parsed into state.

Add a round-trip case where Default source is explicitly shown:

ts
const current = state()
current.customization.visibility.source = true
const url = serializeCardQuery('/card', current)
expect(new URL(url, 'https://example.test').searchParams.get('shown')).toBe(
  'source',
)

Add cases for a hidden default-visible Dual control, allowed font, font size, text color, Gradient colors/angle, and Solid color. Assert Image mode, background image data/position/blur, icon data/geometry, inactive background values, preset IDs, data:, blob:, raw JSON, non-finite numbers, out-of-global-range numbers, control characters, and deleted template-specific legacy fields never serialize.

Assert template=signal-minimal and template=signal-photo parse as plain strings but useCardGenerator.applyQuery() treats them as unknown and falls back to signal-default with queryIssue === 'unknown-template'.

  • [ ] Step 2: Run query and state tests and verify failure

Run:

bash
npm test -- tests/unit/card-query.test.ts tests/unit/card-state.test.ts

Expected: FAIL because the query module still reads and writes palette/paint fields and cannot represent default-hidden controls.

  • [ ] Step 3: Implement bounded v2 parsing and serialization

Import normalizeCardColor from customization.ts. Keep MAX_CARD_QUERY_CONTENT, MAX_CARD_QUERY_CONFIG, and the 64-control limit. Rename the helper to read both shown and hidden, deduplicate each list, and reject a control appearing in both lists by omitting that ID from both results. Add these strict readers:

ts
function readFiniteNumber(
  params: URLSearchParams,
  key: string,
  min: number,
  max: number,
): number | undefined {
  const raw = params.get(key)
  if (raw === null || raw.trim() !== raw || raw === '') return undefined
  const value = Number(raw)
  return Number.isFinite(value) && value >= min && value <= max
    ? value
    : undefined
}

function readSafeColor(
  params: URLSearchParams,
  key: string,
): string | undefined {
  const value = params.get(key)
  return value === null ? undefined : normalizeCardColor(value)
}

Use global parsing bounds fontSize: 1..1000, gradientAngle: 0..359; template-specific bounds are enforced later by customization setters. Only return a background object when backgroundMode is exactly gradient or solid. Never return Image mode.

Serialization must include only explicit overrides. For Image mode, omit all background keys. For Gradient or Solid, serialize only the active mode and its active overrides. Sort shown and hidden; never include brand or an undeclared visibility ID.

  • [ ] Step 4: Apply parsed overrides through schema-aware setters

In useCardGenerator.applyQuery():

  1. select the known template or signal-default fallback;
  2. replace both content fields as today;
  3. filter shown/hidden IDs through template.customization.visibility;
  4. call setVisibility, patchTypography, setBackgroundMode, and patchBackground so invalid fonts, colors, and numeric values are rejected or clamped by one normalization path;
  5. leave images empty and icon overrides empty.

Do not special-case signal-minimal or signal-photo.

  • [ ] Step 5: Run query and state tests

Run:

bash
npm test -- tests/unit/card-query.test.ts tests/unit/card-state.test.ts

Expected: PASS.

  • [ ] Step 6: Commit query v2
bash
git add docs/.vitepress/theme/card/query.ts docs/.vitepress/theme/card/useCardGenerator.ts tests/unit/card-query.test.ts tests/unit/card-state.test.ts
git commit -m "feat: serialize card style overrides"

Task 7: Update all nine localized Card contracts

Files:

  • Modify: tests/unit/card-i18n.test.ts

  • Modify: docs/.vitepress/i18n/en.mjs

  • Modify: docs/.vitepress/i18n/zh.mjs

  • Modify: docs/.vitepress/i18n/ar.mjs

  • Modify: docs/.vitepress/i18n/ru.mjs

  • Modify: docs/.vitepress/i18n/ja.mjs

  • Modify: docs/.vitepress/i18n/es.mjs

  • Modify: docs/.vitepress/i18n/fr.mjs

  • Modify: docs/.vitepress/i18n/de.mjs

  • Modify: docs/.vitepress/i18n/pt.mjs

  • [ ] Step 1: Change the required-key and template-ID tests first

Set:

ts
const presetIds = ['signal-default', 'signal-dual'] as const

Remove showCode, showBrand, palette, defaultFont, auroraBackground, defaultBackground, images, and focalPoint from both the required and proseKeys control lists. Keep the three existing SEO message keys unchanged for later reuse, even though Task 9 removes their component consumer.

Add these required keys:

ts
const styleKeys = [
  'resetStyle',
  'resetStyleTitle',
  'resetStyleDescription',
  'confirmReset',
  'cancel',
  'colorPresets',
  'textStyle',
  'icon',
  'fontSize',
  'textColor',
  'gradientBackground',
  'solidBackground',
  'imageBackground',
  'gradientStart',
  'gradientEnd',
  'gradientAngle',
  'imagePositionX',
  'imagePositionY',
  'blur',
  'iconPositionX',
  'iconPositionY',
  'iconSize',
  'presetVaporwave',
  'presetNight',
  'presetPaper',
] as const

Remove the obsolete backgroundPaints reference loop. Make the template/customization reference loop validate colorPresets[].i18n in addition to text, visibility, and slot labels.

  • [ ] Step 2: Run the i18n test and verify missing keys fail

Run:

bash
npm test -- tests/unit/card-i18n.test.ts

Expected: FAIL because every locale still exposes the old template IDs and settings copy.

  • [ ] Step 3: Apply the exact English and Chinese copy

Use these values in en.mjs and zh.mjs:

ts
const copy = {
  en: {
    resetStyle: 'Reset current theme',
    resetStyleTitle: 'Reset this theme?',
    resetStyleDescription: 'This restores the theme defaults and removes the uploaded background and icon.',
    confirmReset: 'Reset',
    cancel: 'Cancel',
    colorPresets: 'Color presets',
    content: 'Content',
    textStyle: 'Text',
    background: 'Background',
    icon: 'Icon',
    showSource: 'Show original text',
    showLabels: 'Show labels',
    font: 'Font',
    fontSize: 'Font size',
    textColor: 'Text color',
    gradientBackground: 'Gradient',
    solidBackground: 'Solid',
    imageBackground: 'Image',
    gradientStart: 'Start color',
    gradientEnd: 'End color',
    gradientAngle: 'Angle',
    imagePositionX: 'Image horizontal position',
    imagePositionY: 'Image vertical position',
    blur: 'Blur',
    iconPositionX: 'Icon horizontal position',
    iconPositionY: 'Icon vertical position',
    iconSize: 'Icon size',
    opacity: 'Opacity',
    presetVaporwave: 'Vaporwave',
    presetNight: 'Night',
    presetPaper: 'Paper',
  },
  zh: {
    resetStyle: '重置当前主题',
    resetStyleTitle: '重置这个主题?',
    resetStyleDescription: '这会恢复主题默认值,并移除已上传的背景和图标。',
    confirmReset: '重置',
    cancel: '取消',
    colorPresets: '快捷配色',
    content: '内容',
    textStyle: '文字',
    background: '背景',
    icon: '图标',
    showSource: '显示原文',
    showLabels: '显示标签',
    font: '字体',
    fontSize: '字号',
    textColor: '文字颜色',
    gradientBackground: '渐变',
    solidBackground: '纯色',
    imageBackground: '图片',
    gradientStart: '起始颜色',
    gradientEnd: '结束颜色',
    gradientAngle: '角度',
    imagePositionX: '图片水平位置',
    imagePositionY: '图片垂直位置',
    blur: '模糊',
    iconPositionX: '图标水平位置',
    iconPositionY: '图标垂直位置',
    iconSize: '图标大小',
    opacity: '不透明度',
    presetVaporwave: '蒸汽波',
    presetNight: '夜色',
    presetPaper: '纸张',
  },
}

Set English template metadata to:

ts
'signal-default': {
  name: 'Default',
  description: 'A Morse-first layout with optional original text, background, and icon.',
},
'signal-dual': {
  name: 'Text and Signal',
  description: 'A labeled original-text and Morse layout with the same style controls.',
},

Set Chinese metadata to:

ts
'signal-default': {
  name: '默认',
  description: '以摩斯码为主,可添加原文、背景和图标。',
},
'signal-dual': {
  name: '原文与信号',
  description: '带标签的原文与摩斯码布局,并提供相同的样式控制。',
},
  • [ ] Step 4: Apply the other seven locale translations

Use these exact arrays in key order resetStyle, resetStyleTitle, resetStyleDescription, confirmReset, cancel, colorPresets, textStyle, icon, fontSize, textColor, gradientBackground, solidBackground, imageBackground, gradientStart, gradientEnd, gradientAngle, imagePositionX, imagePositionY, blur, iconPositionX, iconPositionY, iconSize, presetVaporwave, presetNight, presetPaper:

ts
const localizedStyleCopy = {
  ar: ['إعادة ضبط السمة الحالية', 'إعادة ضبط هذه السمة؟', 'سيؤدي هذا إلى استعادة إعدادات السمة الافتراضية وإزالة الخلفية والأيقونة المحمّلتين.', 'إعادة الضبط', 'إلغاء', 'إعدادات ألوان مسبقة', 'النص', 'الأيقونة', 'حجم الخط', 'لون النص', 'تدرج', 'لون خالص', 'صورة', 'لون البداية', 'لون النهاية', 'الزاوية', 'موضع الصورة الأفقي', 'موضع الصورة العمودي', 'التمويه', 'موضع الأيقونة الأفقي', 'موضع الأيقونة العمودي', 'حجم الأيقونة', 'موجة بخارية', 'ليل', 'ورق'],
  ru: ['Сбросить текущую тему', 'Сбросить эту тему?', 'Будут восстановлены настройки темы по умолчанию, а загруженные фон и значок будут удалены.', 'Сбросить', 'Отмена', 'Готовые сочетания цветов', 'Текст', 'Значок', 'Размер шрифта', 'Цвет текста', 'Градиент', 'Сплошной цвет', 'Изображение', 'Начальный цвет', 'Конечный цвет', 'Угол', 'Положение изображения по горизонтали', 'Положение изображения по вертикали', 'Размытие', 'Положение значка по горизонтали', 'Положение значка по вертикали', 'Размер значка', 'Вейпорвейв', 'Ночь', 'Бумага'],
  ja: ['現在のテーマをリセット', 'このテーマをリセットしますか?', 'テーマの初期値に戻し、アップロードした背景とアイコンを削除します。', 'リセット', 'キャンセル', '配色プリセット', '文字', 'アイコン', 'フォントサイズ', '文字色', 'グラデーション', '単色', '画像', '開始色', '終了色', '角度', '画像の横位置', '画像の縦位置', 'ぼかし', 'アイコンの横位置', 'アイコンの縦位置', 'アイコンサイズ', 'ヴェイパーウェイヴ', 'ナイト', 'ペーパー'],
  es: ['Restablecer tema actual', '¿Restablecer este tema?', 'Esto restaura los valores del tema y elimina el fondo y el icono cargados.', 'Restablecer', 'Cancelar', 'Ajustes de color', 'Texto', 'Icono', 'Tamaño de fuente', 'Color del texto', 'Degradado', 'Color sólido', 'Imagen', 'Color inicial', 'Color final', 'Ángulo', 'Posición horizontal de la imagen', 'Posición vertical de la imagen', 'Desenfoque', 'Posición horizontal del icono', 'Posición vertical del icono', 'Tamaño del icono', 'Vaporwave', 'Noche', 'Papel'],
  fr: ['Réinitialiser le thème actuel', 'Réinitialiser ce thème ?', 'Cette action restaure les valeurs du thème et supprime l’arrière-plan et l’icône importés.', 'Réinitialiser', 'Annuler', 'Préréglages de couleurs', 'Texte', 'Icône', 'Taille de police', 'Couleur du texte', 'Dégradé', 'Couleur unie', 'Image', 'Couleur de départ', 'Couleur de fin', 'Angle', 'Position horizontale de l’image', 'Position verticale de l’image', 'Flou', 'Position horizontale de l’icône', 'Position verticale de l’icône', 'Taille de l’icône', 'Vaporwave', 'Nuit', 'Papier'],
  de: ['Aktuelles Thema zurücksetzen', 'Dieses Thema zurücksetzen?', 'Dadurch werden die Standardwerte des Themas wiederhergestellt und der hochgeladene Hintergrund sowie das Symbol entfernt.', 'Zurücksetzen', 'Abbrechen', 'Farbvorlagen', 'Text', 'Symbol', 'Schriftgröße', 'Textfarbe', 'Verlauf', 'Volltonfarbe', 'Bild', 'Startfarbe', 'Endfarbe', 'Winkel', 'Horizontale Bildposition', 'Vertikale Bildposition', 'Unschärfe', 'Horizontale Symbolposition', 'Vertikale Symbolposition', 'Symbolgröße', 'Vaporwave', 'Nacht', 'Papier'],
  pt: ['Redefinir tema atual', 'Redefinir este tema?', 'Isso restaura os valores do tema e remove o fundo e o ícone enviados.', 'Redefinir', 'Cancelar', 'Predefinições de cor', 'Texto', 'Ícone', 'Tamanho da fonte', 'Cor do texto', 'Gradiente', 'Cor sólida', 'Imagem', 'Cor inicial', 'Cor final', 'Ângulo', 'Posição horizontal da imagem', 'Posição vertical da imagem', 'Desfoque', 'Posição horizontal do ícone', 'Posição vertical do ícone', 'Tamanho do ícone', 'Vaporwave', 'Noite', 'Papel'],
} as const

Set showSource and both template metadata entries to these exact values:

ts
const localizedTemplateCopy = {
  ar: {
    showSource: 'إظهار النص الأصلي',
    defaultName: 'افتراضي',
    defaultDescription: 'تخطيط يركز على شفرة مورس مع نص أصلي وخلفية وأيقونة اختيارية.',
    dualName: 'النص والإشارة',
    dualDescription: 'تخطيط معنّون للنص الأصلي وشفرة مورس مع عناصر التحكم نفسها.',
  },
  ru: {
    showSource: 'Показывать исходный текст',
    defaultName: 'По умолчанию',
    defaultDescription: 'Макет с акцентом на коде Морзе и необязательными исходным текстом, фоном и значком.',
    dualName: 'Текст и сигнал',
    dualDescription: 'Макет с подписями для исходного текста и кода Морзе и теми же настройками стиля.',
  },
  ja: {
    showSource: '元のテキストを表示',
    defaultName: 'デフォルト',
    defaultDescription: 'モールス信号を中心に、元のテキスト、背景、アイコンを追加できるレイアウトです。',
    dualName: 'テキストと信号',
    dualDescription: '元のテキストとモールス信号にラベルを付け、同じスタイル設定を使えるレイアウトです。',
  },
  es: {
    showSource: 'Mostrar texto original',
    defaultName: 'Predeterminado',
    defaultDescription: 'Diseño centrado en Morse con texto original, fondo e icono opcionales.',
    dualName: 'Texto y señal',
    dualDescription: 'Diseño con etiquetas para el texto original y el código Morse, con los mismos controles de estilo.',
  },
  fr: {
    showSource: 'Afficher le texte original',
    defaultName: 'Par défaut',
    defaultDescription: 'Une mise en page centrée sur le Morse avec texte original, arrière-plan et icône facultatifs.',
    dualName: 'Texte et signal',
    dualDescription: 'Une mise en page avec libellés pour le texte original et le Morse, dotée des mêmes réglages de style.',
  },
  de: {
    showSource: 'Originaltext anzeigen',
    defaultName: 'Standard',
    defaultDescription: 'Ein Morse-zentriertes Layout mit optionalem Originaltext, Hintergrund und Symbol.',
    dualName: 'Text und Signal',
    dualDescription: 'Ein beschriftetes Layout für Originaltext und Morsecode mit denselben Stiloptionen.',
  },
  pt: {
    showSource: 'Mostrar texto original',
    defaultName: 'Padrão',
    defaultDescription: 'Um layout centrado em Morse com texto original, fundo e ícone opcionais.',
    dualName: 'Texto e sinal',
    dualDescription: 'Um layout com rótulos para o texto original e o código Morse, com os mesmos controles de estilo.',
  },
} as const

Keep each locale's existing translated shared labels when their meaning is unchanged. Do not copy English template descriptions into localized modules; the existing non-English-prose assertion must continue to pass.

  • [ ] Step 5: Run the i18n contract test

Run:

bash
npm test -- tests/unit/card-i18n.test.ts

Expected: PASS.

  • [ ] Step 6: Commit localization
bash
git add docs/.vitepress/i18n tests/unit/card-i18n.test.ts
git commit -m "feat: localize card style controls"

Task 8: Build the four-group schema-driven Style editor

Files:

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

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

  • Create: docs/.vitepress/theme/components/card/CardBackgroundSettings.vue

  • Create: docs/.vitepress/theme/components/card/CardIconSettings.vue

  • [ ] Step 1: Replace legacy settings assertions with the confirmed information architecture

Update tests/components/card-settings.test.ts to mount signal-default with nested state. Assert the root order is:

ts
expect(wrapper.findAll('[data-color-preset]').map((node) => node.attributes('data-color-preset'))).toEqual([
  'vaporwave',
  'night',
  'paper',
])
expect(wrapper.findAll('[data-section-toggle]').map((node) => node.attributes('data-section-toggle'))).toEqual([
  'content',
  'text',
  'background',
  'icon',
])

Replace the test label fixture with the English control keys from Task 7 so every visible control has a real accessible name.

Assert there is no data-visibility="code", no palette state control, and no background opacity input. Add event tests for:

  • source visibility;
  • preset ID;
  • font, range/numeric font size, and hex text color;
  • Background Gradient/Solid/Image mode selection;
  • Gradient start/end/angle;
  • Solid color;
  • Image upload, remove, position X/Y, and blur;
  • Icon upload, remove, X/Y, size, and opacity.

Keep the mobile test, but require all four groups to start collapsed below 900px and to toggle independently.

  • [ ] Step 2: Run settings tests and verify failure

Run:

bash
npm test -- tests/components/card-settings.test.ts

Expected: FAIL because CardSettings still renders Content/Style/Images with palette and legacy image fields.

  • [ ] Step 3: Implement the focused Background component

Create CardBackgroundSettings.vue with these props and emits:

ts
const props = defineProps<{
  template: CardTemplate
  state: CardState
  labels: Record<string, string>
  image?: UserImage
  error?: string
}>()

const emit = defineEmits<{
  mode: [mode: BackgroundMode]
  patch: [patch: BackgroundPatch]
  image: [file: File]
  removeImage: []
}>()

Use resolveBackground() for current values. Render a three-button segmented control with data-background-mode="gradient|solid|image", role="radiogroup", and aria-checked. When Image is selected without props.image, click the associated hidden raster file input; do not emit mode until the parent successfully loads the file. When an image already exists, selecting Image emits mode: image immediately.

Render only the active fields:

  • Gradient: data-gradient-start, data-gradient-end, data-gradient-angle range and number;
  • Solid: data-solid-color;
  • Image: data-image-slot="background", filename/removal, data-background-x, data-background-y, and data-background-blur.

There must be no Image opacity control. Clear the native file input value after every change so the same file can be selected again. Bound numeric event values before emitting.

  • [ ] Step 4: Implement the focused Icon component

Create CardIconSettings.vue with:

ts
const props = defineProps<{
  template: CardTemplate
  state: CardState
  labels: Record<string, string>
  image?: UserImage
  error?: string
}>()

const emit = defineEmits<{
  image: [file: File]
  removeImage: []
  patch: [patch: Partial<IconValue>]
}>()

Always render the raster upload input data-image-slot="icon". Render filename/removal and data-icon-x, data-icon-y, data-icon-size, and data-icon-opacity only when props.image exists. Resolve defaults with resolveIcon(). Use range plus number inputs, with % presentation derived from the normalized 0..1 values while emitted values remain normalized decimals.

  • [ ] Step 5: Rewrite CardSettings.vue as the composition shell

Use these events:

ts
const emit = defineEmits<{
  visibility: [id: string, visible: boolean]
  preset: [id: string]
  typography: [patch: Partial<TypographyValue>]
  backgroundMode: [mode: BackgroundMode]
  backgroundPatch: [patch: BackgroundPatch]
  image: [slot: string, file: File]
  removeImage: [slot: string]
  iconPatch: [patch: Partial<IconValue>]
}>()

Render color preset swatches before all fieldsets. Each button uses data-color-preset, a localized aria-label, aria-pressed from matchingColorPreset(), and a CSS swatch composed from the preset text/background values.

Render exactly four independently collapsible fieldsets. Content loops only over schema visibility controls. Text uses resolveTypography() and renders the allowed font select plus synchronized range/number size and color/hex inputs. Background and Icon delegate to the new child components.

Keep controls at stable heights, use logical properties for RTL, use Lucide Trash2 and ChevronDown, and preserve 6px-or-smaller radii. Do not introduce a form library or generic schema renderer.

  • [ ] Step 6: Run settings tests

Run:

bash
npm test -- tests/components/card-settings.test.ts

Expected: PASS.

  • [ ] Step 7: Commit the Style editor
bash
git add docs/.vitepress/theme/components/card/CardSettings.vue docs/.vitepress/theme/components/card/CardBackgroundSettings.vue docs/.vitepress/theme/components/card/CardIconSettings.vue tests/components/card-settings.test.ts
git commit -m "feat: build card style editor groups"

Task 9: Wire full reset, async media, and SEO removal into CardGenerator

Files:

  • Create: tests/components/card-reset-dialog.test.ts

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

  • Modify: tests/unit/card-i18n.test.ts

  • Create: docs/.vitepress/theme/components/card/CardResetDialog.vue

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

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

  • [ ] Step 1: Write failing reset-dialog tests

Create tests/components/card-reset-dialog.test.ts using CardLightbox as the local native-dialog reference. Assert:

  • open=false does not call showModal();

  • open=true renders localized title, description, confirm, and cancel controls and calls showModal() once;

  • Confirm emits confirm once;

  • Cancel, backdrop click, native cancel, and Escape emit cancel without confirm;

  • close restores returnFocus only when it remains connected;

  • a showModal() failure emits openFailed and leaves no stale open state.

  • [ ] Step 2: Add failing generator integration assertions

In tests/components/card-generator.test.ts, update template IDs and replace old palette/background/image selectors. Add one integration test that:

  1. expects [data-reset-card] disabled initially;
  2. changes source visibility, text, background, and both uploads;
  3. expects Reset enabled;
  4. opens the dialog and cancels, preserving state;
  5. opens again and confirms;
  6. expects theme defaults, no uploaded filenames, no image nodes, disabled Reset, and unchanged text/Morse content;
  7. resolves a stale pre-reset image promise and proves it cannot repopulate state;
  8. verifies focus returns to Reset after dialog close.

Update the test's hoisted card mock with every English key from Task 7 and replace its templatesById entries with signal-default and signal-dual before mounting CardGenerator.

Add a theme-switch test proving the same complete reset occurs without a dialog. Add source inspection assertions that CardGenerator.vue does not import/render CardSeo and that CardSeo.vue no longer exists.

  • [ ] Step 3: Run focused tests and verify failure

Run:

bash
npm test -- tests/components/card-reset-dialog.test.ts tests/components/card-generator.test.ts tests/unit/card-i18n.test.ts

Expected: FAIL because Reset/dialog wiring and SEO removal are missing.

  • [ ] Step 4: Implement CardResetDialog.vue

Use this contract:

ts
const props = defineProps<{
  open: boolean
  title: string
  description: string
  confirmLabel: string
  cancelLabel: string
  returnFocus?: HTMLElement | null
}>()

const emit = defineEmits<{
  confirm: []
  cancel: []
  closed: []
  openFailed: []
}>()

Render a native <dialog> with a compact action row. Watch open, call showModal() only after mount, call close() on false, prevent the native cancel default before emitting cancel, and emit closed from the dialog close event after restoring focus. Use aria-labelledby and aria-describedby with useId().

  • [ ] Step 5: Replace legacy CardGenerator style handlers

Remove StyleValue, setPalette, setFont, sameStyleValue, setBackground, and generic image presentation patching. Add handlers that call the model APIs and then invalidatePrepared():

ts
function setVisibility(id: string, visible: boolean): void
function applyPreset(id: string): void
function patchTypography(patch: Partial<TypographyValue>): void
function setBackgroundMode(mode: BackgroundMode): void
function patchBackground(patch: BackgroundPatch): void
function patchIcon(patch: Partial<IconValue>): void

On successful setImage('background', file), assign the binary image and call model.setBackgroundMode('image') in the same accepted request branch. On failed/cancelled load, retain the prior mode. removeImage('background') must remove the binary, call model.clearBackgroundImage(), and, if Image is active, restore the template default background mode. Removing Icon deletes its binary and calls model.clearIcon().

Keep the current request sequence checks. Reset and theme switching must increment imageRequestSequence, clear imageRequests and localized image errors, and invalidate prepared sharing.

  • [ ] Step 6: Add the Style heading Reset button and dialog

Import RotateCcw and CardResetDialog. Keep the real <h2 id="style"> as the first Style-stage child. Add a sibling button in the same grid cell:

vue
<button
  ref="resetStyleButton"
  type="button"
  class="card-generator__reset-style"
  data-reset-card
  :title="card.resetStyle"
  :aria-label="card.resetStyle"
  :disabled="!model.isCustomized.value"
  @click="resetDialogOpen = true"
>
  <RotateCcw :size="18" aria-hidden="true" />
</button>

Wire CardSettings with the Task 8 events. Render CardResetDialog beside CardLightbox; confirm must clear requests/errors, call model.resetCustomization(), close the dialog, and invalidate the revision. cancel only closes it. openFailed closes it, restores focus, and leaves the current customization untouched.

Style .card-generator__reset-style with the same 32px geometry, border, hover, disabled, and focus treatment as .card-generator__gallery-toggle. Place it at Style row 1/column 1 with justify-self: end, and add padding-inline-end: 44px to the Style heading.

  • [ ] Step 7: Remove CardSeo without moving SEO prose

Delete the import, <CardSeo>, .card-generator__seo, and the unused fourth flow row. Delete CardSeo.vue. Update tests/unit/card-i18n.test.ts route/source assertions so the generator contains exactly the three workflow H2s and zero <CardSeo> nodes; keep the existing Card Markdown source snapshots and existing SEO localization messages unchanged.

  • [ ] Step 8: Run component, i18n, state, and export tests

Run:

bash
npm test -- tests/components/card-reset-dialog.test.ts tests/components/card-settings.test.ts tests/components/card-generator.test.ts tests/unit/card-i18n.test.ts tests/unit/card-state.test.ts tests/unit/card-query.test.ts tests/components/card-renderer.test.ts tests/unit/card-export.test.ts

Expected: PASS.

  • [ ] Step 9: Commit orchestration and SEO removal
bash
git add docs/.vitepress/theme/components/card/CardGenerator.vue docs/.vitepress/theme/components/card/CardResetDialog.vue docs/.vitepress/theme/components/card/CardSeo.vue tests/components/card-reset-dialog.test.ts tests/components/card-generator.test.ts tests/unit/card-i18n.test.ts
git commit -m "feat: reset card theme customizations"

Task 10: Update browser workflows and verify the complete feature

Files:

  • Modify: tests/e2e/card.spec.ts

  • [ ] Step 1: Update shared E2E helpers and static-content expectations

Replace every signal-minimal/signal-photo selector with signal-default where the Default theme is intended. Keep signal-dual checks. Change ensureSectionOpen to accept:

ts
section: 'content' | 'text' | 'background' | 'icon'

Replace uploadPhotoSlot() with uploadRasterSlot(page, slot) using the existing generated PNG payload. Update expected Preview image counts to include the permanent favicon image:

  • Default without uploads: one image (brand-logo);
  • with background: two images;
  • with background and icon: three images.

Remove seoTitle, seoIntro, and seoPrivacy from staticRouteContent and assert those strings do not appear inside .card-generator. Continue asserting each route H1, three workflow headings, Outline entries, and <CardGenerator /> behavior.

  • [ ] Step 2: Add the Default layout and controls workflow

Create one test that enters SOS, scrolls to Style, and asserts:

ts
await expect(page.locator('[data-node-id="source"]')).toHaveCount(0)
const morse = page.locator('[data-node-id^="code-line-"]').first()
expect(Number(await morse.getAttribute('y'))).toBeGreaterThanOrEqual(275)

Open Content, enable original text, and verify source appears above Morse. Verify no data-visibility="code". Apply Paper preset, then custom font size and text color, and verify SVG attributes change. Change to custom Gradient start/end/angle, switch to Solid, and switch back to prove per-mode values remain.

  • [ ] Step 3: Add media layer, reset, and query privacy coverage

Upload a background and icon. Set background X/Y and blur; set icon X/Y, size, and opacity. Assert DOM order:

ts
const nodeIds = await page.locator('.card-preview [data-node-id]').evaluateAll(
  (nodes) => nodes.map((node) => node.getAttribute('data-node-id')),
)
expect(nodeIds.indexOf('background-image')).toBeLessThan(
  nodeIds.indexOf('icon-image'),
)
expect(nodeIds.indexOf('icon-image')).toBeLessThan(
  nodeIds.findIndex((id) => id?.startsWith('code-line-')),
)
expect(nodeIds.indexOf('brand-logo')).toBeLessThan(
  nodeIds.indexOf('brand'),
)

Prepare sharing and assert the URL contains text/visibility/typography but no Image mode, data:, file names, blur, focal values, or icon geometry. Download SVG and assert it contains raster uploads, one trusted inline favicon SVG Data URL, the blur filter, no remote URL, and no parser error. Download PNG and use the existing pixel helpers to assert a nonblank multi-color image.

Open Reset, cancel and verify state remains; reopen and confirm, then verify Default source hidden, vaporwave background restored, uploads removed, Reset disabled, and input preserved. Switch to Dual after edits and verify the same reset without a dialog.

  • [ ] Step 4: Add responsive and RTL geometry assertions

In both Playwright projects, assert the four section headings and their longest translated labels fit inside .card-generator__settings-scroll without horizontal overflow. For /ar/card, verify Reset stays at the logical end of the Style heading, conditional controls stay inside their parents, X/Y values are not mirrored, and no element overlaps the compact Preview.

Capture a screenshot attachment for Default with original text enabled, custom background, and icon:

ts
await test.info().attach('card-style-adjustment', {
  body: await page.locator('[data-card-stage="style"]').screenshot(),
  contentType: 'image/png',
})
  • [ ] Step 5: Run the focused browser suite

Run:

bash
npx playwright test tests/e2e/card.spec.ts

Expected: PASS in Desktop Chrome and Pixel 7 with no console errors, page errors, clipped controls, overlap, or blank canvas pixels.

  • [ ] Step 6: Run the entire unit/component suite

Run:

bash
npm test

Expected: PASS.

  • [ ] Step 7: Build all VitePress routes

Run:

bash
npm run docs:build

Expected: PASS with all nine Card routes generated and no unresolved favicon.svg?raw import or hydration warning.

  • [ ] Step 8: Check formatting and the final diff

Run:

bash
git diff --check
git status --short

Expected: no whitespace errors; only the files named in this plan are modified.

  • [ ] Step 9: Commit end-to-end coverage
bash
git add tests/e2e/card.spec.ts
git commit -m "test: verify card style adjustment workflow"