Architecture

Frontend Architecture

Deep dive into DevStride's frontend architecture, patterns, and conventions.

Frontend Architecture

The DevStride frontend is built with Vue 3, using the Composition API, Pinia for state management, and Quasar as the component framework.

Technology Stack

TechnologyPurpose
Vue.js 3UI framework with Composition API
TypeScriptType-safe development (strict mode)
PiniaState management
QuasarComponent framework
Tailwind CSSUtility-first styling
Vue RouterNavigation
TipTapRich text editing
PusherReal-time updates

Project Structure

frontend/src/
├── api/                  # Generated API client
│   ├── sdk.gen.ts        # API methods
│   └── types.gen.ts      # API types
│
├── components/           # Reusable components
│   ├── core/             # Core UI components
│   ├── item/             # Item-related components
│   ├── board/            # Board-related components
│   └── ...
│
├── composables/          # Composition functions
│   ├── useItemPresence.ts
│   ├── useDescriptionDraft.ts
│   └── ...
│
├── models/               # TypeScript interfaces
│
├── pages/                # Route pages
│   ├── boards/
│   ├── items/
│   └── settings/
│
├── store/                # Pinia stores
│   ├── authStore.ts
│   ├── organizationStore.ts
│   └── ...
│
├── styles/               # Global styles
│   └── app.scss
│
├── utils/                # Utility functions
│
└── App.vue               # Root component

Core Patterns

Composition API

All components use Vue 3 Composition API with <script setup>:

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useOrganizationStore } from '@/store/organizationStore';

// Props
const props = defineProps<{
  itemId: string;
  readonly?: boolean;
}>();

// Emits
const emit = defineEmits<{
  (e: 'update', value: string): void;
  (e: 'close'): void;
}>();

// Store
const orgStore = useOrganizationStore();

// Reactive state
const isLoading = ref(false);
const title = ref('');

// Computed
const isValid = computed(() => title.value.length > 0);

// Methods
async function save() {
  isLoading.value = true;
  try {
    await api.updateItem(props.itemId, { title: title.value });
    emit('update', title.value);
  } finally {
    isLoading.value = false;
  }
}

// Lifecycle
onMounted(() => {
  // Initialize
});
</script>

Pinia State Management

Stores manage application state:

// store/organizationStore.ts
import { defineStore } from 'pinia';
import { OrganizationService } from '@/api';

export const useOrganizationStore = defineStore('organization', {
  state: () => ({
    activeOrganization: null as Organization | null,
    organizations: [] as Organization[],
    isLoading: false,
  }),

  getters: {
    organizationId: (state) => state.activeOrganization?.id ?? '',

    hasMultipleOrgs: (state) => state.organizations.length > 1,
  },

  actions: {
    async fetchOrganizations() {
      this.isLoading = true;
      try {
        const response = await OrganizationService.getOrganizations();
        this.organizations = response.data;
      } finally {
        this.isLoading = false;
      }
    },

    setActiveOrganization(org: Organization) {
      this.activeOrganization = org;
    },
  },
});

Prefer Pinia over deep prop drilling - If data needs to pass through many component layers, use a store instead.

Composables

Reusable composition functions for shared logic:

// composables/useItemPresence.ts
import { ref, computed, onUnmounted } from 'vue';

export function useItemPresence(
  itemNumber: () => string | undefined,
  currentUserId: () => string
) {
  const members = ref<Map<string, PresenceMember>>(new Map());
  const isSubscribed = ref(false);

  const otherMembers = computed(() => {
    return Array.from(members.value.values())
      .filter(m => m.userId !== currentUserId());
  });

  function subscribe() {
    // Subscribe to presence channel
  }

  function unsubscribe() {
    // Cleanup
  }

  onUnmounted(() => {
    unsubscribe();
  });

  return {
    members,
    otherMembers,
    isSubscribed,
    subscribe,
    unsubscribe,
  };
}

Component Naming

Components follow multi-word naming convention:

✅ Good:
- ItemDrawer.vue
- BoardSelector.vue
- WorkItemCard.vue

❌ Bad:
- Drawer.vue
- Selector.vue
- Card.vue

Styling

Tailwind CSS with Semantic Colors

DevStride uses a custom theme with semantic color names. Never use numbered Tailwind colors:

<!-- ❌ WRONG - numbered colors -->
<div class="tw-text-gray-500 tw-bg-blue-600">

<!-- ✅ CORRECT - semantic colors -->
<div class="tw-text-gray-medium tw-bg-blue">

Complete Theme Color Reference

Colors are defined in frontend/src/styles/tailwind.css as CSS variables and available as Tailwind classes.

Grayscale Colors

ColorClass ExamplesUsage
whitetw-bg-white, tw-text-whitePure white backgrounds, text on dark
blacktw-bg-black, tw-text-blackPure black
gray-lighttw-bg-gray-light, tw-text-gray-lightLight backgrounds, muted text
gray-medium-lighttw-border-gray-medium-lightBorders, dividers
gray-mediumtw-text-gray-mediumSecondary text, icons
gray-medium-darktw-border-gray-medium-darkDark mode borders
gray-darktw-bg-gray-dark, tw-text-gray-darkPrimary text, dark backgrounds

Named Colors

ColorClass ExamplesUsage
redtw-bg-red, tw-text-redErrors, danger, destructive actions
pinktw-bg-pink, tw-text-pinkAccents
orangetw-bg-orange, tw-text-orangeWarnings, attention
yellowtw-bg-yellow, tw-text-yellowWarnings, highlights
limetw-bg-lime, tw-text-limeAccents
greentw-bg-green, tw-text-greenSuccess, positive states
tealtw-bg-teal, tw-text-tealAccents
bluetw-bg-blue, tw-text-bluePrimary actions, links
purpletw-bg-purple, tw-text-purpleAccents

Semantic Aliases

AliasMaps ToUsage
primarybluePrimary brand actions
successgreenSuccess states
dangerredError/danger states
warningyellowWarning states

Using Colors in JavaScript

For charts, third-party libraries, or dynamic styles:

import { useThemedColors } from '@/utils/color';

const { appColors } = useThemedColors();

// Access colors - automatically updates when theme changes
const blueColor = appColors.value.blue;       // "rgb(0, 153, 255)"
const grayDark = appColors.value['gray-dark']; // "rgb(90, 90, 90)"

Focus States

Form inputs and buttons have specific focus ring requirements:

<!-- Form inputs: border color change only, NO outline -->
<q-input class="focus-within:tw-border-blue" />

<!-- Buttons: 1px outline with 1px offset -->
<q-btn class="focus:tw-outline focus:tw-outline-1 focus:tw-outline-offset-1" />

Component Patterns

DevStride has a standardized modal system. Choose the right pattern:

TypeUse CaseHas API Calls?
Async Form DialogCRUD operationsYes
SelectorSelect from recordsUsually
ChooserConfigure valuesNo
ControlFilter bar slide-insNo
Simple DialogConfirmation/infoNo

Focus Management

Modals must return focus to the trigger element when closed. This is critical for keyboard accessibility.

Using useChooserModal

For choosers, use the useChooserModal composable which handles focus restoration automatically:

const { modalRef, showPopup, closePopup, handleShow, handleHide } =
  useChooserModal({ emit });

Manual Focus Restoration

For custom dialogs that don't use useChooserModal:

import { returnFocusToTrigger } from '@/utils/returnFocusToTrigger';

function onHide() {
  returnFocusToTrigger(triggerElement);
}

Focus Recovery After Data Reload

When an action triggers a data reload (e.g., saving in a modal), focus recovery must wait for the reload to complete:

const pendingFocusEntryId = ref<string | null>(null);

async function handleSave(entryId: string) {
  pendingFocusEntryId.value = entryId;  // Store BEFORE save
  await saveEntry(entryId, data);
}

watch(() => entries.value.length, () => {
  if (pendingFocusEntryId.value) {
    nextTick(() => {
      focusRowById(pendingFocusEntryId.value);
      pendingFocusEntryId.value = null;
    });
  }
});

Items Table Keyboard Navigation

The ItemsTable system provides Excel-like keyboard navigation with focus persistence.

Architecture

Navigation is coordinated across three levels:

  1. RichTable - Handles navigation within a single table
  2. NavigationCoordinator - Coordinates between parent and subtask tables
  3. useTableFocusRecovery - Persists and recovers focus position

List items and table cells require these attributes:

<div
  data-navigable="true"
  tabindex="0"
  role="option"
  :aria-selected="isSelected"
>
  <!-- Item content -->
</div>

<!-- For hierarchical items (folders) - prevents auto-save on Enter -->
<div data-navigable="true" data-hierarchical="true">

<!-- For disabled items -->
<div data-navigable="true" data-disabled="true">

Focus Isolation Guards

Parent tables must not steal focus from subtask tables. Three guards are required:

function handleDocumentKeydown(event: KeyboardEvent) {
  if (drawerOpen.value) return;                                    // Guard 1
  if (!tableEl.value?.rootEl?.contains(focusedElement)) return;    // Guard 2
  if (focusedElement?.closest('[data-table-type="subtask"]')) return; // Guard 3
}

SubItemsTable sets data-table-type="subtask" attribute for the third guard.

Event Naming Conventions

Use standard event names for consistency across components.

Standard Events

EventUsage
update:modelValuev-model binding
saveForm submission
cancelClose without saving
show / hideModal visibility changes
item-selectedSingle selection mode
item-toggledMulti selection mode
selectedGeneric selection complete
changeValue changed

Naming Rules

// ❌ WRONG - Custom/inconsistent names
emit('user-selected', user);
emit('work-type-clicked', item);
emit('onSave', data);

// ✅ CORRECT - Standard names
emit('item-selected', item);   // Single mode
emit('item-toggled', item);    // Multi mode
emit('save', data);
emit('update:modelValue', value);

Event Typing

Always type your emits:

const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void;
  (e: 'save', data: FormData): void;
  (e: 'cancel'): void;
  (e: 'item-selected', item: Item): void;
}>();

Loading States

DevStride supports two loading patterns:

InnerLoader (Spinner)

Use for overlays or when content structure is unknown:

<template>
  <div class="tw-relative">
    <inner-loader :visible="isLoading" size="30px" />
    <div v-if="!isLoading">
      <!-- Content -->
    </div>
  </div>
</template>

<script setup lang="ts">
import InnerLoader from '@/components/core/InnerLoader.vue';
</script>

Skeletons

Use for placeholder content that mimics the final layout:

<template>
  <div v-if="isLoading">
    <q-skeleton type="text" width="200px" />
    <q-skeleton type="rect" height="100px" class="tw-mt-2" />
  </div>
  <div v-else>
    <!-- Actual content -->
  </div>
</template>

Never use tw-animate-spin - Use InnerLoader or skeletons instead.

Real-Time Updates

Pusher Integration

Subscribe to real-time events:

import { ChannelEvents } from '@/store/events';

// Listen for events
ChannelEvents.on('item-updated', (event) => {
  console.log('Item updated:', event.data);
});

// Clean up
onUnmounted(() => {
  ChannelEvents.off('item-updated', handler);
});

Presence Channels

Track who's viewing/editing:

import { useItemPresence } from '@/composables/useItemPresence';

const presence = useItemPresence(
  () => item.value?.number,
  () => authStore.user?.id ?? ''
);

// Subscribe when component mounts
onMounted(() => presence.subscribe());

// Check who else is present
const othersViewing = presence.otherMembers;

Router

Route Structure

// router/index.ts
const routes = [
  {
    path: '/boards',
    component: () => import('@/pages/boards/BoardsPage.vue'),
    meta: { requiresAuth: true },
  },
  {
    path: '/boards/:boardId',
    component: () => import('@/pages/boards/BoardDetailPage.vue'),
    props: true,
  },
];
router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore();

  if (to.meta.requiresAuth && !authStore.isAuthenticated) {
    return next('/login');
  }

  next();
});

Code Style

TypeScript

  • Never use any - Use proper types or unknown
  • Define prop types - Use defineProps<{ ... }>()
  • Define emit types - Use defineEmits<{ ... }>()

Vue

  • Use <script setup> - Not Options API
  • Multi-word component names - ItemDrawer, not Drawer
  • Prefer Pinia over prop drilling

CSS

  • Use semantic Tailwind colors - Not numbered variants
  • No !important - Fix specificity properly

Testing

Frontend tests use Vitest:

import { mount } from '@vue/test-utils';
import ItemCard from '@/components/item/ItemCard.vue';

describe('ItemCard', () => {
  it('renders item title', () => {
    const wrapper = mount(ItemCard, {
      props: {
        item: { number: 'ITEM-1', title: 'Test Item' },
      },
    });

    expect(wrapper.text()).toContain('Test Item');
  });
});

Running Tests

cd frontend
pnpm test

Build & Development

# Start development server
./ds run ui

# Build for production
cd frontend && pnpm build:ui

# Type check
cd frontend && pnpm check:ts

# Lint
cd frontend && pnpm lint