refactor: add design system with CSS tokens, utilities, and app shell

- Add design tokens (colors, spacing, radius, shadows, typography, transitions)
- Add global reset, body/link/focus/typography base styles
- Add utility classes (container, surface-card, btn variants, field, badge, message, divider)
- Replace header ✉ symbol with inline SVG envelope icon
- Update favicon to license-plate shaped mark with blue gradient and bold B
- Rename brand from BilHälsning to Bilhej in header, footer, and HTML title
- Rewrite footer tagline: focus on service, not privacy
- Add theme-color meta tag for browser chrome
This commit is contained in:
Joakim Mörling 2026-05-16 16:09:35 +02:00
parent 8cd7991603
commit 00327674ed
9 changed files with 653 additions and 68 deletions

View file

@ -2,9 +2,14 @@
<html lang="sv">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BilHälsning</title>
<meta name="description" content="Skicka ett brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, så postar vi det." />
<meta name="theme-color" content="#1d4ed8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Bilhej — Skicka brev till fordonsägare</title>
</head>
<body>
<div id="app"></div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 990 B

View file

@ -14,6 +14,6 @@ import AppFooter from '@/components/AppFooter.vue'
<style>
.app__main {
min-height: calc(100vh - 12rem);
min-height: calc(100vh - 10rem);
}
</style>

View file

@ -29,6 +29,6 @@ describe('App', () => {
plugins: [router, createPinia()],
},
})
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')
expect(wrapper.text()).toContain('Skicka ett brev')
})
})

View file

@ -25,6 +25,11 @@ function createTestRouter() {
name: 'orders',
component: { template: '<div>Orders</div>' },
},
{
path: '/admin',
name: 'admin',
component: { template: '<div>Admin</div>' },
},
],
})
}
@ -47,7 +52,7 @@ describe('AppHeader', () => {
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
expect(wrapper.text()).toContain('BilHälsning')
expect(wrapper.text()).toContain('Bilhej')
})
it('has a link to home', () => {
@ -113,38 +118,39 @@ describe('AppHeader', () => {
})
describe('when authenticated', () => {
function mountAuthenticated() {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
function mountAuthenticated(role = 'user') {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role })
localStorage.setItem('auth_token', jwt)
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
return mount(AppHeader, {
const wrapper = mount(AppHeader, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
it('shows user email', () => {
const wrapper = mountAuthenticated()
const { wrapper } = mountAuthenticated()
expect(wrapper.text()).toContain('test@bilhalsning.se')
})
it('shows logout button', () => {
const wrapper = mountAuthenticated()
const { wrapper } = mountAuthenticated()
const logoutButton = wrapper.find('button')
expect(logoutButton.exists()).toBe(true)
expect(logoutButton.text()).toBe('Logga ut')
})
it('does not show login link', () => {
const wrapper = mountAuthenticated()
const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a')
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
expect(loginLink).toBeUndefined()
})
it('does not show register link', () => {
const wrapper = mountAuthenticated()
const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a')
const registerLink = links.find(
(a) => a.attributes('href') === '/registrera',
@ -153,21 +159,47 @@ describe('AppHeader', () => {
})
it('shows orders link', () => {
const wrapper = mountAuthenticated()
const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a')
const ordersLink = links.find((a) => a.attributes('href') === '/orders')
expect(ordersLink).toBeTruthy()
expect(ordersLink?.text()).toBe('Mina beställningar')
})
it('calls logout when clicking logout button', async () => {
const wrapper = mountAuthenticated()
it('does not show admin link for regular user', () => {
const { wrapper } = mountAuthenticated('user')
const links = wrapper.findAll('a')
const adminLink = links.find((a) => a.attributes('href') === '/admin')
expect(adminLink).toBeUndefined()
})
it('shows admin link for admin user', () => {
const { wrapper } = mountAuthenticated('admin')
const links = wrapper.findAll('a')
const adminLink = links.find((a) => a.attributes('href') === '/admin')
expect(adminLink).toBeTruthy()
expect(adminLink?.text()).toBe('Admin')
})
it('calls logout and redirects to home when clicking logout button', async () => {
const { wrapper, router } = mountAuthenticated()
const auth = useAuthStore()
expect(auth.isAuthenticated).toBe(true)
await router.push('/orders')
await router.isReady()
const navigationDone = new Promise<void>((resolve) => {
const remove = router.afterEach(() => {
remove()
resolve()
})
})
await wrapper.find('button').trigger('click')
await navigationDone
expect(auth.isAuthenticated).toBe(false)
expect(router.currentRoute.value.path).toBe('/')
})
})
})

View file

@ -0,0 +1,409 @@
/* ── Reset ────────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
img,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
/* ── Design Tokens ───────────────────────────────────────────────────── */
:root {
/* ink / text */
--color-ink: #111827;
--color-muted: #667085;
--color-soft: #9ca3af;
/* surfaces */
--color-paper: #fdfaf5;
--color-surface: #ffffff;
--color-surface-tint: #f5f0ff;
/* brand */
--color-primary: #1d4ed8;
--color-primary-dark: #1e3a8a;
--color-primary-soft: #dbeafe;
--color-primary-ring: rgba(29, 78, 216, 0.22);
/* accent */
--color-accent: #0f766e;
--color-accent-soft: #ccfbf1;
/* semantic */
--color-success: #15803d;
--color-success-soft: #f0fdf4;
--color-warning: #b45309;
--color-warning-soft: #fffbeb;
--color-danger: #b91c1c;
--color-danger-soft: #fef2f2;
/* borders & dividers */
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
/* spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07),
0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.08),
0 8px 10px -6px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.12);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.04);
/* typography */
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
}
/* ── Body ────────────────────────────────────────────────────────────── */
body {
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.6;
color: var(--color-ink);
background: var(--color-paper);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Typography ──────────────────────────────────────────────────────── */
h1,
h2,
h3,
h4 {
line-height: 1.25;
font-weight: 700;
color: var(--color-ink);
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.375rem;
}
h3 {
font-size: 1.125rem;
}
h4 {
font-size: 1rem;
}
p {
color: var(--color-muted);
}
/* ── Links ───────────────────────────────────────────────────────────── */
a[href] {
color: var(--color-primary);
transition: color var(--transition-fast);
}
a[href]:hover {
color: var(--color-primary-dark);
}
/* ── Buttons as links ────────────────────────────────────────────────── */
.btn[href],
.btn[href]:hover {
color: inherit;
}
.btn--primary[href],
.btn--primary[href]:hover {
color: #fff;
}
.btn--success[href],
.btn--success[href]:hover {
color: #fff;
}
.btn--accent[href],
.btn--accent[href]:hover {
color: #fff;
}
/* ── Focus ───────────────────────────────────────────────────────────── */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ── Container ───────────────────────────────────────────────────────── */
.container {
width: 100%;
max-width: 72rem;
margin-inline: auto;
padding-inline: var(--space-lg);
}
.container--narrow {
max-width: 36rem;
}
.container--wide {
max-width: 80rem;
}
/* ── Surface card ────────────────────────────────────────────────────── */
.surface-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
box-shadow: var(--shadow-card);
}
/* ── buttons ─────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 600;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast),
transform var(--transition-fast), box-shadow var(--transition-fast);
white-space: nowrap;
}
.btn:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn--primary {
background: var(--color-primary);
color: #fff;
}
.btn--primary:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.btn--success {
background: var(--color-success);
color: #fff;
}
.btn--success:hover:not(:disabled) {
background: #166534;
}
.btn--accent {
background: var(--color-accent);
color: #fff;
}
.btn--accent:hover:not(:disabled) {
background: #0284c7;
}
.btn--ghost {
background: transparent;
color: var(--color-ink);
border: 1px solid var(--color-border);
}
.btn--ghost:hover:not(:disabled) {
background: var(--color-border-light);
}
.btn--sm {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
}
.btn--lg {
padding: 1rem 2rem;
font-size: 1.0625rem;
}
/* ── Form fields ─────────────────────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field__label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-ink);
}
.field__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.field__input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-ring);
}
.field__input--error {
border-color: var(--color-danger);
}
.field__input--error:focus {
box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.2);
}
.field__error {
font-size: 0.8125rem;
color: var(--color-danger);
}
.field__hint {
font-size: 0.8125rem;
color: var(--color-soft);
}
/* ── Badge ───────────────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: var(--radius-full);
white-space: nowrap;
}
.badge--muted {
background: var(--color-border-light);
color: var(--color-muted);
}
.badge--primary {
background: var(--color-primary-soft);
color: var(--color-primary-dark);
}
.badge--success {
background: var(--color-success-soft);
color: var(--color-success);
}
.badge--warning {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.badge--danger {
background: var(--color-danger-soft);
color: var(--color-danger);
}
/* ── Message boxes ───────────────────────────────────────────────────── */
.message {
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.message--error {
background: var(--color-danger-soft);
border: 1px solid #fecaca;
color: var(--color-danger);
}
.message--info {
background: var(--color-primary-soft);
border: 1px solid #ddd6fe;
color: var(--color-primary-dark);
}
.message--success {
background: var(--color-success-soft);
border: 1px solid #bbf7d0;
color: var(--color-success);
}
/* ── Divider ─────────────────────────────────────────────────────────── */
.divider {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-lg) 0;
}
/* ── Eyebrow ─────────────────────────────────────────────────────────── */
.eyebrow {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-primary);
}
/* ── Utility ─────────────────────────────────────────────────────────── */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.text-center {
text-align: center;
}
.text-muted {
color: var(--color-muted);
}
.text-sm {
font-size: 0.875rem;
}
.text-xs {
font-size: 0.75rem;
}

View file

@ -4,6 +4,11 @@ import { RouterLink } from 'vue-router'
<template>
<footer class="app-footer">
<div class="app-footer__inner">
<p class="app-footer__tagline">
Bilhej hjälper dig att skicka brev till bilägare via
registreringsnummer.
</p>
<nav class="app-footer__links">
<RouterLink to="/om" class="app-footer__link">Om oss</RouterLink>
<RouterLink to="/kontakt" class="app-footer__link">Kontakt</RouterLink>
@ -12,31 +17,54 @@ import { RouterLink } from 'vue-router'
>
<RouterLink to="/villkor" class="app-footer__link">Villkor</RouterLink>
</nav>
<p class="app-footer__copy">
&copy; {{ new Date().getFullYear() }} Bilhej
</p>
</div>
</footer>
</template>
<style scoped>
.app-footer {
background: #f7fafc;
border-top: 1px solid #e2e8f0;
padding: 1.5rem;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.app-footer__inner {
max-width: 72rem;
margin: 0 auto;
padding: var(--space-xl) var(--space-lg);
text-align: center;
}
.app-footer__tagline {
color: var(--color-muted);
font-size: 0.875rem;
margin: 0 0 var(--space-lg) 0;
}
.app-footer__links {
display: flex;
justify-content: center;
gap: 2rem;
gap: var(--space-xl);
flex-wrap: wrap;
margin-bottom: var(--space-lg);
}
.app-footer__link {
color: #718096;
color: var(--color-soft);
text-decoration: none;
font-size: 0.8125rem;
transition: color var(--transition-fast);
}
.app-footer__link:hover {
color: #1a202c;
color: var(--color-ink);
}
.app-footer__copy {
color: var(--color-soft);
font-size: 0.75rem;
margin: 0;
}
</style>

View file

@ -1,13 +1,46 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const auth = useAuthStore()
function handleLogout() {
auth.logout()
router.push('/')
}
</script>
<template>
<header class="app-header">
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
<div class="app-header__inner">
<RouterLink to="/" class="app-header__logo">
<svg
class="app-header__logo-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M2 7l10 6 10-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Bilhej
</RouterLink>
<nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
<template v-if="!auth.isAuthenticated">
@ -19,65 +52,119 @@ const auth = useAuthStore()
>
</template>
<template v-else>
<RouterLink
v-if="auth.isAdmin"
to="/admin"
class="app-header__link app-header__link--admin"
>Admin</RouterLink
>
<RouterLink to="/orders" class="app-header__link"
>Mina beställningar</RouterLink
>
<span class="app-header__email">{{ auth.email }}</span>
<button class="app-header__logout" @click="auth.logout()">
<button class="app-header__logout" @click="handleLogout">
Logga ut
</button>
</template>
</nav>
</div>
</header>
</template>
<style scoped>
.app-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(253, 250, 245, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
}
.app-header__inner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
background: #fff;
max-width: 72rem;
margin: 0 auto;
padding: 0.875rem var(--space-lg);
}
.app-header__logo {
font-size: 1.25rem;
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1.125rem;
font-weight: 700;
color: #1a202c;
color: var(--color-ink);
text-decoration: none;
}
.app-header__logo-icon {
width: 1.5rem;
height: 1.5rem;
}
.app-header__nav {
display: flex;
gap: 1rem;
align-items: center;
gap: var(--space-sm);
}
.app-header__link {
color: #4a5568;
text-decoration: none;
padding: 0.4rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-muted);
text-decoration: none;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.app-header__link:hover {
color: #1a202c;
.app-header__link:hover,
.app-header__link.router-link-active {
color: var(--color-primary-dark);
background: var(--color-primary-soft);
}
.app-header__link--admin {
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 600;
}
.app-header__link--admin:hover {
background: #e9d5ff;
color: var(--color-primary-dark);
}
.app-header__email {
color: #4a5568;
font-size: 0.875rem;
color: var(--color-muted);
font-size: 0.8125rem;
padding: 0 0.5rem;
}
.app-header__logout {
background: none;
border: none;
color: #4a5568;
font-size: 0.875rem;
border: 1px solid var(--color-border);
color: var(--color-muted);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
padding: 0;
padding: 0.35rem 0.875rem;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.app-header__logout:hover {
color: #1a202c;
color: var(--color-danger);
border-color: var(--color-danger);
background: var(--color-danger-soft);
}
</style>

View file

@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles/base.css'
const app = createApp(App)