Appearance
Appearance
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.
This document outlines the enforceable programming conventions for the Funkwhale frontend. See the Architecture guide for the reasoning behind these rules.
Rule: Always order Single File Components like this:
script setup (imports, defineProps, defineModel),templatestyle moduleThe 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.
<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>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.
Rule: Use <style module> and explicitly bind classes using $style.
Modules guarantee strict locality and zero CSS leakage. Read more about Locality in our Architecture guide.
<template>
<div :class="$style.section">
...
</div>
</template>
<style module>
.section {
background: var(--color-background);
}
</style>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.
<template>
<Button :aria-busy="albumData.status === 'loading'">
Save
</Button>
</template>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 featuresRule: 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.
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,
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.
<nav
:class="[
props.class,
isBlock && 'layout-row',
]"
ui-block-size=48
>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:
Only for indices and "piped" variables, use single letters where it avoids duplication.:
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
Rule: Use the same name for the same thing.
<component
:is
:on-click
:aria-busy
>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).
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.
const getAvatarUrl = (hash: string) => `https://node/avatar/${hash}.png`
const startPlayback = () => {
audioEngine.play()
isPlaying.value = true
}Rule: Use string literal unions (type Status = 'loading' | 'success') instead of enum.
Unions remove an unnecessary layer of abstraction.
Rule: When composable and functions have several optional parameters, combine them into a strictly typed Config parameter.
Config 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 config object and returns a type automatically derived from the type of allowed values.
export function useUrlParamStore<
const T extends string,
const C extends {
allowedValues?: readonly T[] | readonly [undefined, ...T[]] | 'array' | 'singleton' | 'nonnegativeInteger'
}
>(
name: string,
config: C = {} as C
): Ref<InferDerivedType<C>> {
// implementation
}const scope = useUrlParamStore('scope', {
allowedValues: [undefined, 'subscribed', ...tabs.map(t => t.name)] as const
})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.