Enkidu Coding Style Guide
This document outlines the coding standards and conventions for the Enkidu project. Adhering to these guidelines ensures code consistency, maintainability, and readability across the frontend and backend.
1. General Guidelines
- Git Flow: Use descriptive branch names (e.g.,
feature/user-auth,bugfix/login-error). - Commits: Write clear, concise commit messages. Start with a verb (e.g., "Add user profile component", "Fix navigation bug").
- Code Reviews: All changes must be reviewed via Pull Request before merging to the main branch.
2. Frontend Guidelines (Vue.js)
Tech Stack
- Framework: Vue 3 (Composition API)
- UI Framework: Ionic 8
- State Management: Pinia
- Templating: Pug (
<template lang='pug'>) - Styling: Stylus (
<style lang='styl'>) - Testing: Vitest (Unit), Cypress (E2E)
File Structure & Naming
- Components: Use PascalCase for filenames (e.g.,
UserProfile.vue,GoalCard.vue). - Directories: Use kebab-case for directories unless they strictly contain components (e.g.,
src/pages,src/components). - Component Names: Multi-word names are mandatory to prevent conflicts with HTML elements (e.g.,
TodoIteminstead ofTodo).
Component Syntax
- Use
<script setup>for concise component logic. - define props and emits using
definePropsanddefineEmits. - Use
defineOptions({ name: 'ComponentName' })to explicitly name components for debugging.
<template lang='pug'>
ion-page
h1 {{ title }}
</template>
<script setup>
import { ref } from 'vue'
defineOptions({
name: 'ExamplePage'
})
const props = defineProps({
title: String
})
</script>
<style lang='styl'>
.container
padding 20px
</style>Pinia Stores (new as of OBJ-32)
Stores live in ionic_frontend/src/stores/ and use Pinia's Composition API style (defineStore('storeName', () => { ... })).
Use Pinia stores for:
- Cross-component reactive state that needs persistence (SecureStorage, localStorage)
- Complex shared state logic (e.g., sync queues, cache management)
- Example:
syncStore.ts(offline mutation queue for check-ins)
Use composables for:
- Per-component or per-feature local state with no persistence
- Reusable logic that doesn't need cross-component sharing
- Example:
useGoalForm.ts,useNetworkStatus.ts
Pinia store structure:
import { defineStore } from 'pinia'
export const useSyncStore = defineStore('sync', () => {
const queue = ref<PendingMutation[]>([])
const isSyncing = ref(false)
async function processQueue(): Promise<void> {
// Queue processing logic
}
return { queue, isSyncing, processQueue }
})Mutation re-entry guards
Any composable or component handler that dispatches a GraphQL mutation needs a synchronous early-return guard before the dispatch, not a reactive :disabled binding alone — a same-tick double dispatch (raw double-click, double-tap) fires before Vue's reactivity ever re-renders the disabled state. See Mutation Re-Entry Guard § The pattern for the idiom, why :disabled isn't sufficient on its own, and the shared-in-flight-flag cross-blocking trap to avoid when an action shares a composable with others.
Linting
- Ensure all code passes
eslintwith the standard config. - Run
npm run lintto fix automatic style issues. vue/no-undef-componentsruns at error level — a template referencing a component that was never imported now fails lint immediately instead of silently rendering nothing. Add a component to the rule'signorePatternsonly if it's registered globally by a plugin (e.g.router-link), never to silence a genuinely missing import. See gotchas.md for the two blind spots (<component :is>,lang="pug"templates) this rule can't see.
Third-Party Widget Theming
When integrating third-party embedded widgets (Clerk, Stripe Elements, etc.), default to the vendor's stock theme; override only what's verified wrong or unreachable, and pin any override with a test.
- Vendor widgets have stable, tested styling for light/dark modes and accessibility — start there.
- Vendor element class names are unstable across releases; hand-styling them creates maintenance risk. Prefer high-level configuration (e.g.
variables.colorPrimary) over targeting internal elements. - "Prefer stock" is not "assume correct, don't verify." OBJ-1396 shipped an inverted
colorNeutralvalue that blanked several form rows in both light and dark mode, precisely because the value went unverified and untested. If a vendor default (or an override of it) is genuinely wrong or unreachable through the theming API, override it explicitly, pin the value for every mode, and add a regression test.
Example: Clerk's appearance prop is used for both variables and targeted element overrides in ionic_frontend/src/composables/useClerkAppearance.ts, plus CSS overrides in ionic_frontend/src/styles/clerk.css for markup the API can't reach. See Authentication § Clerk Appearance & Theming for the current contract and the colorNeutral gotcha.
Theming, Coach identity & network-error conventions
Conventions established during the OBJ-1168 design-debt audit, Package A (theming/Coach-identity/mutation-error) and Package B (missing/wrong-state & silent-failure sweep):
- Dark mode toggles via the
html.dark/html.lightclass, neverprefers-color-schemein component code.useTheme.tsapplies the class todocument.documentElementand Tailwind'sdark:variant follows that class strategy. A component readingprefers-color-schemedirectly (in CSS or JS) ignores the user's in-app theme toggle and drifts out of sync with it — always read the resolved theme fromuseTheme()instead. - Coach-branded surfaces use the
coachtoken, neverprimary.primaryis the app's action color and stays on app-chrome (navigation, "Edit"/"Change persona" links, text inputs, wizard Next/Skip).coachis reserved for the Coach's own identity — the emphasis word in a Coach heading, and the selection/focus ring on a Coach-choice card.coachon background contrasts at 3.95:1 — it passes WCAG AA for large/bold text and non-text rings, but fails the 4.5:1 normal-text floor, so never applytext-coachto body, caption, or eyebrow text. - Every network-backed action needs a visible error state and a retry, not just a Sentry breadcrumb. A caught mutation error must never let the UI advance or render as if the save succeeded — surface a
role="alert"banner (text-foregroundonbg-destructive/10,border-destructive/40—text-muted-foregroundfails AA on that tint) with a way to retry the same action. - A network-backed list needs a distinct error state, separate from its empty state — a failed fetch must never render as "nothing here yet." They read as opposite messages to the user (a transient problem vs. a stable fact about their data) and need different
roles: the error block isrole="alert"/aria-live="assertive"with a "Try again" retry; the empty block isrole="status"/aria-live="polite"with no retry (there's nothing to retry). SeeFeedback.vue's board/roadmap lists andwelcome/slides/GoalTypeSlide.vue's category list for the two-branch pattern, modeled on the originalGoals.vue/ActivityFeed.vuereference implementations. If the query is a lazy/paginated one (useLazyQueryor similar) rather than a plainuseQuery, retrying it isn't a simple refetch — Apollo'sload()returnsfalseon the 2nd+ call since the observable is already active.composables/useAllySearch.ts'sretry()is the reference pattern: re-run throughrefetch()instead, guarding againstundefined(apolloRefetch()?.catch(...)) since Apollo'srefetchcan returnundefined. - One canonical focus-ring token, everywhere:
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2. A few older surfaces (e.g.ActivityFeed.vue) ring onring-ringinstead — that's a pre-existing inconsistency, not an alternate pattern; new and touched code should always usering-primary. - Interactive controls need a ≥44px touch target, even when their visual padding is smaller — apply Tailwind's
min-h-11(ormin-h-[44px]where an arbitrary value is clearer) rather than shrinking the target to fit dense layouts like tab bars or chip rows.
See .planning/phases/obj-1168-hallmark-package-a/UI-SPEC.md for the Package A design contract and .planning/phases/obj-1175-states-a11y/UI-SPEC.md for the Package B design contract these rules were extracted from.
Color tokens, type ramp, shadow token & honest-UI conventions
Conventions established during the OBJ-1168 design-debt audit, Package C (token hygiene + honest-UI/voice sweep):
- Never reach for an inline hex,
rgba(...), or a raw Tailwind palette class (slate-200,amber-500, etc.) in app UI — use a token.design_system/css/tokens.cssdefines the tokens as HSL custom properties (light block, then a.darkoverride block);design_system/tailwind-tokens.cjswires them into Tailwind classes. A raw palette class or literal color renders identically in one theme and wrong (or invisible) in the other — the token pair is how a color gets both a light and a dark value from one class name. - Goal life-area badges use the
cat-*token family (bg-cat-health,text-cat-wealth,bg-cat-career/10, …) — ten tokens (family,health,relationship,socializing,spirituality,other,career,lifevision,personal,wealth), each independently tuned per-theme intokens.cssto clear ≥3:1 icon-vs-badge contrast. Don't reuse the communitycategory-*tokens for life areas — those are tuned for white-text fills, not icon/badge contrast, and swapping them back in reintroduces the contrast failure Package C fixed (seewelcome/slides/LifeAreaSlide.vue). - A data-driven per-entity accent (e.g. each Enneagram type's identity color) is the sanctioned exception —
EnneagramTypeDetail.vue's per-type hex values encode type identity, not UI semantics, and intentionally stay outside the token system rather than collapsing nine distinct types onto one flat color. - Avatar/image fallbacks render locally — never bake theme colors into a fetched placeholder image.
PublicGoal.vue's no-photo state renders a localbg-muted text-muted-foregroundinitials<div>, not aui-avatars.comURL with baked-in background/text colors (a remote image can't follow the viewer's light/dark toggle). text-2xs(11px) is the floor — nothing below it. Added intailwind-tokens.cjs'sfontSizescale for the eyebrow/label/metadata micro-text pattern (rank badges, streak counters, timestamps) that used to drop to arbitrarytext-[8.5px]–text-[10px]values. Every text size should resolve to a step on the documented ramp (text-2xsthrough Tailwind's default scale) — an arbitrarytext-[Npx]in new or touched code is a sign a ramp step is missing, not license to reach belowtext-2xs. Spacing/radii should likewise land on the 4/8px grid rather than an arbitraryh-[7px]/border-[2.5px]value.- One shared shadow token for card elevation:
shadow-card/shadow-card-hover(backed by--shadow-card/--shadow-card-hoverintokens.css, themed separately for light/dark). Use these instead of copy-pasting ashadow-[0_2px_8px_-2px_rgba(...)]literal — the onboarding slides (GoalNameSlide,GoalMotivationSnapshotSlide,LifeAreaSlide) were the original copy-paste source and now consume the token. - No dead controls, no fake affordances, no empty state that just repeats itself. Three concrete rules from the Package C honest-UI sweep:
- A control that's permanently disabled and does nothing (a "Share (coming soon)" button with no ship date) should be removed, not shipped disabled — see
ChallengeLeaderboard.vue, which dropped the whole deadion-buttonsblock rather than leaving a button users can see but never use. - A decoration that implies a working capability the UI doesn't actually have (a mic icon styled to look active on a slide with no real audio input) should be removed — see
welcome/slides/GoalWhySlide.vue, which dropped thebg-success-tintedMicchip. - An empty state's heading and subtitle must say two different things, not the same fact twice — see
Communities.vue's three empty states (search / category / cold-start), each with a distinct heading + subtitle pair, andChallengeLeaderboard.vue's empty state, which replaced a lone 🏆 emoji with a designedTrophy-in-a-circle treatment and real copy.
- A control that's permanently disabled and does nothing (a "Share (coming soon)" button with no ship date) should be removed, not shipped disabled — see
See .planning/phases/obj-1177-package-c1-token-map/UI-SPEC.md for the Package C design contract (token map, ramp mapping, shadow token, and the full honest-UI triage table) these rules were extracted from.
3. Backend Guidelines (Ruby on Rails)
Tech Stack
- Framework: Ruby on Rails (API Mode)
- Language: Ruby 3.x
- Testing: RSpec
- API: GraphQL (
graphql-ruby)
Code Style (Rubocop)
The project enforces specific Rubocop rules. Key configurations include:
- Line Length: Max 140 characters.
- Method Length: Max 65 lines.
- Complexity: AbcSize max 80, Cyclomatic Complexity max 25.
- Frozen String Literals: Enabled (
# frozen_string_literal: trueat top of files).
Naming Conventions
- Classes/Modules: PascalCase (e.g.,
User,AuthenticationService). - Variables/Methods: snake_case (e.g.,
user_params,def calculate_total). - Database Columns: snake_case.
- Files: snake_case (e.g.,
user_profile.rb).
Project Structure
- Service Objects/Interactions: Use interactions for business logic to keep controllers/resolvers skinny.
- GraphQL:
- Types live in
app/graphql/types. - Mutations live in
app/graphql/mutations. - Resolvers live in
app/graphql/resolvers.
- Types live in
Testing (RSpec)
- Write unit tests for models, services, and GraphQL types.
- Ensure high test coverage for critical paths.
- Run
bundle exec rspecbefore pushing changes.
# frozen_string_literal: true
module Mutations
class CreateGoal < BaseMutation
argument :title, String, required: true
field :goal, Types::GoalType, null: true
def resolve(title:)
# implementation
end
end
endLast updated: 2026-08-18 (OBJ-2676: added § Mutation re-entry guards pointer)