Skip to content

Frontend Conventions

Vue Component Structure

Every component uses two <script> blocks:

vue
<template>
  <!-- Template -->
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import type { ButtonVariant } from './types/button.types';

withDefaults(
  defineProps<{
    label?: string;
    variant?: ButtonVariant;
  }>(),
  {
    label: undefined,
    variant: 'primary',
  },
);

const emit = defineEmits<{
  change: [value: string];
  close: [];
}>();
</script>

<script lang="ts">
export default {
  name: 'gis-component-name',
};
</script>

Rules:

  • Always use <script setup lang="ts"> for logic
  • Always add a separate <script lang="ts"> block with name: 'gis-<name>'
  • Use withDefaults(defineProps<{...}>(), {...}) for typed props with defaults
  • Set optional string/object props to undefined (not empty string)
  • Use import type for type-only imports
  • Use v-bind="$attrs" on the root element when wrapping native elements

Pages

Pages are the orchestration layer — they import composables and wire data to child components:

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAccidentMarkers } from '../composables/useAccidentMarkers';
import AccidentFilters from '../components/filters/accident-filters.vue';
import GisDrawer from '@uikit/drawer/drawer.vue';
</script>

<script lang="ts">
export default {
  name: 'accidents-page',
};
</script>

Rules:

  • Pages are the only components that import composables
  • Pages wire composable data to child components via props
  • Always lazy-load pages in the router
  • Feature code goes in src/modules/<name>/ — never in src/shared/

Components

Child components receive data via props and emit events:

vue
<script setup lang="ts">
import type { Accident } from '../types/accident.types';

defineProps<{
  accident: Accident;
}>();

const emit = defineEmits<{
  close: [];
}>();
</script>

Rules:

  • Components receive data via props, emit events — no data fetching inside components
  • Feature-specific components go in modules/<name>/components/
  • Shared/reusable components live in the shared/ package (shared/src/uikit/)
  • Import UIKit components via the alias: import GisButton from '@uikit/button/button.vue'
  • GisIcon is the only globally registered component — no import needed

Composables

Composables encapsulate reactive logic and API interactions:

typescript
// modules/<name>/composables/use<Name>.ts
import { ref } from 'vue';

export function useAccidentMarkers(getMap: () => mapboxgl.Map) {
  const selectedAccident = ref<Accident | null>(null);
  const detailError = ref<string | null>(null);

  async function loadMarkers(filters?: AccidentFilterValues) {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    // ...
  }

  return { selectedAccident, detailError, loadMarkers };
}

Rules:

  • Name with use prefix: use<Name>.ts
  • Export a named function (not default export)
  • Accept dependencies as parameters (dependency injection pattern)
  • Return reactive state (ref, computed) and functions as an object
  • Error messages shown to users are in Slovenian

TypeScript Types

Types are defined per module using const arrays and derived union types:

typescript
// modules/<name>/types/<name>.types.ts
export const accidentClassifications = [
  'S smrtnim izidom',
  'S hudo telesno poškodbo',
  'Z lažjo telesno poškodbo',
  'Brez poškodb',
] as const;

export type AccidentClassification = (typeof accidentClassifications)[number];

export interface Accident {
  id: string;
  latitude: number;
  longitude: number;
  date: string;
  participants: AccidentParticipant[];
}

export interface AccidentFilterValues {
  accidentreason?: string;
  accidenttype?: string;
  startDate?: string;
  endDate?: string;
}

Import Order

Follow this order, separated by blank lines:

typescript
// 1. Vue core
import { ref, computed, onMounted, watch } from 'vue';

// 2. External libraries
import { useQuery } from '@tanstack/vue-query';

// 3. Same-module components (relative paths)
import AccidentDetail from '../components/detail/accident-detail.vue';

// 4. Same-module composables/utils/types (relative paths)
import { useAccidentMarkers } from '../composables/useAccidentMarkers';
import type { AccidentFilterValues } from '../types/accident.types';

// 5. Shared UIKit components (@uikit alias)
import GisDrawer from '@uikit/drawer/drawer.vue';
import GisSpinner from '@uikit/spinner/spinner.vue';

// 6. Shared stores (absolute @/ paths)
import { useAppStore } from '@/shared/stores/app';

Rules:

  • Use @/ alias for anything in local src/
  • Use @uikit/ alias for shared UIKit components
  • Use @theme/ alias for shared styles
  • Use relative paths for same-module imports
  • Use import type for type-only imports
  • Never use CommonJS (require) in frontend code

API Calls

Use native fetch() — no axios or wrapper library:

typescript
const params = new URLSearchParams();
if (filters?.startDate) params.set('startDate', filters.startDate);
const qs = params.toString();
const url = qs ? `/api/accidents?${qs}` : '/api/accidents';

const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();

Rules:

  • Use URLSearchParams for query string building
  • Check response.ok before parsing JSON
  • Prefix all endpoints with /api/ (Vite proxies to backend)
  • API functions live in modules/<name>/api/<name>.api.ts
  • For cached server state, wrap in TanStack Query composables
  • Error handling: log to console.error, set error state in refs

Styling

Tailwind CSS 4

Tailwind is configured via PostCSS (@tailwindcss/postcss). There is no tailwind.config.ts.

All design tokens and the Tailwind/UIKit imports live in a single file at shared/src/assets/styles/main.css:

css
@import 'tailwindcss';
@import '../../uikit/index.scss';

@theme {
  --color-primary-600: #337418;
  --color-secondary-600: #48b821;
  --radius: 12px;
  --radius-lg: 16px;
  --shadow-card: 0px 3px 3px -2px rgba(...);
}

:root {
  --gis-font-sans: 'Onest', sans-serif;
  --gis-border-color: #e5e5e5;
}

Rules:

  • In CSS: use var(--color-primary-600)not theme('colors.primary.600')
  • In templates: use Tailwind classes directly (bg-primary-600, text-neutral-700)
  • Use outline-hidden instead of outline-none (TW4 breaking change)
  • @use rules must come before @import 'tailwindcss'
  • Custom CSS variables use --gis-* prefix in :root block
  • Theme tokens use --color-*, --radius-*, --shadow-* in the @theme block

Component Styles

UIKit components use SCSS with BEM naming:

scss
.gis-button {
  @apply inline-flex items-center justify-center;
  border-radius: 12px;

  &--primary {
    @apply bg-primary-600 text-white;
    &:hover:not(:disabled) {
      @apply bg-primary-700;
    }
  }

  &--small {
    @apply text-xs h-8;
  }

  &:disabled {
    @apply opacity-50 cursor-not-allowed;
  }

  &__icon {
    @apply text-current;
  }
}

BEM pattern: .gis-<name>, .gis-<name>--<modifier>, .gis-<name>__<element>

Key Design Tokens

TokenValueUsage
Primary#337418Main brand green
Secondary#48b821Bright accent green
Border radius12px / 16pxDefault / cards
FontOnest, sans-serifAll text
Sidebar width270px / 87pxExpanded / collapsed
Header height70pxSticky top header

Pinia Stores

Use the composition API style:

typescript
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useAppStore = defineStore('app', () => {
  const sidebarOpen = ref(true);

  function toggleSidebar() {
    sidebarOpen.value = !sidebarOpen.value;
  }

  return { sidebarOpen, toggleSidebar };
});

Code Style

  • Prettier: Single quotes, trailing commas, semicolons, 100 char width (120 for .vue)
  • ESLint: Flat config (9.x), TypeScript + Vue + Prettier plugins
  • no-console: Only console.warn and console.error allowed (except main.ts)
  • Icons: Remix Icons via ri-* CSS classes (remixicon package)