Lay the Almanacka design foundation: tokens, type, shell

New token system in global.css (near-white surfaces, one ink-blue
accent, no sub-12px type), self-hosted Schibsted Grotesk, rewritten
primitives (buttons, fields, modal shell, segmented, disclosure,
skeleton), a borderless 56px header with an SV | EN language toggle,
an Icon component replacing glyphs, page skeletons that keep the
layout during loading, and leave-type colours moved out of TS into
CSS variables. Feature blocks keep their structure for later phases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 18:25:04 +02:00
parent 95444d3bf4
commit d14f86478a
44 changed files with 1358 additions and 485 deletions
@@ -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}`}
@@ -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(<Icon name={name} />)
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(<Icon name="check" />)
expect(container.querySelector('svg')).toHaveAttribute('width', '16')
expect(container.querySelector('svg')).toHaveAttribute('height', '16')
rerender(<Icon name="check" size={24} />)
expect(container.querySelector('svg')).toHaveAttribute('width', '24')
})
it('always carries the icon class and appends a custom one', () => {
const { container, rerender } = render(<Icon name="plus" />)
expect(container.querySelector('svg')).toHaveClass('icon')
rerender(<Icon name="plus" className="btn__icon" />)
expect(container.querySelector('svg')).toHaveClass('icon', 'btn__icon')
})
})
+82
View File
@@ -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<IconName, readonly string[]> = {
'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 (
<svg
className={className ? `icon ${className}` : 'icon'}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
data-icon={name}
>
{PATHS[name].map((d) => (
<path key={d} d={d} />
))}
</svg>
)
}
@@ -6,11 +6,12 @@ describe('LoadingSpinner', () => {
it('renders loading spinner', () => {
render(<LoadingSpinner />)
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(<LoadingSpinner />)
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(<LoadingSpinner />)
const spinner = screen.getByTestId('loading-spinner')
expect(spinner).toHaveAttribute('aria-label', 'Loading')
expect(spinner).toHaveAttribute('aria-label', 'Laddar')
})
})
@@ -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 `<LoadingSpinner />` 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 (
<div className="loading-spinner" role="status" aria-label={ariaLabel} aria-busy={true} data-testid="loading-spinner">
<div className="loading-spinner__icon" data-testid="spinner-icon" role="presentation">⊙</div>
<p className="loading-spinner__message" data-testid="spinner-message">{message}</p>
<div
className="loading-spinner"
role="status"
aria-label={ariaLabel ?? t('common.loading')}
aria-busy={true}
data-testid="loading-spinner"
>
<div className="loading-spinner__icon" data-testid="spinner-icon" role="presentation" />
<p className="loading-spinner__message" data-testid="spinner-message">
{message ?? t('common.loadingEllipsis')}
</p>
</div>
)
}
@@ -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(<PageSkeleton />)
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(<PageSkeleton />)
expect(screen.getAllByTestId('page-skeleton-row')).toHaveLength(3)
})
it('renders the requested number of rows', () => {
render(<PageSkeleton rows={5} />)
expect(screen.getAllByTestId('page-skeleton-row')).toHaveLength(5)
})
it('renders no rows when asked for zero', () => {
render(<PageSkeleton rows={0} />)
expect(screen.queryAllByTestId('page-skeleton-row')).toHaveLength(0)
})
})
@@ -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 (
<div
className="page-skeleton"
role="status"
aria-busy={true}
aria-label={t('common.loading')}
data-testid="page-skeleton"
>
<span className="visually-hidden">{t('common.loadingEllipsis')}</span>
<span className="skeleton page-skeleton__title" aria-hidden="true" />
<span className="skeleton page-skeleton__lede" aria-hidden="true" />
<div className="page-skeleton__rows">
{Array.from({ length: rows }, (_, i) => (
<span key={i} className="skeleton page-skeleton__row" aria-hidden="true" data-testid="page-skeleton-row" />
))}
</div>
</div>
)
}
@@ -1,4 +1,4 @@
/* Loading Spinner */
/* Loading Spinner — a thin ring with an accent arc, not a glyph. */
.loading-spinner {
display: flex;
flex-direction: column;
@@ -10,14 +10,17 @@
}
.loading-spinner__icon {
font-size: var(--icon-lg);
color: var(--c-accent);
animation: spin 2s linear infinite;
display: block;
width: var(--icon-lg);
height: var(--icon-lg);
border: 2px solid var(--c-border);
border-top-color: var(--c-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner__message {
font-size: var(--fs-base);
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin: 0;
}
@@ -0,0 +1,36 @@
/* Page skeleton — sized like a page title, a lede and a few text rows. */
.page-skeleton {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-2);
}
.page-skeleton__title {
width: 40%;
max-width: 320px;
height: 28px;
margin-bottom: var(--sp-2);
}
.page-skeleton__lede {
width: 70%;
height: 16px;
margin-bottom: var(--sp-4);
}
.page-skeleton__rows {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.page-skeleton__row {
width: 100%;
height: 48px;
border-radius: var(--radius);
}
.page-skeleton__row:nth-child(2n) {
width: 92%;
}