Skip to content

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., TodoItem instead of Todo).

Component Syntax

  • Use <script setup> for concise component logic.
  • define props and emits using defineProps and defineEmits.
  • Use defineOptions({ name: 'ComponentName' }) to explicitly name components for debugging.
vue
<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:

typescript
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 eslint with the standard config.
  • Run npm run lint to fix automatic style issues.
  • vue/no-undef-components runs 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's ignorePatterns only 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 colorNeutral value 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.light class, never prefers-color-scheme in component code. useTheme.ts applies the class to document.documentElement and Tailwind's dark: variant follows that class strategy. A component reading prefers-color-scheme directly (in CSS or JS) ignores the user's in-app theme toggle and drifts out of sync with it — always read the resolved theme from useTheme() instead.
  • Coach-branded surfaces use the coach token, never primary. primary is the app's action color and stays on app-chrome (navigation, "Edit"/"Change persona" links, text inputs, wizard Next/Skip). coach is reserved for the Coach's own identity — the emphasis word in a Coach heading, and the selection/focus ring on a Coach-choice card. coach on 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 apply text-coach to 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-foreground on bg-destructive/10, border-destructive/40text-muted-foreground fails 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 is role="alert"/aria-live="assertive" with a "Try again" retry; the empty block is role="status"/aria-live="polite" with no retry (there's nothing to retry). See Feedback.vue's board/roadmap lists and welcome/slides/GoalTypeSlide.vue's category list for the two-branch pattern, modeled on the original Goals.vue/ActivityFeed.vue reference implementations. If the query is a lazy/paginated one (useLazyQuery or similar) rather than a plain useQuery, retrying it isn't a simple refetch — Apollo's load() returns false on the 2nd+ call since the observable is already active. composables/useAllySearch.ts's retry() is the reference pattern: re-run through refetch() instead, guarding against undefined (apolloRefetch()?.catch(...)) since Apollo's refetch can return undefined.
  • 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 on ring-ring instead — that's a pre-existing inconsistency, not an alternate pattern; new and touched code should always use ring-primary.
  • Interactive controls need a ≥44px touch target, even when their visual padding is smaller — apply Tailwind's min-h-11 (or min-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.css defines the tokens as HSL custom properties (light block, then a .dark override block); design_system/tailwind-tokens.cjs wires 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 in tokens.css to clear ≥3:1 icon-vs-badge contrast. Don't reuse the community category-* 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 (see welcome/slides/LifeAreaSlide.vue).
  • A data-driven per-entity accent (e.g. each Enneagram type's identity color) is the sanctioned exceptionEnneagramTypeDetail.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 local bg-muted text-muted-foreground initials <div>, not a ui-avatars.com URL 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 in tailwind-tokens.cjs's fontSize scale for the eyebrow/label/metadata micro-text pattern (rank badges, streak counters, timestamps) that used to drop to arbitrary text-[8.5px]text-[10px] values. Every text size should resolve to a step on the documented ramp (text-2xs through Tailwind's default scale) — an arbitrary text-[Npx] in new or touched code is a sign a ramp step is missing, not license to reach below text-2xs. Spacing/radii should likewise land on the 4/8px grid rather than an arbitrary h-[7px]/border-[2.5px] value.
  • One shared shadow token for card elevation: shadow-card / shadow-card-hover (backed by --shadow-card / --shadow-card-hover in tokens.css, themed separately for light/dark). Use these instead of copy-pasting a shadow-[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 dead ion-buttons block 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 the bg-success-tinted Mic chip.
    • 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, and ChallengeLeaderboard.vue's empty state, which replaced a lone 🏆 emoji with a designed Trophy-in-a-circle treatment and real copy.

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: true at 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.

Testing (RSpec)

  • Write unit tests for models, services, and GraphQL types.
  • Ensure high test coverage for critical paths.
  • Run bundle exec rspec before pushing changes.
ruby
# 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
end

Last updated: 2026-08-18 (OBJ-2676: added § Mutation re-entry guards pointer)

Loading…