Skip to content

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 pattern

Raw 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 call

When 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.data

Early 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 = 80

Single-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.ts companion files (e.g., Blocks/data/types.ts)
  • Never define types inside .vue files -- 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 function to 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:

  1. Review the diff -- does the code read well, follow conventions, and avoid unnecessary complexity?
  2. Open and save changed files to trigger lint
  3. 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)
  4. Explicitly confirm that everything looks good

Internal documentation