diff --git a/frontend/e2e/tests/header-nav-hover.spec.ts b/frontend/e2e/tests/header-nav-hover.spec.ts
index 1d53640..5fa7474 100644
--- a/frontend/e2e/tests/header-nav-hover.spec.ts
+++ b/frontend/e2e/tests/header-nav-hover.spec.ts
@@ -1,14 +1,14 @@
/**
* Regression for a specificity bug: `.header__nav-link:hover` (a class +
- * pseudo-class, specificity (0,2,0)) outranked `.header__nav-link--active`
- * (a single class, (0,1,0)), so hovering the *active* tab kept the active
- * rule's white (`--c-surface`) text but reverted the background to the
- * hover rule's `--c-bg` — near-white text on a near-white background.
+ * pseudo-class, specificity (0,2,0)) once outranked the single-class active
+ * rule, so hovering the *active* tab reverted its background to the ghost
+ * hover colour while keeping the active text colour — unreadable.
* `.header__nav-link[aria-current='page']:hover` now wins on specificity
- * (attribute selector + pseudo-class, (0,2,0)) over the plain hover rule,
- * and repaints the background with `--c-accent-hover` so the surface-coloured
- * text stays readable. The active state is marked by `aria-current="page"`
- * (set automatically by react-router-dom when the route matches). See `header.css`.
+ * (attribute selector + pseudo-class, (0,2,0)) over the plain hover rule and
+ * keeps the active link on its accent tint (`--c-accent-weak`) with the
+ * darker accent text (`--c-accent-hover`). The active state is marked by
+ * `aria-current="page"` (set automatically by react-router-dom when the
+ * route matches). See `header.css`.
*/
import { test, expect, type Page } from '@playwright/test'
@@ -37,12 +37,12 @@ test.describe('header nav — active tab stays readable on hover', () => {
const activeLink = page.getByTestId('header-nav').getByRole('link', { name: 'Översikt' })
await expect(activeLink).toHaveAttribute('aria-current', 'page')
+ const accentWeak = await resolveCssVar(page, '--c-accent-weak')
const accentHover = await resolveCssVar(page, '--c-accent-hover')
- const surface = await resolveCssVar(page, '--c-surface')
await activeLink.hover()
- await expect(activeLink).toHaveCSS('background-color', accentHover)
- await expect(activeLink).toHaveCSS('color', surface)
+ await expect(activeLink).toHaveCSS('background-color', accentWeak)
+ await expect(activeLink).toHaveCSS('color', accentHover)
})
})
diff --git a/frontend/index.html b/frontend/index.html
index a91de57..9f89a32 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,6 +3,13 @@
+
Föräldraledigplannerare
diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template
index 0901bed..f0cc69a 100644
--- a/frontend/nginx.conf.template
+++ b/frontend/nginx.conf.template
@@ -61,6 +61,14 @@ server {
try_files $uri =404;
}
+ # Self-hosted webfonts are versioned in their filename (…-v7.woff2), so
+ # a glyph update ships as a new file and the old one can be cached forever.
+ location /fonts/ {
+ access_log off;
+ add_header Cache-Control "public, max-age=31536000, immutable" always;
+ try_files $uri =404;
+ }
+
# index.html is the only mutable entry point — never cache it, or a deploy
# leaves clients pinned to a stale asset graph.
location = /index.html {
diff --git a/frontend/public/fonts/OFL.txt b/frontend/public/fonts/OFL.txt
new file mode 100644
index 0000000..ba41e11
--- /dev/null
+++ b/frontend/public/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/public/fonts/SchibstedGrotesk-latin-ext-v7.woff2 b/frontend/public/fonts/SchibstedGrotesk-latin-ext-v7.woff2
new file mode 100644
index 0000000..42fd93c
Binary files /dev/null and b/frontend/public/fonts/SchibstedGrotesk-latin-ext-v7.woff2 differ
diff --git a/frontend/public/fonts/SchibstedGrotesk-latin-v7.woff2 b/frontend/public/fonts/SchibstedGrotesk-latin-v7.woff2
new file mode 100644
index 0000000..dbca3fd
Binary files /dev/null and b/frontend/public/fonts/SchibstedGrotesk-latin-v7.woff2 differ
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e5141e7..f73f75d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,8 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from '@/context/AuthContext'
import { WorkspaceProvider } from '@/context/WorkspaceContext'
-import { LanguageProvider, useTranslation } from '@/i18n'
+import { LanguageProvider } from '@/i18n'
+import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { Auth } from '@/pages/Auth'
import { Calendar } from '@/pages/Calendar'
import { Dashboard } from '@/pages/Dashboard'
@@ -10,8 +11,7 @@ import { DevFetch } from '@/pages/DevFetch'
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
- const { t } = useTranslation()
- if (isLoading) return
{t('auth.loadingEllipsis')}
+ if (isLoading) return
if (!isAuthenticated) return
return <>{children}>
}
diff --git a/frontend/src/components/calendar/preset-rail.css b/frontend/src/components/calendar/preset-rail.css
index 25c420c..e7c40e9 100644
--- a/frontend/src/components/calendar/preset-rail.css
+++ b/frontend/src/components/calendar/preset-rail.css
@@ -3,8 +3,8 @@
flex-shrink: 0;
padding: var(--sp-4);
background-color: var(--c-surface);
- border-radius: var(--radius);
- box-shadow: var(--shadow-sm);
+ border: 1px solid var(--c-border);
+ border-radius: var(--radius-lg);
align-self: flex-start;
}
@@ -58,6 +58,6 @@
}
.preset-rail__button-detail {
- font-size: var(--fs-2xs);
+ font-size: var(--fs-xs);
color: var(--c-text-muted);
}
diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx
index c3a09c9..4dcd30b 100644
--- a/frontend/src/components/common/CalendarWeek.tsx
+++ b/frontend/src/components/common/CalendarWeek.tsx
@@ -1,6 +1,6 @@
import { ReactNode, useMemo } from 'react'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
-import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
+import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
import { useTranslation } from '@/i18n'
import { CalendarDay } from './CalendarDay'
@@ -395,10 +395,12 @@ export function CalendarWeek({
const representativeType = firstRealDay?.dayType ?? 'vacation'
const typeLabel = getLabelForType(representativeType, t)
const color = getColorForType(representativeType)
+ const ink = getInkForType(representativeType)
const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
const classNames = [
'calendar-band',
+ `calendar-band--${getTypeModifier(representativeType)}`,
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
@@ -427,6 +429,7 @@ export function CalendarWeek({
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
height: `${barHeight}px`,
backgroundColor: color,
+ color: ink,
}}
data-testid={`calendar-band-${segment.id}`}
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
diff --git a/frontend/src/components/common/Icon.test.tsx b/frontend/src/components/common/Icon.test.tsx
new file mode 100644
index 0000000..76e7757
--- /dev/null
+++ b/frontend/src/components/common/Icon.test.tsx
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest'
+import { render } from '@testing-library/react'
+import { Icon, ICON_NAMES } from './Icon'
+
+describe('Icon', () => {
+ it.each(ICON_NAMES)('renders "%s" as a hidden currentColor line glyph', (name) => {
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).not.toBeNull()
+ expect(svg).toHaveAttribute('aria-hidden', 'true')
+ expect(svg).toHaveAttribute('data-icon', name)
+ expect(svg).toHaveAttribute('stroke', 'currentColor')
+ expect(svg).toHaveAttribute('stroke-width', '1.5')
+ expect(svg).toHaveAttribute('fill', 'none')
+ expect(svg?.querySelectorAll('path').length).toBeGreaterThan(0)
+ })
+
+ it('covers every name in the IconName union exactly once', () => {
+ expect(ICON_NAMES).toHaveLength(12)
+ expect(new Set(ICON_NAMES).size).toBe(ICON_NAMES.length)
+ })
+
+ it('defaults to 16px and honours an explicit size', () => {
+ const { container, rerender } = render()
+ expect(container.querySelector('svg')).toHaveAttribute('width', '16')
+ expect(container.querySelector('svg')).toHaveAttribute('height', '16')
+ rerender()
+ expect(container.querySelector('svg')).toHaveAttribute('width', '24')
+ })
+
+ it('always carries the icon class and appends a custom one', () => {
+ const { container, rerender } = render()
+ expect(container.querySelector('svg')).toHaveClass('icon')
+ rerender()
+ expect(container.querySelector('svg')).toHaveClass('icon', 'btn__icon')
+ })
+})
diff --git a/frontend/src/components/common/Icon.tsx b/frontend/src/components/common/Icon.tsx
new file mode 100644
index 0000000..5343c83
--- /dev/null
+++ b/frontend/src/components/common/Icon.tsx
@@ -0,0 +1,82 @@
+import { ReactNode } from 'react'
+
+/**
+ * The app's icon set — a handful of 24-unit line glyphs, inlined so there is
+ * no icon font or sprite request. Every icon is a 1.5 stroke in
+ * `currentColor`, so it takes the colour of the text next to it, and is
+ * `aria-hidden`: the control that contains it carries the accessible name.
+ */
+export type IconName =
+ | 'chevron-left'
+ | 'chevron-right'
+ | 'close'
+ | 'pin'
+ | 'check'
+ | 'briefcase'
+ | 'ban'
+ | 'help'
+ | 'clock'
+ | 'plus'
+ | 'external'
+ | 'calendar'
+
+const CIRCLE = 'M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18z'
+
+const PATHS: Record = {
+ 'chevron-left': ['M15 18l-6-6 6-6'],
+ 'chevron-right': ['M9 18l6-6-6-6'],
+ close: ['M18 6L6 18', 'M6 6l12 12'],
+ pin: ['M12 17v5', 'M9 3h6l-1 7 3 3H7l3-3-1-7z'],
+ check: ['M5 12l5 5L20 7'],
+ briefcase: [
+ 'M5 7h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z',
+ 'M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2',
+ 'M3 12h18',
+ ],
+ ban: [CIRCLE, 'M5.6 5.6l12.8 12.8'],
+ help: [CIRCLE, 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 1-1 1.7', 'M12 17h.01'],
+ clock: [CIRCLE, 'M12 7v5l3 2'],
+ plus: ['M12 5v14', 'M5 12h14'],
+ external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5'],
+ calendar: [
+ 'M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
+ 'M3 10h18',
+ 'M8 3v4',
+ 'M16 3v4',
+ ],
+}
+
+/** Every icon name, in one place, so a test can render each of them. */
+export const ICON_NAMES = Object.keys(PATHS) as IconName[]
+
+export type IconSize = 16 | 20 | 24
+
+export interface IconProps {
+ name: IconName
+ /** Rendered size in px — matches --icon-sm / --icon-md / --icon-lg. */
+ size?: IconSize | undefined
+ className?: string | undefined
+}
+
+export function Icon({ name, size = 16, className }: IconProps): ReactNode {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/common/LoadingSpinner.test.tsx b/frontend/src/components/common/LoadingSpinner.test.tsx
index 98983a0..93a1bb5 100644
--- a/frontend/src/components/common/LoadingSpinner.test.tsx
+++ b/frontend/src/components/common/LoadingSpinner.test.tsx
@@ -6,11 +6,12 @@ describe('LoadingSpinner', () => {
it('renders loading spinner', () => {
render()
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
+ expect(screen.getByTestId('spinner-icon')).toHaveClass('loading-spinner__icon')
})
- it('displays default message', () => {
+ it('displays the localized default message (Swedish under the test environment)', () => {
render()
- expect(screen.getByTestId('spinner-message')).toHaveTextContent('Loading...')
+ expect(screen.getByTestId('spinner-message')).toHaveTextContent('Laddar…')
})
it('displays custom message', () => {
@@ -26,9 +27,9 @@ describe('LoadingSpinner', () => {
expect(spinner).toHaveAttribute('aria-busy', 'true')
})
- it('uses default aria label', () => {
+ it('uses the localized default aria label', () => {
render()
const spinner = screen.getByTestId('loading-spinner')
- expect(spinner).toHaveAttribute('aria-label', 'Loading')
+ expect(spinner).toHaveAttribute('aria-label', 'Laddar')
})
})
diff --git a/frontend/src/components/common/LoadingSpinner.tsx b/frontend/src/components/common/LoadingSpinner.tsx
index 9229bd4..fe86021 100644
--- a/frontend/src/components/common/LoadingSpinner.tsx
+++ b/frontend/src/components/common/LoadingSpinner.tsx
@@ -1,16 +1,31 @@
import { ReactNode } from 'react'
+import { useTranslation } from '@/i18n'
import './loading-spinner.css'
interface LoadingSpinnerProps {
- message?: string
- ariaLabel?: string
+ message?: string | undefined
+ ariaLabel?: string | undefined
}
-export function LoadingSpinner({ message = 'Loading...', ariaLabel = 'Loading' }: LoadingSpinnerProps): ReactNode {
+/**
+ * A ring spinner with a message. Defaults come from the i18n bundle so a
+ * bare `` reads correctly in either language; callers
+ * with a more specific message ("Laddar kalender…") pass it in.
+ */
+export function LoadingSpinner({ message, ariaLabel }: LoadingSpinnerProps): ReactNode {
+ const { t } = useTranslation()
return (
-
-
⊙
-
{message}
+
+
+
+ {message ?? t('common.loadingEllipsis')}
+
)
}
diff --git a/frontend/src/components/common/PageSkeleton.test.tsx b/frontend/src/components/common/PageSkeleton.test.tsx
new file mode 100644
index 0000000..6779b3d
--- /dev/null
+++ b/frontend/src/components/common/PageSkeleton.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { PageSkeleton } from './PageSkeleton'
+
+describe('PageSkeleton', () => {
+ it('renders a busy status region with a localized hidden label', () => {
+ render()
+ const skeleton = screen.getByTestId('page-skeleton')
+ expect(skeleton).toHaveAttribute('role', 'status')
+ expect(skeleton).toHaveAttribute('aria-busy', 'true')
+ expect(skeleton).toHaveAttribute('aria-label', 'Laddar')
+ expect(screen.getByText('Laddar…')).toHaveClass('visually-hidden')
+ })
+
+ it('renders three placeholder rows by default', () => {
+ render()
+ expect(screen.getAllByTestId('page-skeleton-row')).toHaveLength(3)
+ })
+
+ it('renders the requested number of rows', () => {
+ render()
+ expect(screen.getAllByTestId('page-skeleton-row')).toHaveLength(5)
+ })
+
+ it('renders no rows when asked for zero', () => {
+ render()
+ expect(screen.queryAllByTestId('page-skeleton-row')).toHaveLength(0)
+ })
+})
diff --git a/frontend/src/components/common/PageSkeleton.tsx b/frontend/src/components/common/PageSkeleton.tsx
new file mode 100644
index 0000000..48ccc5f
--- /dev/null
+++ b/frontend/src/components/common/PageSkeleton.tsx
@@ -0,0 +1,36 @@
+import { ReactNode } from 'react'
+import { useTranslation } from '@/i18n'
+import './page-skeleton.css'
+
+interface PageSkeletonProps {
+ /** Number of placeholder text rows under the title block. */
+ rows?: number | undefined
+}
+
+/**
+ * What a page shows inside the shell while its data loads: a title-shaped
+ * block and a few text-shaped rows, so the header/nav stay put and the
+ * content area doesn't flash empty. Announced once to assistive tech via
+ * the hidden "Laddar…" text.
+ */
+export function PageSkeleton({ rows = 3 }: PageSkeletonProps): ReactNode {
+ const { t } = useTranslation()
+ return (
+