Skip to content

Automation first

Rules defined in the funkwhale eslint config are not documented in this document. See Getting started for a guide on how to set up tooling locally.

Code style ​

This document outlines the enforceable programming conventions for the Funkwhale frontend. See the Architecture guide for the reasoning behind these rules.

Vue component structure ​

Component section order ​

Rule: Always order Single File Components like this:

  1. script setup (imports, defineProps, defineModel),
  2. template
  3. style module

The script defines the interface of the module. Especially with deep modules and narrow interfaces, knowing what data goes in and out is the key to understanding the functionality of a module within its feature.

vue
<script setup lang="ts">
// imports here
const {
  a = true, // defaults
  ...props
} = defineProps<{
  a?: boolean
}>()
const songs = defineModel<Song[]>()
</script>

<template>
  <span />
</template>

<style module>
</style>

Type prop declarations ​

Rule: Always use explicit type-based declarations (defineProps<{ ... }>()) rather than runtime declarations or any.

Strict boundaries at the component edge prevent bugs deep in the tree. See Parse, don't validate.

Prevent CSS leakage ​

Rule: Use inline styles for computed or one-off styles and simple affordances where possible. Use <style module> and explicitly bind classes using $style for repeated and semantically meaningful classes and selectors, and for repeated patterns. Prefer meaningful and accessible CSS selectors over scripted conditions.

Inline styles are easiest to maintain and most local. Modules guarantee strict locality and zero CSS leakage. Read more about Locality in our Architecture guide. Reach for modules when semantic classes and data- attributes help understand the meaning, when you want to reuse a style rule, and/or when you need a complex selector. Never use a script when CSS selectors would do the job, e.g. for :hover, :focus, disabled affordances or nested selectors - the least powerful tool is the most expressive. Where possible, encode variants using accessible attributes.

vue
<template>
  <Icon
    style="font-size: 2em;"
    icon="bi:gear"
  />
  <img
    :style="{
      aspectRatio: isSquare ? '1' : 'auto',
      maxWidth: '100%'
    }"
    src="..."
  >
  <div
    :class="$style.section"
    :data-selected="isSelected"
  >
    ...
  </div>
</template>

<style module>
.section {
    background: var(--color-background);
    &[data-selected] { ... }
}
</style>

State and reactivity ​

Limit watchers ​

Rule: Avoid watch and watchEffect when synchronizing internal component state. Use them exclusively for triggering side-effects with external sources (LocalStorage, WebAudio API, DOM elements).

Complex systems of watchers are hard to debug. Default early, derive late to avoid intermediate state and invisible dependencies.

vue
<template>
  <Button :aria-busy="albumData.status === 'loading'">
    Save
  </Button>
</template>

Keep templates idempotent ​

Rule: Use idempotent functions and composables in the template.

Idempotence means that whether you run it once or many times, the result is the same. If needed, use lodash memoize to make any function idempotent.

Vue's reactivity system often re-runs functions in template nodes. If that function produces new data every time it runs, it can lead to memory overflow.

Defer optional and low-priority operations ​

Rule: Calculate and fetch only essential data in the <script setup>. Defer calculation of optional or low-priority data by placing it into a <Sync> (reactive state) or a lazy function (run once).

This rule prevents unnecessary calculations and network requests.

  • If the UI is incomplete without it, compute it in the setup.

  • If a fragment of the UI is hidden by default, always use <Sync> and lazy (() => ...) functions

Features ​

Separate features ​

Rule: Never import from one feature into another.

We keep features independent from each other so that fixing one does not create new bugs in another.

Exceptions:

  • ~/pages can import from other features
  • ~/shared can import from other features

Only share features used in multiple other features ​

Rule: Place composables, helpers and components into ~/shared if they are consumed by at least two different features. If they are only consumed by modules in a single feature, they belong into that feature. If a module is only consumed by one other module, inline it.

Keeping ~/shared lean and flat helps reduce coupling in thecodebase long-term.

Avoid defensive programming ​

Rule: Remove all undesired codepaths.

Typecastingas any or non-null assertion variable! usually indicate that the shape of data isnot fully known. With a strict schema for API data and typed Web APIs, there is rarely need for these patterns.

If you find yourself programming for unneeded states,

Styling and design system ​

Use logical directions ​

Rule: Use logical directions (inline-start, inline-end, block-start, block-end) instead of physical directions (left, right, top, bottom) to support international writing systems

Learn more: Logical properties and values

Use design tokens where available ​

Rule: Instead of hardcoded color values (e.g., #FF0000) or absolute dimensions (14px), use context-aware CSS variables.

Hardcoded values can break themes, consistency and accessibility. See Theming for an overview and Design system for a detailed reference of available classes, attributes and properties.

template
  <nav
    :class="[
      props.class,
      isBlock && 'layout-row',
    ]"
    ui-block-size=48
  >

Naming ​

Use domain language and canonical names ​

Rule: For Funkwhale date, stick to vocabulary as defined by API schema and avoid aliases. Never abbreviate (idx -> index; sug -> suggestion ) and never append types to names (userArray -> users). Mirror semantic and syntactic function with grammar:

  • Type -> Noun
  • Collection -> Plural
  • Function -> Imperative verb
  • Builder function -> with*
  • Accessor -> get*
  • State discriminator -> Gerund
  • Event discriminator -> Perfect tense verb
  • Boolean -> Predicate

Only for indices and "piped" variables, use single letters where it avoids duplication.:

ts
const items = [{ type: 'a' }, { type: 'b' }] as const
return items
  .filter(i => i.type === 'a')
  .map(i => ({ ...i, type: i.type.toUpperCase() }))

Consistent naming and Reduces cognitive load. A developer should not have to memorize a frontend-specific alias for a backend concept. See Natural and canonical names

Reuse variables ​

Rule: Use the same name for the same thing.

  • Do not rename variables mid-script
  • Do not add redundant names when structuring and destructuring records, including component props and attributes
  • Use consistent names for items across modules
  • Where a name is set by an API, use that name.
template
<component
  :is
  :on-click
  :aria-busy
>

Inline documentation ​

Rule: Use // comments to explain why a specific, non-obvious technical decision was made in the body of the code. Use /** */ (JSDoc) exclusively above exports, interfaces, props and record fields to document the API.

JSDoc blocks are parsed by language servers (providing hover-text in code editors).

Function syntax ​

Rule: Use arrow functions without braces () => ... for pure functions/derivations. Use arrow functions with braces () => { ... } or the function keyword when the block has side effects.

Provides an immediate visual cue to the reader about whether a function will mutate state.

ts
const getAvatarUrl = (hash: string) => `https://node/avatar/${hash}.png`

const startPlayback = () => {
  audioEngine.play()
  isPlaying.value = true
}

Prefer string unions over enums ​

Rule: Use string literal unions (type Status = 'loading' | 'success') instead of enum.

Unions remove an unnecessary layer of abstraction.

Parameter order ​

Rule: When composable and functions have several optional parameters, combine them into a strictly typed options parameter.

Options objects are easier to parse than lists of values because of their shape and the named fields.

Example: The Url param composable ~/routing/urlParam accepts a name string and a options object and returns a type automatically derived from the type of allowed values.

ts
export function useUrlParamStore<
  const T extends string,
  const Options extends {
    allowedValues?: readonly T[] | readonly [undefined, ...T[]] | 'array' | 'singleton' | 'nonnegativeInteger'
  }
>(
  name: string,
  config: Options = {} as Options
): Ref<InferDerivedType<Options>> {
  // implementation
}
ts
const scope = useUrlParamStore('scope', {
  allowedValues: [undefined, 'subscribed', ...tabs.map(t => t.name)] as const
})

Architecture boundaries ​

Colocate Single-Use Utils ​

Rule: If a utility function, interface, or constant is only used by one component, place it inside that component's file or right next to it in its feature folder.

Locality over DRY. Prevents code rot, and makes deleting and refactoring features safe.