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.tsModify:
docs/.vitepress/theme/card/types.tsModify:
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:
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:
npm test -- tests/unit/card-schema.test.tsExpected: 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:
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:
export interface UserImage {
name: string
type: RasterMimeType
dataUrl: string
width: number
height: number
}Finish the top-level types with:
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:
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
ImageSlotentries; - 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
validateFrameand stay within the fixed canvas; slot,src, andassetare mutually exclusive;- runtime scene nodes may contain resolved
srcbut not unresolvedasset.
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:
npm test -- tests/unit/card-schema.test.tsExpected: PASS.
- [ ] Step 6: Commit the schema contract
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.tsModify:
docs/.vitepress/theme/card/templates.tsCreate:
docs/.vitepress/theme/card/assets.ts[ ] Step 1: Write the two-theme contract tests
Replace the three-preset expectations with:
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:
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:
npm test -- tests/unit/card-templates.test.tsExpected: FAIL because the template list still contains signal-minimal, signal-dual, and signal-photo schema-v1 objects.
- [ ] Step 3: Replace
templates.tswith v2 data
Keep the existing systemSans and systemMono safe stacks. Add shared slots and controls using plain serializable constants:
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 constUse these controlled media nodes in both themes before any content nodes:
{
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:
{
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:
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:
npm test -- tests/unit/card-schema.test.ts tests/unit/card-templates.test.tsExpected: PASS.
- [ ] Step 6: Commit the two-theme catalog
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.tsModify:
tests/unit/card-state.test.tsModify:
tests/unit/card-image.test.tsCreate:
docs/.vitepress/theme/card/customization.tsModify:
docs/.vitepress/theme/card/useCardGenerator.tsModify:
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:
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:
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:
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:
{
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:
npm test -- tests/unit/card-customization.test.ts tests/unit/card-state.test.ts tests/unit/card-image.test.tsExpected: 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:
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): booleanUse 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:
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
useCardGeneratoraround the pure module
Initialize state.customization with createEmptyCustomization(). Expose:
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:
npm test -- tests/unit/card-customization.test.ts tests/unit/card-state.test.ts tests/unit/card-image.test.tsExpected: PASS.
- [ ] Step 8: Commit normalized customization state
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.tsModify:
docs/.vitepress/theme/card/scene.tsModify:
docs/.vitepress/theme/card/validate.ts[ ] Step 1: Write failing scene-resolution tests
Update stateFor() to use createEmptyCustomization(). Add tests for these exact behaviors:
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-backgroundpaint;Solid replaces the background token with a safe color;
Image omits the background rect, includes
background-imageonly 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:
npm test -- tests/unit/card-scene.test.tsExpected: 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:
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 | undefinedelementIsHidden 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:
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:
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:
npm test -- tests/unit/card-scene.test.ts tests/unit/card-generators.test.tsExpected: PASS.
- [ ] Step 5: Commit scene resolution
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.tsModify:
tests/components/card-renderer.test.tsModify:
tests/components/template-gallery.test.tsModify:
tests/unit/card-export.test.tsModify:
docs/.vitepress/theme/card/generators.tsModify:
docs/.vitepress/theme/components/card/CardRenderer.vueModify:
docs/.vitepress/theme/components/card/TemplateGallery.vueModify:
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:
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:
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:
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:
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.tsExpected: 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, nottemplate.paints; - accept
node.srconly whenisTrustedCardAssetUrl(node.src)and accept user image URLs only through the existing raster-prefix check; - read cover focal coordinates from
node.focalPoint, notUserImage; - take opacity from resolved
node.style.opacity, notUserImage; - add
filterId(node.id)using the existing instanceidPrefix; - output filters only for background image nodes with finite
blur > 0.
Use this template inside <defs>:
<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:
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:
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:
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:
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.tsExpected: PASS.
- [ ] Step 7: Commit the rendering changes
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.tsModify:
tests/unit/card-state.test.tsModify:
docs/.vitepress/theme/card/query.tsModify:
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:
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:
const cardQueryKeys = [
'mode',
'text',
'code',
'template',
'shown',
'hidden',
'font',
'fontSize',
'textColor',
'backgroundMode',
'gradientStart',
'gradientEnd',
'gradientAngle',
'solidColor',
'palette',
'paint',
] as constThe 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:
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:
npm test -- tests/unit/card-query.test.ts tests/unit/card-state.test.tsExpected: 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:
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():
- select the known template or
signal-defaultfallback; - replace both content fields as today;
- filter shown/hidden IDs through
template.customization.visibility; - call
setVisibility,patchTypography,setBackgroundMode, andpatchBackgroundso invalid fonts, colors, and numeric values are rejected or clamped by one normalization path; - leave
imagesempty and icon overrides empty.
Do not special-case signal-minimal or signal-photo.
- [ ] Step 5: Run query and state tests
Run:
npm test -- tests/unit/card-query.test.ts tests/unit/card-state.test.tsExpected: PASS.
- [ ] Step 6: Commit query v2
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.tsModify:
docs/.vitepress/i18n/en.mjsModify:
docs/.vitepress/i18n/zh.mjsModify:
docs/.vitepress/i18n/ar.mjsModify:
docs/.vitepress/i18n/ru.mjsModify:
docs/.vitepress/i18n/ja.mjsModify:
docs/.vitepress/i18n/es.mjsModify:
docs/.vitepress/i18n/fr.mjsModify:
docs/.vitepress/i18n/de.mjsModify:
docs/.vitepress/i18n/pt.mjs[ ] Step 1: Change the required-key and template-ID tests first
Set:
const presetIds = ['signal-default', 'signal-dual'] as constRemove 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:
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 constRemove 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:
npm test -- tests/unit/card-i18n.test.tsExpected: 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:
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:
'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:
'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:
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 constSet showSource and both template metadata entries to these exact values:
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 constKeep 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:
npm test -- tests/unit/card-i18n.test.tsExpected: PASS.
- [ ] Step 6: Commit localization
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.tsModify:
docs/.vitepress/theme/components/card/CardSettings.vueCreate:
docs/.vitepress/theme/components/card/CardBackgroundSettings.vueCreate:
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:
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:
npm test -- tests/components/card-settings.test.tsExpected: 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:
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-anglerange and number; - Solid:
data-solid-color; - Image:
data-image-slot="background", filename/removal,data-background-x,data-background-y, anddata-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:
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.vueas the composition shell
Use these events:
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:
npm test -- tests/components/card-settings.test.tsExpected: PASS.
- [ ] Step 7: Commit the Style editor
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.tsModify:
tests/components/card-generator.test.tsModify:
tests/unit/card-i18n.test.tsCreate:
docs/.vitepress/theme/components/card/CardResetDialog.vueModify:
docs/.vitepress/theme/components/card/CardGenerator.vueDelete:
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=falsedoes not callshowModal();open=truerenders localized title, description, confirm, and cancel controls and callsshowModal()once;Confirm emits
confirmonce;Cancel, backdrop click, native
cancel, and Escape emitcancelwithout confirm;close restores
returnFocusonly when it remains connected;a
showModal()failure emitsopenFailedand 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:
- expects
[data-reset-card]disabled initially; - changes source visibility, text, background, and both uploads;
- expects Reset enabled;
- opens the dialog and cancels, preserving state;
- opens again and confirms;
- expects theme defaults, no uploaded filenames, no image nodes, disabled Reset, and unchanged text/Morse content;
- resolves a stale pre-reset image promise and proves it cannot repopulate state;
- 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:
npm test -- tests/components/card-reset-dialog.test.ts tests/components/card-generator.test.ts tests/unit/card-i18n.test.tsExpected: FAIL because Reset/dialog wiring and SEO removal are missing.
- [ ] Step 4: Implement
CardResetDialog.vue
Use this contract:
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():
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>): voidOn 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:
<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:
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.tsExpected: PASS.
- [ ] Step 9: Commit orchestration and SEO removal
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:
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:
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:
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:
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:
npx playwright test tests/e2e/card.spec.tsExpected: 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:
npm testExpected: PASS.
- [ ] Step 7: Build all VitePress routes
Run:
npm run docs:buildExpected: 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:
git diff --check
git status --shortExpected: no whitespace errors; only the files named in this plan are modified.
- [ ] Step 9: Commit end-to-end coverage
git add tests/e2e/card.spec.ts
git commit -m "test: verify card style adjustment workflow"