Appearance
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 withname: 'gis-<name>' - Use
withDefaults(defineProps<{...}>(), {...})for typed props with defaults - Set optional string/object props to
undefined(not empty string) - Use
import typefor 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 insrc/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' GisIconis 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
useprefix: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 localsrc/ - Use
@uikit/alias for shared UIKit components - Use
@theme/alias for shared styles - Use relative paths for same-module imports
- Use
import typefor 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
URLSearchParamsfor query string building - Check
response.okbefore 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)— nottheme('colors.primary.600') - In templates: use Tailwind classes directly (
bg-primary-600,text-neutral-700) - Use
outline-hiddeninstead ofoutline-none(TW4 breaking change) @userules must come before@import 'tailwindcss'- Custom CSS variables use
--gis-*prefix in:rootblock - Theme tokens use
--color-*,--radius-*,--shadow-*in the@themeblock
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
| Token | Value | Usage |
|---|---|---|
| Primary | #337418 | Main brand green |
| Secondary | #48b821 | Bright accent green |
| Border radius | 12px / 16px | Default / cards |
| Font | Onest, sans-serif | All text |
| Sidebar width | 270px / 87px | Expanded / collapsed |
| Header height | 70px | Sticky 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: Onlyconsole.warnandconsole.errorallowed (exceptmain.ts)- Icons: Remix Icons via
ri-*CSS classes (remixiconpackage)