Appearance
Coding Conventions -- Detailed Reference
Terse rules auto-loaded from .claude/rules/coding-conventions.md. This file has detailed examples and rationale.
i18n: All user-visible strings via $t()
Every string a user can see must use an i18n key. This includes templates, JS fallbacks, and dynamically built menus.
vue
<!-- BAD -->
<span>Add target</span>
<!-- GOOD -->
<span>{{ $t('visuals.animation.addTarget') }}</span>js
// BAD -- hard-coded fallback in script
if (!blockName) return 'No target'
// GOOD
if (!blockName) return this.$t('visuals.animation.noTarget')js
// BAD -- hard-coded label in dynamic menu
{ label: 'No animation', value: '' }
// GOOD
{ label: this.$t('visuals.animation.noAnimation'), value: '' }Exception: CSS technical terms (Ease, Ease In, Linear) are universal -- no i18n needed.
Key location: src/assets/i18n/en.js -- nest under relevant section (e.g., visuals.animation.*).
inputLocked: Propagate to all mutating children
When a parent receives inputLocked (e.g., child creative without overrides), every child that allows mutation must be disabled or hidden.
vue
<!-- BAD -- EffectList can still be edited when locked -->
<AnimationEffectList :effects="config.effects" />
<!-- GOOD -->
<AnimationEffectList :effects="config.effects" :disabled="inputLocked" />vue
<!-- BAD -- paste button visible when locked -->
<TooltipWrapper v-if="canPaste">
<!-- GOOD -->
<TooltipWrapper v-if="canPaste && !inputLocked">Read-only actions (copy, preview) don't need gating.
InputSelect for dropdowns
Always use InputSelect for dropdowns in builder configuration panels. Never native <select>.
vue
<!-- BAD -->
<select v-model="alignment">
<option value="left">Left</option>
<option value="center">Center</option>
</select>
<!-- GOOD -->
<InputSelect
:items="alignmentOptions"
:value="alignment"
@change="updateAlignment"
/>InputSelect provides consistent styling, theming support, and keyboard navigation.
sectionSettings registration
Every new Section component using sectionLogic must have an entry in utils.ts. Without it, getSectionSettings() will crash at runtime.
ts
// In Configuration/components/utils.ts
export const sectionSettings = {
// ...existing entries
MyNewSection: {
overridePaths: ['style.myProperty'],
},
}OptionList: Use full-width for overflow
When an OptionList has more than 3-4 items or items with long labels, add the full-width prop to prevent horizontal overflow.
vue
<!-- BAD -- 6 items will overflow the default width -->
<OptionList :items="animationPresets" :value="selected" />
<!-- GOOD -->
<OptionList :items="animationPresets" :value="selected" full-width />Component reuse: Extend before you create
The codebase has a rich library of reusable components. Always search for existing solutions before building new UI.
Prefer extending existing components
vue
<!-- BAD -- new DropdownMenu component that duplicates InputSelect behavior -->
<DropdownMenu :items="presets" @select="onSelect" />
<!-- GOOD -- reuse existing InputSelect with a prop tweak -->
<InputSelect :items="presets" :value="selected" @change="onSelect" />If an existing component almost does what you need, add a prop or slot to it rather than creating a parallel component.
Don't use raw DOM when Vue components exist
js
// BAD -- building a dropdown with document.createElement in a Vue component
const menu = document.createElement('div')
items.forEach(item => {
const btn = document.createElement('button')
btn.textContent = item.label
menu.appendChild(btn)
})
document.body.appendChild(menu)
// GOOD -- use an existing component or create a minimal Vue one
// Floating menus: use Vuetify's v-menu, or the existing preset-menu patternRaw DOM creation inside Vue components breaks reactivity, doesn't respect scoped styles, can leak event listeners, and is harder to maintain.
When to extract a new component
Extract when the same pattern appears in 2+ places. Don't pre-extract "just in case."
1 usage -> inline it
2 usages -> extract a component
3 usages -> the component was the right callWhen extracting, prefer making it generic enough for its known use cases, but no more. Don't add configurability for hypothetical future needs.
Data-driven templates: v-for over repeated sibling elements
When 3+ sibling elements share the same structure (buttons, list items, toolbar actions), define them as a data or computed array and render with v-for.
vue
<!-- BAD -- four near-identical buttons -->
<button :class="{ active: editor.isActive('italic') }" class="btn italic"
@mousedown.prevent="editor.chain().focus().toggleItalic().run()">I</button>
<button :class="{ active: editor.isActive('underline') }" class="btn"
@mousedown.prevent="editor.chain().focus().toggleUnderline().run()">U</button>
<button :class="{ active: editor.isActive('strike') }" class="btn strike"
@mousedown.prevent="editor.chain().focus().toggleStrike().run()">S</button>
<button :class="{ active: isUppercase }" class="btn uppercase"
@mousedown.prevent="toggleUppercase">TT</button>
<!-- GOOD -- data-driven with v-for -->
<button
v-for="btn in formattingButtons"
:key="btn.label"
:class="[{ active: btn.active }, 'btn', btn.cssClass]"
@mousedown.prevent="btn.action"
>
{{ btn.label }}
</button>ts
// In computed:
formattingButtons() {
return [
{ label: 'I', cssClass: 'italic', active: this.editor.isActive('italic'),
action: () => this.editor.chain().focus().toggleItalic().run() },
{ label: 'U', cssClass: '', active: this.editor.isActive('underline'),
action: () => this.editor.chain().focus().toggleUnderline().run() },
// ...
]
}Keep individual elements only when they are structurally unique (e.g., a color picker with a slot, a dropdown with a sub-menu). See AddBlockTool.vue and RichTextEditor.vue for real examples.
Arrow functions vs function declarations
Prefer arrow functions for standalone/utility functions. function declarations are fine when you need hoisting (mutual recursion, call-before-definition). Vue component methods use object method shorthand naturally.
ts
// GOOD -- standalone utility
const isVideo = (p) => p.type === 'video'
// GOOD -- needs hoisting (mutual recursion)
function parseNode(node) {
node.children.forEach(parseChild)
}
function parseChild(child) {
if (child.nested) parseNode(child)
}
// GOOD -- Vue component method (object shorthand)
methods: {
handleClick() {
this.$emit('click')
},
}Code style: Breathing room and early returns
Breathing room
Write code with blank lines between logical blocks. Never collapse if/guard clauses onto one line.
ts
// BAD
if (!value) return
const result = compute()
if (result.error) return handleError(result)
return result.data
// GOOD
if (!value) {
return
}
const result = compute()
if (result.error) {
return handleError(result)
}
return result.dataEarly returns over deep nesting
Handle edge cases and bail out at the top of a function.
ts
// BAD -- deeply nested
const processBlock = (block) => {
if (block) {
if (block.visible) {
if (block.type === 'text') {
return formatText(block)
}
}
}
return null
}
// GOOD -- flat with early returns
const processBlock = (block) => {
if (!block || !block.visible) {
return null
}
if (block.type !== 'text') {
return null
}
return formatText(block)
}Control flow: switch and while
switch over if-chains
When multiple branches check the same variable, use switch. It signals "one value, many outcomes" and encourages extracting logic per case.
ts
// BAD -- if-chain checking the same variable
if (block.type === 'text') {
return renderText(block)
} else if (block.type === 'button') {
return renderButton(block)
} else if (block.type === 'graphic') {
return renderGraphic(block)
} else {
return renderDefault(block)
}
// GOOD
switch (block.type) {
case 'text':
return renderText(block)
case 'button':
return renderButton(block)
case 'graphic':
return renderGraphic(block)
default:
return renderDefault(block)
}while/do-while for traversal
Use while when the number of iterations is unknown (flow walking, linked lists, tree traversal). It expresses "repeat until done" more clearly than a for loop with break conditions.
ts
// BAD -- for loop with awkward break
for (let i = 0; ; i++) {
const node = getNext(current)
if (!node) break
current = node
}
// GOOD
while (current) {
current = getNext(current)
}Constants: Extract repeated hardcoded values
Extract hardcoded values (URLs, regex patterns, magic numbers) to named constants when they appear in 2+ files.
ts
// BAD -- magic number in multiple files
if (tagline.offsetWidth > 80) { ... }
// ...in another file:
const maxIconSize = Math.min(parsedSize, 80)
// GOOD -- named constant
const MAX_ICON_SIZE = 80Single-file magic numbers are fine if their meaning is obvious from context. The rule targets cross-file duplication.
Section comments for long files
Use section comments (// -- Section Name --) to divide longer files into navigable chunks.
ts
// -- Computed --
const blockCount = computed(() => blocks.value.length)
// -- Methods --
const addBlock = (type: string) => { ... }
// -- Watchers --
watch(selectedBlock, (block) => { ... })This is especially useful in large Vue components where the Options API groups by option type, and in utility files with many exports.
JSDoc: Explain why, not what
Add JSDoc on non-obvious functions where it aids understanding. Skip it on self-evident code.
ts
// BAD -- restates the code
/** Returns true if the block is a video block */
const isVideo = (block) => block.type === 'video'
// GOOD -- explains a non-obvious decision
/**
* Strips bare <span> wrappers that Tiptap inserts when removing
* the last text-style attribute. Without this, the HTML accumulates
* empty spans on every edit cycle.
*/
const stripEmptySpans = (html: string): string =>
html.replace(/<span\s*>(.*?)<\/span>/g, '$1')Include type info when the signature alone is ambiguous, and examples when the behavior is non-trivial.
TypeScript conventions
type over interface
Use type instead of interface. They're more versatile (unions, intersections, mapped types) and consistent across the codebase.
ts
// BAD
interface BlockConfig {
name: string
visible: boolean
}
// GOOD
type BlockConfig = {
name: string
visible: boolean
}Type locations
- Shared types go in
src/interfaces/for global use - Domain-specific types go in colocated
types.tscompanion files (e.g.,Blocks/data/types.ts) - Never define types inside
.vuefiles -- extract them
Type judiciously
Add types where they clarify. Skip them where the code is self-evident.
ts
// BAD -- type adds nothing, the filter + boolean return is obvious
const isVideo = (p: BlockProperties): boolean => p.type === 'video'
// GOOD -- no annotation needed
const isVideo = (p) => p.type === 'video'
// GOOD -- return type clarifies what a complex function produces
const buildParamValues = (params, saved): Record<string, unknown> => { ... }Utils purity
Keep src/utils/ pure. No Vue reactivity (ref, computed, watch), no side effects, no store access. Utils should be plain functions that take inputs and return outputs.
ts
// BAD -- reactive import in utils
import { ref } from 'vue'
import store from '@/store'
export const getActiveBlock = () => store.state.blocks.selectedBlock
// GOOD -- pure function, caller provides data
export const getBlockPath = (blockName: string, parentPath?: string): string => {
if (!parentPath) {
return blockName
}
return `${parentPath}.${blockName}`
}If you need reactivity, put it in a composable (src/composables/) or a Vuex module.
Naming: Readability over brevity
Avoid abbreviations when a descriptive name is just as short.
ts
// BAD
const dict = {}
const val = getVal()
const cb = () => {}
const idx = items.findIndex(...)
// GOOD
const blockNameTypes = {}
const value = getValue()
const onChange = () => {}
const index = items.findIndex(...)Well-known abbreviations are fine: el (element), i/j (loop counters), fn (when the variable literally holds "a function"), e (event in handlers).
Refactor as you go
When touching existing code for a feature or bugfix, clean up what you touch:
- Expand one-liner guards to multi-line with braces
- Add breathing room (blank lines between logical blocks)
- Convert
functionto arrow where appropriate - Look at related code nearby for similar improvements
Don't refactor unrelated code in the same PR, but leave the code you change cleaner than you found it.
ts
// Before (touching this file for a bug fix):
if (!block) return
const name = block.name
if (name === 'text') return handleText(block)
// After (cleaned up while fixing the bug):
if (!block) {
return
}
const { name } = block
if (name === 'text') {
return handleText(block)
}Before committing
Never commit or push until the user has completed all of the following:
- Review the diff -- does the code read well, follow conventions, and avoid unnecessary complexity?
- Open and save changed files to trigger lint
- Test locally -- check for console errors, verify in builder, flow, delivery standalone preview, and confirm it renders correctly on external sites (e.g. test-a-tag)
- Explicitly confirm that everything looks good