The DevStride frontend is built with Vue 3, using the Composition API, Pinia for state management, and Quasar as the component framework.
| Technology | Purpose |
|---|---|
| Vue.js 3 | UI framework with Composition API |
| TypeScript | Type-safe development (strict mode) |
| Pinia | State management |
| Quasar | Component framework |
| Tailwind CSS | Utility-first styling |
| Vue Router | Navigation |
| TipTap | Rich text editing |
| Pusher | Real-time updates |
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
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>
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.
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,
};
}
Components follow multi-word naming convention:
✅ Good:
- ItemDrawer.vue
- BoardSelector.vue
- WorkItemCard.vue
❌ Bad:
- Drawer.vue
- Selector.vue
- Card.vue
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">
Colors are defined in frontend/src/styles/tailwind.css as CSS variables and available as Tailwind classes.
| Color | Class Examples | Usage |
|---|---|---|
white | tw-bg-white, tw-text-white | Pure white backgrounds, text on dark |
black | tw-bg-black, tw-text-black | Pure black |
gray-light | tw-bg-gray-light, tw-text-gray-light | Light backgrounds, muted text |
gray-medium-light | tw-border-gray-medium-light | Borders, dividers |
gray-medium | tw-text-gray-medium | Secondary text, icons |
gray-medium-dark | tw-border-gray-medium-dark | Dark mode borders |
gray-dark | tw-bg-gray-dark, tw-text-gray-dark | Primary text, dark backgrounds |
| Color | Class Examples | Usage |
|---|---|---|
red | tw-bg-red, tw-text-red | Errors, danger, destructive actions |
pink | tw-bg-pink, tw-text-pink | Accents |
orange | tw-bg-orange, tw-text-orange | Warnings, attention |
yellow | tw-bg-yellow, tw-text-yellow | Warnings, highlights |
lime | tw-bg-lime, tw-text-lime | Accents |
green | tw-bg-green, tw-text-green | Success, positive states |
teal | tw-bg-teal, tw-text-teal | Accents |
blue | tw-bg-blue, tw-text-blue | Primary actions, links |
purple | tw-bg-purple, tw-text-purple | Accents |
| Alias | Maps To | Usage |
|---|---|---|
primary | blue | Primary brand actions |
success | green | Success states |
danger | red | Error/danger states |
warning | yellow | Warning states |
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)"
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" />
DevStride has a standardized modal system. Choose the right pattern:
| Type | Use Case | Has API Calls? |
|---|---|---|
| Async Form Dialog | CRUD operations | Yes |
| Selector | Select from records | Usually |
| Chooser | Configure values | No |
| Control | Filter bar slide-ins | No |
| Simple Dialog | Confirmation/info | No |
Modals must return focus to the trigger element when closed. This is critical for keyboard accessibility.
For choosers, use the useChooserModal composable which handles focus restoration automatically:
const { modalRef, showPopup, closePopup, handleShow, handleHide } =
useChooserModal({ emit });
For custom dialogs that don't use useChooserModal:
import { returnFocusToTrigger } from '@/utils/returnFocusToTrigger';
function onHide() {
returnFocusToTrigger(triggerElement);
}
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;
});
}
});
The ItemsTable system provides Excel-like keyboard navigation with focus persistence.
Navigation is coordinated across three levels:
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">
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.
Use standard event names for consistency across components.
| Event | Usage |
|---|---|
update:modelValue | v-model binding |
save | Form submission |
cancel | Close without saving |
show / hide | Modal visibility changes |
item-selected | Single selection mode |
item-toggled | Multi selection mode |
selected | Generic selection complete |
change | Value changed |
// ❌ 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);
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;
}>();
DevStride supports two loading patterns:
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>
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.
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);
});
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/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();
});
any - Use proper types or unknowndefineProps<{ ... }>()defineEmits<{ ... }>()<script setup> - Not Options APIItemDrawer, not Drawer!important - Fix specificity properlyFrontend 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');
});
});
cd frontend
pnpm test
# 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