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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/SchibstedGrotesk-latin-v7.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<title>Föräldraledigplannerare</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 <p>{t('auth.loadingEllipsis')}</p>
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
if (!isAuthenticated) return <Navigate to="/auth" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
|
||||
import { getColorForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
|
||||
export interface YearTimelineProps {
|
||||
@@ -134,7 +134,7 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
|
||||
{TYPE_PRIORITY.map((type) => (
|
||||
<li key={type} className="year-timeline__legend-item">
|
||||
<span
|
||||
className="year-timeline__legend-swatch"
|
||||
className={`year-timeline__legend-swatch year-timeline__legend-swatch--${getTypeModifier(type)}`}
|
||||
style={{ backgroundColor: getColorForType(type) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -165,7 +165,7 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
|
||||
<button
|
||||
key={`${year}-${idx}`}
|
||||
type="button"
|
||||
className="year-timeline__band"
|
||||
className={`year-timeline__band year-timeline__band--${getTypeModifier(band.dominantType)}`}
|
||||
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: getColorForType(band.dominantType) }}
|
||||
onClick={() => onBandClick(band.startDate)}
|
||||
data-testid={`year-timeline-band-${year}-${idx}`}
|
||||
|
||||
@@ -126,4 +126,31 @@ describe('Header', () => {
|
||||
expect(screen.getByText('Inställningar')).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByText('Översikt')).not.toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
it('renders the SV | EN language toggle in the full variant', () => {
|
||||
renderWithRouter(<Header />)
|
||||
expect(screen.getByTestId('language-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('renders only brand, language toggle and logout in the bare variant', () => {
|
||||
const childrenList = [{ id: '1', name: 'Emma' }]
|
||||
renderWithRouter(
|
||||
<Header variant="bare" parentName="Elin" childrenList={childrenList} selectedChildId="1" onLogout={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByTestId('header')).toHaveClass('header--bare')
|
||||
expect(screen.getByTestId('header-title')).toHaveTextContent('Föräldraledigplannerare')
|
||||
expect(screen.getByTestId('language-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('logout-button')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-nav')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('child-select')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('parent-name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to the full variant', () => {
|
||||
renderWithRouter(<Header />)
|
||||
expect(screen.getByTestId('header')).toHaveClass('header--full')
|
||||
expect(screen.getByTestId('header-nav')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { LanguageToggle } from './LanguageToggle'
|
||||
import './header.css'
|
||||
|
||||
export type HeaderVariant = 'full' | 'bare'
|
||||
|
||||
export interface HeaderProps {
|
||||
/**
|
||||
* `full` (default): brand, nav, child select + parent name, language,
|
||||
* logout. `bare`: brand, language, logout only — for screens where there
|
||||
* is nothing to navigate to yet (onboarding).
|
||||
*/
|
||||
variant?: HeaderVariant | undefined
|
||||
parentName?: string | undefined
|
||||
childrenList?: Array<{ id: string; name: string }> | undefined
|
||||
selectedChildId?: string | null | undefined
|
||||
@@ -12,18 +21,14 @@ export interface HeaderProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* #5 fix — previously two rows built from a `header__top` (title + identity,
|
||||
* with the identity cluster wrapping onto 2-3 lines once a name/child was
|
||||
* long) plus a separate `header__nav` row. Now a single flex-wrapping bar:
|
||||
* brand, nav (as `btn btn--ghost`, active state read from `NavLink`'s own
|
||||
* `aria-current="page"` rather than a hand-rolled `--active` class), the
|
||||
* child select + parent name, and logout — each sized by its content
|
||||
* (`.field__control`/`.btn`) instead of the identity cluster's old fixed
|
||||
* layout, so long content wraps the *row*, not individual words. The
|
||||
* previously-duplicated `selectedChildName` span is gone: the child select
|
||||
* already shows the selected child's name as its value.
|
||||
* One flex-wrapping bar: brand, nav (as `btn btn--ghost`, active state read
|
||||
* from `NavLink`'s own `aria-current="page"`), the child select + parent
|
||||
* name, then the language toggle and logout — each sized by its content so
|
||||
* long content wraps the *row*, not individual words. See responsive.css
|
||||
* for how the groups reorder at <=640px.
|
||||
*/
|
||||
export function Header({
|
||||
variant = 'full',
|
||||
parentName,
|
||||
childrenList,
|
||||
selectedChildId,
|
||||
@@ -31,59 +36,67 @@ export function Header({
|
||||
onLogout,
|
||||
}: HeaderProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const isFull = variant === 'full'
|
||||
return (
|
||||
<header className="header" role="banner" data-testid="header">
|
||||
<header className={`header header--${variant}`} role="banner" data-testid="header">
|
||||
<div className="header__bar">
|
||||
<div className="header__brand" data-testid="header-title">
|
||||
{t('app.name')}
|
||||
</div>
|
||||
|
||||
<nav className="header__nav" role="navigation" data-testid="header-nav">
|
||||
<NavLink to="/" className="btn btn--ghost header__nav-link">
|
||||
{t('nav.overview')}
|
||||
</NavLink>
|
||||
<NavLink to="/calendar" className="btn btn--ghost header__nav-link">
|
||||
{t('nav.calendar')}
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className="btn btn--ghost header__nav-link">
|
||||
{t('nav.settings')}
|
||||
</NavLink>
|
||||
</nav>
|
||||
{isFull && (
|
||||
<nav className="header__nav" role="navigation" data-testid="header-nav">
|
||||
<NavLink to="/" className="btn btn--ghost header__nav-link">
|
||||
{t('nav.overview')}
|
||||
</NavLink>
|
||||
<NavLink to="/calendar" className="btn btn--ghost header__nav-link">
|
||||
{t('nav.calendar')}
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className="btn btn--ghost header__nav-link">
|
||||
{t('nav.settings')}
|
||||
</NavLink>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="header__identity">
|
||||
{childrenList && childrenList.length > 0 && (
|
||||
<select
|
||||
className="field__control header__child-select"
|
||||
value={selectedChildId ?? ''}
|
||||
onChange={(e) => onChildSelect?.(e.target.value)}
|
||||
data-testid="child-select"
|
||||
aria-label={t('nav.selectChildAria')}
|
||||
{isFull && (
|
||||
<div className="header__identity">
|
||||
{childrenList && childrenList.length > 0 && (
|
||||
<select
|
||||
className="header__child-select"
|
||||
value={selectedChildId ?? ''}
|
||||
onChange={(e) => onChildSelect?.(e.target.value)}
|
||||
data-testid="child-select"
|
||||
aria-label={t('nav.selectChildAria')}
|
||||
>
|
||||
{childrenList.map((child) => (
|
||||
<option key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{parentName && (
|
||||
<span className="header__parent-name" data-testid="parent-name">
|
||||
{parentName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="header__tools">
|
||||
<LanguageToggle />
|
||||
{onLogout && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost btn--sm header__logout"
|
||||
onClick={onLogout}
|
||||
data-testid="logout-button"
|
||||
aria-label={t('nav.logoutAria')}
|
||||
>
|
||||
{childrenList.map((child) => (
|
||||
<option key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{parentName && (
|
||||
<span className="header__parent-name" data-testid="parent-name">
|
||||
{parentName}
|
||||
</span>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onLogout && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost btn--sm header__logout"
|
||||
onClick={onLogout}
|
||||
data-testid="logout-button"
|
||||
aria-label={t('nav.logoutAria')}
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { LanguageProvider } from '@/i18n'
|
||||
import { LanguageToggle } from './LanguageToggle'
|
||||
|
||||
// Node's own experimental `localStorage` global (active in newer Node
|
||||
// runtimes without a valid --localstorage-file) shadows jsdom's working
|
||||
// implementation and throws on every call. Stub a real in-memory Storage,
|
||||
// matching the idiom in LanguageContext.test.tsx.
|
||||
function createStorageMock(): Storage {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => (key in store ? store[key]! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key]
|
||||
},
|
||||
clear: () => {
|
||||
store = {}
|
||||
},
|
||||
key: (index: number) => Object.keys(store)[index] ?? null,
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderToggle() {
|
||||
return render(
|
||||
<LanguageProvider>
|
||||
<LanguageToggle />
|
||||
</LanguageProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('LanguageToggle', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createStorageMock())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders a labelled group with SV and EN options', () => {
|
||||
renderToggle()
|
||||
const group = screen.getByRole('group', { name: 'Språk' })
|
||||
expect(group).toBe(screen.getByTestId('language-toggle'))
|
||||
expect(screen.getByTestId('language-toggle-sv')).toHaveTextContent('SV')
|
||||
expect(screen.getByTestId('language-toggle-en')).toHaveTextContent('EN')
|
||||
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-label', 'Svenska')
|
||||
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-label', 'English')
|
||||
})
|
||||
|
||||
it('marks the resolved locale as pressed (Swedish under the test environment)', () => {
|
||||
renderToggle()
|
||||
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('switches the locale (and its own label) when the other option is pressed', async () => {
|
||||
renderToggle()
|
||||
await userEvent.click(screen.getByTestId('language-toggle-en'))
|
||||
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('group', { name: 'Language' })).toBeInTheDocument()
|
||||
expect(localStorage.getItem('language_preference')).toBe('en')
|
||||
|
||||
await userEvent.click(screen.getByTestId('language-toggle-sv'))
|
||||
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(localStorage.getItem('language_preference')).toBe('sv')
|
||||
})
|
||||
|
||||
it('reflects a persisted explicit choice on mount', () => {
|
||||
localStorage.setItem('language_preference', 'en')
|
||||
renderToggle()
|
||||
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation, type Locale } from '@/i18n'
|
||||
import './language-toggle.css'
|
||||
|
||||
const OPTIONS: ReadonlyArray<{ locale: Locale; short: string; ariaKey: 'nav.languageSv' | 'nav.languageEn' }> = [
|
||||
{ locale: 'sv', short: 'SV', ariaKey: 'nav.languageSv' },
|
||||
{ locale: 'en', short: 'EN', ariaKey: 'nav.languageEn' },
|
||||
]
|
||||
|
||||
/**
|
||||
* SV | EN — a two-option segmented control in the header. Pressing an option
|
||||
* sets an explicit locale (persisted by `LanguageProvider`); the pressed one
|
||||
* is whichever locale is currently resolved, so it also reflects browser
|
||||
* detection before the person has chosen.
|
||||
*/
|
||||
export function LanguageToggle(): ReactNode {
|
||||
const { t, locale, setLocale } = useTranslation()
|
||||
return (
|
||||
<div className="segmented language-toggle" role="group" aria-label={t('nav.languageAria')} data-testid="language-toggle">
|
||||
{OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.locale}
|
||||
type="button"
|
||||
className="segmented__option language-toggle__option"
|
||||
aria-pressed={locale === option.locale}
|
||||
aria-label={t(option.ariaKey)}
|
||||
lang={option.locale}
|
||||
onClick={() => setLocale(option.locale)}
|
||||
data-testid={`language-toggle-${option.locale}`}
|
||||
>
|
||||
{option.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,10 +23,10 @@ vi.mock('react-router-dom', async () => {
|
||||
const mockUseAuth = vi.mocked(useAuth)
|
||||
const mockUseWorkspace = vi.mocked(useWorkspace)
|
||||
|
||||
function renderLayout(children = <div>page content</div>) {
|
||||
function renderLayout(children = <div>page content</div>, chrome?: 'full' | 'bare') {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Layout>{children}</Layout>
|
||||
<Layout chrome={chrome}>{children}</Layout>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
@@ -71,6 +71,27 @@ describe('Layout', () => {
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes exactly one main landmark — the <main> element, not the wrapper', () => {
|
||||
renderLayout()
|
||||
const mains = screen.getAllByRole('main')
|
||||
expect(mains).toHaveLength(1)
|
||||
expect(mains[0]).toBe(screen.getByTestId('layout-main'))
|
||||
expect(screen.getByTestId('layout')).not.toHaveAttribute('role')
|
||||
expect(screen.getByTestId('layout')).toHaveClass('layout')
|
||||
expect(screen.getByTestId('layout-main')).toHaveClass('layout__main')
|
||||
})
|
||||
|
||||
it('shows the full header by default and forwards chrome="bare" to the Header', () => {
|
||||
const { unmount } = renderLayout()
|
||||
expect(screen.getByTestId('header')).toHaveClass('header--full')
|
||||
expect(screen.getByTestId('header-nav')).toBeInTheDocument()
|
||||
unmount()
|
||||
renderLayout(<div>page content</div>, 'bare')
|
||||
expect(screen.getByTestId('header')).toHaveClass('header--bare')
|
||||
expect(screen.queryByTestId('header-nav')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('logout-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes parent name and children list from workspace context to Header', () => {
|
||||
mockUseWorkspace.mockReturnValue({
|
||||
workspace: null,
|
||||
|
||||
@@ -2,16 +2,18 @@ import { ReactNode, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useWorkspace } from '@/context/WorkspaceContext'
|
||||
import { Header, HeaderProps } from './Header'
|
||||
import { Header, HeaderProps, HeaderVariant } from './Header'
|
||||
import { Footer } from './Footer'
|
||||
import './layout.css'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
header?: HeaderProps
|
||||
header?: HeaderProps | undefined
|
||||
/** Which header to show — see `HeaderProps.variant`. Defaults to `full`. */
|
||||
chrome?: HeaderVariant | undefined
|
||||
}
|
||||
|
||||
export function Layout({ children, header }: LayoutProps): ReactNode {
|
||||
export function Layout({ children, header, chrome = 'full' }: LayoutProps): ReactNode {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
const { parentName, children: childrenList, selectedChildId, selectChild } = useWorkspace()
|
||||
@@ -31,6 +33,7 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
|
||||
|
||||
const headerProps: HeaderProps = {
|
||||
...header,
|
||||
variant: chrome,
|
||||
parentName: parentName ?? undefined,
|
||||
childrenList,
|
||||
selectedChildId,
|
||||
@@ -38,10 +41,14 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
|
||||
onLogout: handleLogout,
|
||||
}
|
||||
|
||||
// The `<main>` below is the page's one main landmark — the wrapper is a
|
||||
// plain div so assistive tech never sees two of them.
|
||||
return (
|
||||
<div role="main" data-testid="layout">
|
||||
<div className="layout" data-testid="layout">
|
||||
<Header {...headerProps} />
|
||||
<main data-testid="layout-main">{children}</main>
|
||||
<main className="layout__main" data-testid="layout-main">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/* Footer — a single faint line of text at the foot of the page; no rule
|
||||
* above it, the whitespace before it is the boundary. */
|
||||
footer {
|
||||
margin-top: var(--sp-8);
|
||||
padding: var(--sp-5) 0;
|
||||
border-top: 1px solid var(--c-border);
|
||||
background-color: var(--c-bg);
|
||||
color: var(--c-text-muted);
|
||||
padding: var(--sp-5) 0 var(--sp-6);
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
/* Matches [data-testid='layout-main']'s own max-width/side gutters so the
|
||||
* footer's text lines up with the page content above it instead of
|
||||
* spreading edge-to-edge under a narrower container. */
|
||||
/* Matches .layout__main's max-width/side gutters so the footer's text lines
|
||||
* up with the page content above it. */
|
||||
.footer__inner {
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -1,104 +1,131 @@
|
||||
/* Header — 56px, no border, no shadow. The page background runs straight
|
||||
* into it; the brand wordmark and the accent-tinted active link are the
|
||||
* only things that mark it as chrome. */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background-color: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
z-index: var(--z-header);
|
||||
background-color: var(--c-bg);
|
||||
}
|
||||
|
||||
/* #5 — one flex-wrapping bar instead of a fixed title/identity row plus a
|
||||
* separate nav row. Each group (brand, nav, identity, logout) is sized by
|
||||
* its own content and wraps onto a new line as a whole group rather than
|
||||
* letting a long name/child force the row into 2-3 lines of ragged text —
|
||||
* see the per-breakpoint order overrides in responsive.css for how the
|
||||
* groups rearrange at <=640px. */
|
||||
/* One flex-wrapping bar. Each group (brand, nav, identity, tools) is sized
|
||||
* by its own content and wraps onto a new line as a whole group — see the
|
||||
* per-breakpoint `order` overrides in responsive.css for how the groups
|
||||
* rearrange at <=640px. */
|
||||
.header__bar {
|
||||
max-width: var(--maxw);
|
||||
min-height: 56px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--sp-5);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
gap: var(--sp-2) var(--sp-5);
|
||||
}
|
||||
|
||||
.header__brand {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--c-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header__nav {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
gap: var(--sp-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* `.btn` doesn't set `text-decoration` (most buttons aren't anchors) — these
|
||||
* are `<NavLink>`s, so without this the global `a:hover` rule underlines
|
||||
* the label on hover. */
|
||||
/* Nav links are `.btn.btn--ghost` `<NavLink>`s. The global `a:hover` rule
|
||||
* would underline the label; the active state is read from `NavLink`'s own
|
||||
* `aria-current="page"` rather than a hand-rolled `--active` class. */
|
||||
.header__nav-link,
|
||||
.header__nav-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Active state comes from `NavLink`'s own `aria-current="page"` attribute
|
||||
* (set automatically when the route matches) rather than a hand-rolled
|
||||
* `--active` class — one less thing for a link's className function to get
|
||||
* out of sync with. */
|
||||
.header__nav-link.btn {
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header__nav-link.btn[aria-current='page'] {
|
||||
background-color: var(--c-accent);
|
||||
color: var(--c-surface);
|
||||
background-color: var(--c-accent-weak);
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.header__nav-link.btn[aria-current='page']:hover {
|
||||
background-color: var(--c-accent-hover);
|
||||
color: var(--c-surface);
|
||||
background-color: var(--c-accent-weak);
|
||||
color: var(--c-accent-hover);
|
||||
}
|
||||
|
||||
/* Child select + parent name. `margin-left: auto` pushes this cluster (and
|
||||
* the tools after it) to the right of the row when there's room. */
|
||||
.header__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
/* Row 1 reads brand + nav on the left; this margin pushes the child
|
||||
* select + parent name to the right of that same row when there's room,
|
||||
* or onto their own wrapped row 2 when there isn't. */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Sized by its content, not a fixed width — the global `select { width:
|
||||
* 100% }` reset (global.css) would otherwise stretch this across whatever
|
||||
* room the flex row happens to leave it. Long child names ellipsize instead
|
||||
* of forcing the header wider. It shrinks before `.header__parent-name`
|
||||
* below when the row genuinely runs out of room (that name is pinned to
|
||||
* `flex: 0 0 auto`), so a name never gets clipped to a few characters while
|
||||
* this select still has its full width. */
|
||||
/* Bare: no box, no fill — just the child's name and a chevron. Sized by its
|
||||
* content (the global `select { width: 100% }` reset would otherwise
|
||||
* stretch it); long names ellipsize instead of forcing the header wider. */
|
||||
.header__child-select {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
max-width: 22ch;
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-5) 0 var(--sp-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: transparent;
|
||||
background-position: right var(--sp-1) center;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Pinned to its content size — never flex-shrunk. Without this, the identity
|
||||
* group's default flex-shrink squeezed this span down to a sliver (~60px at
|
||||
* 1280px with a child select present) long before the row ran out of room,
|
||||
* clipping a name like "Recheck Tester" to a couple of characters. The
|
||||
* child select (above) and the nav/brand groups absorb tight space instead;
|
||||
* only once there is truly no room left does the row wrap (`.header__bar`'s
|
||||
* `flex-wrap: wrap`). */
|
||||
.header__child-select:hover {
|
||||
background-color: var(--c-accent-weak);
|
||||
}
|
||||
|
||||
.header__child-select:focus {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Pinned to its content size — never flex-shrunk, so a name never gets
|
||||
* clipped to a couple of characters while the select still has its full
|
||||
* width. Only once there is truly no room left does the row wrap. */
|
||||
.header__parent-name {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
font-weight: 500;
|
||||
max-width: 24ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Language toggle + logout. */
|
||||
.header__tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* In the bare variant (no nav, no identity) the tools are the only thing on
|
||||
* the right, so they take the auto margin the identity group normally has. */
|
||||
.header--bare .header__tools {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* SV | EN — built on `.segmented` (primitives.css); this only adds the thin
|
||||
* divider between the two options and pins the letter-spacing. */
|
||||
.language-toggle__option {
|
||||
min-width: 34px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.language-toggle__option + .language-toggle__option {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.language-toggle__option + .language-toggle__option::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
transform: translateY(-50%);
|
||||
background-color: var(--c-border);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/* Layout container */
|
||||
[data-testid='layout'] {
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: var(--c-bg);
|
||||
}
|
||||
|
||||
[data-testid='layout-main'] {
|
||||
.layout__main {
|
||||
flex: 1;
|
||||
max-width: var(--maxw);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6) var(--sp-5);
|
||||
padding: var(--sp-6) var(--sp-5) var(--sp-7);
|
||||
}
|
||||
|
||||
/* Dashboard & Pages */
|
||||
@@ -27,12 +27,9 @@
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
/* Sections separate by whitespace and a hairline rule, not a stack of
|
||||
* bordered/shadowed cards — every section on a page looking equally "boxed"
|
||||
* flattens the hierarchy the headline figures above them are meant to have.
|
||||
* The `<details>` accordions (plan-fold, dashboard__more-details) keep their
|
||||
* own bordered treatment in components.css since they're interactive
|
||||
* disclosure controls, not passive content groups. */
|
||||
/* Sections separate by whitespace and, where two run together, one
|
||||
* hairline — never a stack of bordered cards. Every section looking equally
|
||||
* "boxed" flattens the hierarchy the headline figures are meant to have. */
|
||||
.dashboard__section,
|
||||
.settings-page__section,
|
||||
.calendar__section,
|
||||
@@ -42,7 +39,7 @@
|
||||
border-top: 1px solid var(--c-border);
|
||||
border-radius: 0;
|
||||
padding: var(--sp-6) 0 0;
|
||||
margin-bottom: var(--sp-6);
|
||||
margin-bottom: var(--sp-7);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -75,7 +72,7 @@
|
||||
}
|
||||
|
||||
.dashboard__break-item {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -105,4 +102,3 @@
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export const common = {
|
||||
'common.close': 'Close',
|
||||
'common.delete': 'Delete',
|
||||
'common.deleting': 'Deleting…',
|
||||
'common.loading': 'Loading',
|
||||
'common.loadingEllipsis': 'Loading…',
|
||||
'common.save': 'Save',
|
||||
'common.saving': 'Saving…',
|
||||
@@ -13,6 +14,9 @@ export const common = {
|
||||
'leaveType.unknown': 'Unknown',
|
||||
'leaveType.vacation': 'Vacation',
|
||||
'nav.calendar': 'Calendar',
|
||||
'nav.languageAria': 'Language',
|
||||
'nav.languageEn': 'English',
|
||||
'nav.languageSv': 'Svenska',
|
||||
'nav.logout': 'Log out',
|
||||
'nav.logoutAria': 'Log out',
|
||||
'nav.overview': 'Overview',
|
||||
|
||||
@@ -4,6 +4,7 @@ export const common = {
|
||||
'common.close': 'Stäng',
|
||||
'common.delete': 'Ta bort',
|
||||
'common.deleting': 'Tar bort…',
|
||||
'common.loading': 'Laddar',
|
||||
'common.loadingEllipsis': 'Laddar…',
|
||||
'common.save': 'Spara',
|
||||
'common.saving': 'Sparar…',
|
||||
@@ -13,6 +14,9 @@ export const common = {
|
||||
'leaveType.unknown': 'Okänd',
|
||||
'leaveType.vacation': 'Semester',
|
||||
'nav.calendar': 'Kalender',
|
||||
'nav.languageAria': 'Språk',
|
||||
'nav.languageEn': 'English',
|
||||
'nav.languageSv': 'Svenska',
|
||||
'nav.logout': 'Logga ut',
|
||||
'nav.logoutAria': 'Logga ut',
|
||||
'nav.overview': 'Översikt',
|
||||
|
||||
@@ -71,10 +71,6 @@ vi.mock('@/components/layout/Layout', () => ({
|
||||
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LoadingSpinner', () => ({
|
||||
LoadingSpinner: () => <div>Loading...</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/modals/PlannedDayModal', () => ({
|
||||
// Exposes the props this test suite cares about as data attributes/buttons
|
||||
// so tests can assert on Calendar.tsx's wiring without rendering the real
|
||||
@@ -313,7 +309,7 @@ describe('Calendar', () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspace, isLoading: true })
|
||||
|
||||
render(<Calendar />)
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders 7 weekday headers in Monday-first Swedish order', () => {
|
||||
|
||||
@@ -25,12 +25,12 @@ import type {
|
||||
WindowOutcome,
|
||||
} from '@/types'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { PageSkeleton } from '@/components/common/PageSkeleton'
|
||||
import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek'
|
||||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
|
||||
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
|
||||
import { getColorForType } from '@/utils/leaveTypes'
|
||||
import { getColorForType, getTypeModifier } from '@/utils/leaveTypes'
|
||||
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
|
||||
import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates'
|
||||
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
|
||||
@@ -437,7 +437,11 @@ export function Calendar(): ReactNode {
|
||||
}, [isDragging, dragAnchor, dragCurrent, personalDataUuid])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('calendar.loading')} />
|
||||
return (
|
||||
<Layout>
|
||||
<PageSkeleton rows={5} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const year = currentMonth.getFullYear()
|
||||
@@ -1215,7 +1219,7 @@ export function Calendar(): ReactNode {
|
||||
<span>{t('calendar.legendIncomeBased')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
|
||||
<span className={`calendar__legend-swatch calendar__legend-swatch--${getTypeModifier('lagsta')}`} style={{ backgroundColor: getColorForType('lagsta') }} />
|
||||
<span>{t('calendar.legendMinimumRate')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('Dashboard', () => {
|
||||
})
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hands off to Onboarding when there are no children yet', () => {
|
||||
@@ -528,7 +528,7 @@ describe('Dashboard', () => {
|
||||
})
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows planned days loading state', () => {
|
||||
@@ -539,7 +539,7 @@ describe('Dashboard', () => {
|
||||
})
|
||||
)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with zero planned days', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isSelf, orderSelfFirst } from '@/utils/members'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { PageSkeleton } from '@/components/common/PageSkeleton'
|
||||
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
|
||||
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
|
||||
import { Hero } from '@/components/dashboard/Hero'
|
||||
@@ -91,7 +92,11 @@ export function Dashboard(): ReactNode {
|
||||
const isLoading = wsLoading || analyticsLoading || pdLoading
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('dashboard.loading')} />
|
||||
return (
|
||||
<Layout>
|
||||
<PageSkeleton />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const handleFetchFK = async () => {
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('Settings', () => {
|
||||
})
|
||||
)
|
||||
renderSettings()
|
||||
expect(screen.getByText(/Laddar inställningar/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders consistently Swedish section headings', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isApiError } from '@/services/errors'
|
||||
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { PageSkeleton } from '@/components/common/PageSkeleton'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
|
||||
import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
|
||||
@@ -189,7 +190,11 @@ export function Settings(): ReactNode {
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('settings.loading')} />
|
||||
return (
|
||||
<Layout>
|
||||
<PageSkeleton rows={4} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
|
||||
appearance: none;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 8"><path d="M1 1l5 5 5-5" stroke="%231c2733" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>');
|
||||
background-image: var(--icon-chevron-down);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
background-size: 12px;
|
||||
@@ -172,7 +172,6 @@
|
||||
|
||||
.calendar-day:hover {
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 2px 8px var(--c-accent-shadow);
|
||||
background-color: var(--c-accent-weak);
|
||||
}
|
||||
|
||||
@@ -194,7 +193,7 @@
|
||||
}
|
||||
|
||||
.calendar-day--has-events {
|
||||
background-color: var(--c-accent-tint);
|
||||
background-color: var(--c-row-hover);
|
||||
}
|
||||
|
||||
.calendar-day__number {
|
||||
@@ -223,7 +222,7 @@
|
||||
.calendar-day--selected {
|
||||
background-color: var(--c-accent-weak);
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 2px 8px var(--c-focus-ring);
|
||||
box-shadow: inset 0 0 0 1px var(--c-accent);
|
||||
}
|
||||
|
||||
.calendar-day--selected:hover {
|
||||
@@ -235,20 +234,20 @@
|
||||
title/aria-label carrying the holiday name. */
|
||||
.calendar-day--red-day {
|
||||
background-color: var(--c-danger-weak);
|
||||
border-color: var(--c-redday-border);
|
||||
border-color: var(--c-border);
|
||||
}
|
||||
|
||||
.calendar-day--red-day:hover {
|
||||
background-color: var(--c-redday-hover);
|
||||
background-color: var(--c-danger-weak);
|
||||
}
|
||||
|
||||
.calendar-day--klamdag {
|
||||
background-color: var(--c-klamdag-bg);
|
||||
border-color: var(--c-klamdag-border);
|
||||
background-color: var(--c-warning-weak);
|
||||
border-color: var(--c-border);
|
||||
}
|
||||
|
||||
.calendar-day--klamdag:hover {
|
||||
background-color: var(--c-klamdag-hover);
|
||||
background-color: var(--c-warning-weak);
|
||||
}
|
||||
|
||||
/* Plain Saturday — neutral grey, deliberately NOT red. Matches the Swedish
|
||||
@@ -257,15 +256,15 @@
|
||||
(the backend collapses both into a single "weekend" day_type). */
|
||||
.calendar-day--weekend {
|
||||
background-color: var(--c-weekend-bg);
|
||||
border-color: var(--c-weekend-border);
|
||||
border-color: var(--c-border);
|
||||
}
|
||||
|
||||
.calendar-day--weekend:hover {
|
||||
background-color: var(--c-weekend-hover);
|
||||
background-color: var(--c-bg);
|
||||
}
|
||||
|
||||
.calendar-day__holiday-name {
|
||||
font-size: var(--fs-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-danger);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -305,7 +304,7 @@
|
||||
.calendar-day__override-badge,
|
||||
.calendar-day__excluded-badge,
|
||||
.calendar-event__badge {
|
||||
font-size: var(--fs-4xs);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
@@ -316,7 +315,7 @@
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: var(--c-surface);
|
||||
background-color: var(--c-scrim-strong);
|
||||
background-color: var(--c-text);
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
@@ -446,7 +445,6 @@
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px var(--c-band-shadow);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -460,6 +458,12 @@
|
||||
border-bottom-right-radius: var(--radius-band);
|
||||
}
|
||||
|
||||
/* Lägsta is a white fill told apart by a dark outline, not a hue (see the
|
||||
* --c-leave-* tokens); without it the band would vanish into the cell. */
|
||||
.calendar-band--type-lagsta {
|
||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
||||
}
|
||||
|
||||
.calendar-band:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
@@ -473,10 +477,10 @@
|
||||
|
||||
@keyframes calendar-band-flash {
|
||||
0%, 100% {
|
||||
box-shadow: 0 1px 2px var(--c-band-shadow);
|
||||
box-shadow: none;
|
||||
}
|
||||
30%, 70% {
|
||||
box-shadow: 0 0 0 3px var(--c-flash), 0 1px 2px var(--c-band-shadow);
|
||||
box-shadow: 0 0 0 3px var(--c-flash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,9 +490,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
font-size: var(--fs-2xs);
|
||||
color: var(--c-surface);
|
||||
text-shadow: 0 1px 1px var(--c-label-text-shadow);
|
||||
font-size: var(--fs-xs);
|
||||
/* Ink is set inline on the band from the leave type (getInkForType) */
|
||||
color: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -513,7 +517,7 @@
|
||||
funded planned_leave_day row) — no funding color, just a light neutral
|
||||
fill so the band still reads as continuous. */
|
||||
.calendar-band__day--gap {
|
||||
background-color: var(--c-fill-light);
|
||||
background-color: var(--c-band-gap);
|
||||
}
|
||||
|
||||
/* §5/§6 — hand-edited ("pinned") day: the planner must not retype it. */
|
||||
@@ -526,7 +530,7 @@
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: 1px;
|
||||
font-size: var(--fs-5xs);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -538,7 +542,7 @@
|
||||
}
|
||||
|
||||
.calendar-event__label {
|
||||
font-size: var(--fs-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -558,7 +562,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
font-size: var(--fs-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -569,7 +573,8 @@
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 3px;
|
||||
background-color: var(--c-badge-fill);
|
||||
background-color: var(--c-band-gap);
|
||||
color: var(--c-leave-lagsta-ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -608,25 +613,30 @@
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--fs-2xs);
|
||||
font-weight: bold;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--c-surface);
|
||||
box-shadow: inset 0 0 0 1px var(--c-border);
|
||||
}
|
||||
|
||||
.calendar__legend-swatch--type-lagsta {
|
||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
||||
}
|
||||
|
||||
.calendar__legend-swatch--dubbeldag {
|
||||
background-color: var(--c-scrim-soft);
|
||||
background-color: var(--c-band-gap);
|
||||
border: 1px solid var(--c-text-faint);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.calendar__legend-swatch--red-day {
|
||||
background-color: var(--c-danger-weak);
|
||||
border: 1px solid var(--c-redday-border);
|
||||
border: 1px solid var(--c-danger);
|
||||
}
|
||||
|
||||
.calendar__legend-swatch--klamdag {
|
||||
background-color: var(--c-klamdag-bg);
|
||||
border: 1px solid var(--c-klamdag-border);
|
||||
background-color: var(--c-warning-weak);
|
||||
border: 1px solid var(--c-warning);
|
||||
}
|
||||
|
||||
.calendar__legend-swatch--override {
|
||||
@@ -733,7 +743,7 @@
|
||||
justify-content: center;
|
||||
padding: var(--sp-2);
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-faint);
|
||||
border-right: 1px solid var(--c-border);
|
||||
background-color: var(--c-bg);
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
padding: var(--sp-5);
|
||||
background-color: var(--c-surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* The "all parents" combined total — a footer row spanning every column
|
||||
@@ -210,9 +209,9 @@
|
||||
.dashboard-hero__message {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 600;
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
@@ -259,11 +258,9 @@
|
||||
}
|
||||
|
||||
.age4-bars__title {
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
color: var(--c-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -314,11 +311,9 @@
|
||||
}
|
||||
|
||||
.year-timeline__title {
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
color: var(--c-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
@@ -351,6 +346,14 @@
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 0 0 1px var(--c-border);
|
||||
}
|
||||
|
||||
/* Lägsta is a white fill told apart by its outline, not a hue (see the
|
||||
* --c-leave-* tokens) — the outline is what makes it visible at all. */
|
||||
.year-timeline__legend-swatch--type-lagsta,
|
||||
.year-timeline__band--type-lagsta {
|
||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
||||
}
|
||||
|
||||
.year-timeline__row {
|
||||
|
||||
+164
-130
@@ -3,112 +3,144 @@
|
||||
@import './components.css';
|
||||
@import './responsive.css';
|
||||
|
||||
/* Typeface — Schibsted Grotesk (OFL), self-hosted from /public/fonts so no
|
||||
* request ever leaves for a font CDN. Two subsets with Google's own
|
||||
* unicode-ranges: the browser only fetches latin-ext when a glyph needs it.
|
||||
* Variable weight axis 400–900; `swap` so text is never invisible while the
|
||||
* file loads (index.html preloads the latin file to keep that swap short). */
|
||||
@font-face {
|
||||
font-family: 'Schibsted Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 400 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/SchibstedGrotesk-latin-ext-v7.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
|
||||
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Schibsted Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 400 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/SchibstedGrotesk-latin-v7.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Design Tokens
|
||||
*
|
||||
* One neutral ground + one accent; every other colour is semantic (danger/
|
||||
* warning/success) and exists as a solid + "-weak" tint pair. Everything
|
||||
* below is used app-wide, including the calendar and modal families that
|
||||
* used to hardcode their own hex values — see calendar.css/period-popover.css/
|
||||
* etc. Two values are load-bearing for e2e: --c-danger-weak (red day
|
||||
* background) and --c-weekend-bg (plain-Saturday background) — read
|
||||
* directly by frontend/e2e/tests/red-days.spec.ts. Do not repurpose either.
|
||||
* One neutral ground, one accent. Hierarchy comes from type and whitespace;
|
||||
* hairlines only where a boundary is unavoidable; one shadow, only on
|
||||
* floating layers. Leave types are told apart by fill / ink / outline, not
|
||||
* by hue, and day states by the numeral, not by a cell fill.
|
||||
*
|
||||
* Four names are load-bearing for e2e, which resolves them by name and
|
||||
* compares against painted elements: --c-danger-weak (red-day cell),
|
||||
* --c-weekend-bg (plain Saturday), --c-accent-hover and --c-surface
|
||||
* (header-nav-hover). Do not repurpose any of them.
|
||||
*/
|
||||
:root {
|
||||
/* Color - calm, trustworthy palette */
|
||||
--c-bg: #f7f8fa;
|
||||
--c-surface: #ffffff;
|
||||
--c-border: #e3e6eb;
|
||||
--c-text: #1c2733;
|
||||
--c-text-muted: #5b6675;
|
||||
--c-text-faint: #94a0ac;
|
||||
--c-accent: #2f6f4f; /* calm green — the app's one accent */
|
||||
--c-accent-weak: #e8f1ec;
|
||||
--c-accent-hover: #1f5a3b;
|
||||
--c-accent-active: #0f4a2f;
|
||||
--c-danger: #b3261e;
|
||||
--c-danger-weak: #fdeceb; /* also the red-day cell background — see note above */
|
||||
--c-danger-hover: #8a1a15;
|
||||
--c-success: #1e7d4f;
|
||||
--c-success-weak: #e7f4ec;
|
||||
--c-success-hover: #0d6a37;
|
||||
--c-warning: #8a6d1f;
|
||||
--c-warning-weak: #fef8e7;
|
||||
--font-sans: 'Schibsted Grotesk', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Calendar-specific semantics (klämdag/weekend/red-day borders + hover) —
|
||||
* kept distinct from the generic danger/warning pair above because a red
|
||||
* day and a klämdag are both visible on screen at once and need to read
|
||||
* as different hues, not different intensities of the same one. */
|
||||
--c-weekend-bg: #f0f0f0; /* plain Saturday — read directly by red-days.spec.ts */
|
||||
--c-weekend-border: #cfcfcf;
|
||||
--c-weekend-hover: #e8e8e8;
|
||||
--c-redday-border: #d9776e;
|
||||
--c-redday-hover: #fbdedb;
|
||||
--c-klamdag-bg: #fef8e7;
|
||||
--c-klamdag-border: #d9b23c;
|
||||
--c-klamdag-hover: #fcf0c8;
|
||||
/* The one deliberately loud color in the app — a "this just changed"
|
||||
* flash ring on a calendar band, never a static/resting state. */
|
||||
--c-flash: #ffd54f;
|
||||
/* Neutrals */
|
||||
--c-bg: #fcfcfd;
|
||||
--c-surface: #fff;
|
||||
--c-border: #ececf0;
|
||||
--c-text: #111318;
|
||||
--c-text-muted: #5e6472;
|
||||
--c-text-faint: #9297a3;
|
||||
|
||||
--overlay: rgba(16, 24, 40, 0.5);
|
||||
/* The one accent — CTA, focus, active nav, today, selection */
|
||||
--c-accent: #2f5bea;
|
||||
--c-accent-weak: #eef2fe;
|
||||
--c-accent-hover: #2549c4;
|
||||
--c-accent-active: #1d3ca3;
|
||||
|
||||
/* Translucent tints used only for subtle hover/shadow/scrim effects — kept
|
||||
* as tokens (rather than inlined rgba() elsewhere) because the style
|
||||
* guard test (styles.test.ts) forbids rgba() outside this file. */
|
||||
--c-row-hover: rgba(47, 111, 79, 0.02);
|
||||
--c-accent-tint: rgba(47, 111, 79, 0.04);
|
||||
--c-accent-shadow: rgba(47, 111, 79, 0.12);
|
||||
--c-scrim-strong: rgba(0, 0, 0, 0.4);
|
||||
--c-band-shadow: rgba(0, 0, 0, 0.1);
|
||||
--c-label-text-shadow: rgba(0, 0, 0, 0.35);
|
||||
--c-fill-light: rgba(255, 255, 255, 0.35);
|
||||
--c-badge-fill: rgba(255, 255, 255, 0.3);
|
||||
--c-scrim-soft: rgba(0, 0, 0, 0.2);
|
||||
/* Semantic */
|
||||
--c-danger: #d03a2f;
|
||||
--c-danger-weak: #fbeceb;
|
||||
--c-danger-hover: #b02f26;
|
||||
--c-warning: #b26a00;
|
||||
--c-warning-weak: #fdf3e3;
|
||||
--c-success: var(--c-accent);
|
||||
--c-success-weak: var(--c-accent-weak);
|
||||
--c-success-hover: var(--c-accent-hover);
|
||||
|
||||
/* Spacing scale (8px base) */
|
||||
/* Day states — a colour on the numeral, never a cell fill */
|
||||
--c-day-red: var(--c-danger);
|
||||
--c-day-klamdag: var(--c-warning);
|
||||
--c-day-saturday: var(--c-text-faint);
|
||||
--c-weekend-bg: var(--c-surface); /* legacy name; Saturday has no fill */
|
||||
|
||||
/* Leave types: fill / ink / outline */
|
||||
--c-leave-vacation: #eef2fe;
|
||||
--c-leave-vacation-ink: #2f5bea;
|
||||
--c-leave-sjukpenning: #2a2d35;
|
||||
--c-leave-sjukpenning-ink: #fff;
|
||||
--c-leave-lagsta: #fff;
|
||||
--c-leave-lagsta-ink: #2a2d35;
|
||||
--c-leave-lagsta-outline: #2a2d35;
|
||||
--c-leave-lagsta-hatch: rgba(42, 45, 53, 0.35);
|
||||
--c-band-gap: #f1f2f5;
|
||||
--c-band-cap-self: var(--c-accent);
|
||||
--c-band-cap-partner: #9297a3;
|
||||
|
||||
/* Interaction chrome */
|
||||
--c-flash: var(--c-accent);
|
||||
--overlay: rgba(17, 19, 24, 0.4);
|
||||
--c-focus-ring: rgba(47, 91, 234, 0.35);
|
||||
--c-row-hover: rgba(47, 91, 234, 0.04);
|
||||
|
||||
/* Spacing */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 24px;
|
||||
--sp-6: 32px;
|
||||
--sp-8: 48px;
|
||||
--sp-7: 48px;
|
||||
--sp-8: 64px;
|
||||
|
||||
/* Type scale */
|
||||
--fs-5xs: 0.5rem;
|
||||
--fs-4xs: 0.5625rem;
|
||||
--fs-2xs: 0.6875rem;
|
||||
/* Type scale 12/14/16/20/28/40 — nothing below 12px anywhere */
|
||||
--fs-xs: 0.75rem;
|
||||
--fs-sm: 0.875rem;
|
||||
--fs-base: 1rem;
|
||||
--fs-lg: 1.25rem;
|
||||
--fs-xl: 1.5rem;
|
||||
--fs-2xl: 2rem;
|
||||
--fs-xl: 1.75rem;
|
||||
--fs-2xl: 2.5rem;
|
||||
--lh: 1.5;
|
||||
--lh-tight: 1.15;
|
||||
|
||||
/* Shape & depth */
|
||||
--radius-sm: 6px;
|
||||
--radius-sm: 4px;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-full: 999px;
|
||||
/* Matches frontend/e2e/tests/calendar-band.spec.ts's literal 4px/0px
|
||||
* assertions on band corners — kept as its own token (not tied to
|
||||
* --radius-sm) so a future radius change there can't silently break it. */
|
||||
* assertions on band corners — kept as its own token so a future radius
|
||||
* change elsewhere can't silently break it. */
|
||||
--radius-band: 4px;
|
||||
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08);
|
||||
--shadow-float: 0 8px 24px rgba(17, 19, 24, 0.1);
|
||||
|
||||
/* Content width */
|
||||
/* Sizes */
|
||||
--maxw: 960px;
|
||||
|
||||
/* Interaction chrome shared by every focusable control */
|
||||
--c-focus-ring: rgba(47, 111, 79, 0.32);
|
||||
--fs-3xs: 0.625rem;
|
||||
--control-h: 40px;
|
||||
--control-h-sm: 32px;
|
||||
--icon-sm: 16px;
|
||||
--icon-md: 20px;
|
||||
--icon-lg: 24px;
|
||||
|
||||
/* Icon glyph sizes (spinner, etc.) — a separate scale from --fs-* since
|
||||
* these size a single glyph, not running text. */
|
||||
--icon-lg: 48px;
|
||||
--icon-md: 36px;
|
||||
/* Layers */
|
||||
--z-header: 100;
|
||||
--z-popover: 900;
|
||||
--z-modal: 1000;
|
||||
|
||||
/* The one inline image in the system: a 1.5px chevron for native selects.
|
||||
* Lives here (not in a feature file) so the style guard can keep `url(`
|
||||
* and hex out of everything else. Colour = --c-text-muted. */
|
||||
--icon-chevron-down: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 8"><path d="M1 1l5 5 5-5" stroke="%235e6472" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -124,8 +156,6 @@ html,
|
||||
body,
|
||||
div,
|
||||
span,
|
||||
applet,
|
||||
object,
|
||||
iframe,
|
||||
h1,
|
||||
h2,
|
||||
@@ -138,9 +168,7 @@ blockquote,
|
||||
pre,
|
||||
a,
|
||||
abbr,
|
||||
acronym,
|
||||
address,
|
||||
big,
|
||||
cite,
|
||||
code,
|
||||
del,
|
||||
@@ -153,16 +181,12 @@ q,
|
||||
s,
|
||||
samp,
|
||||
small,
|
||||
strike,
|
||||
strong,
|
||||
sub,
|
||||
sup,
|
||||
tt,
|
||||
var,
|
||||
b,
|
||||
u,
|
||||
i,
|
||||
center,
|
||||
dl,
|
||||
dt,
|
||||
dd,
|
||||
@@ -183,9 +207,7 @@ th,
|
||||
td,
|
||||
article,
|
||||
aside,
|
||||
canvas,
|
||||
details,
|
||||
embed,
|
||||
figure,
|
||||
figcaption,
|
||||
footer,
|
||||
@@ -193,49 +215,54 @@ header,
|
||||
hgroup,
|
||||
main,
|
||||
mark,
|
||||
meter,
|
||||
nav,
|
||||
output,
|
||||
progress,
|
||||
section,
|
||||
summary,
|
||||
time,
|
||||
video {
|
||||
time {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
line-height: var(--lh);
|
||||
font-size: var(--fs-base);
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* Headings — tighter leading than body text, the usual editorial cue that a
|
||||
* line is a heading rather than another sentence of the paragraph below it. */
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh);
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Headings — tighter leading and a touch of negative tracking at 28px+ is
|
||||
* what makes a line read as a heading, not a heavier weight. */
|
||||
h1 {
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--fs-lg);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: var(--sp-3);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
/* Text */
|
||||
@@ -297,10 +324,10 @@ button:disabled {
|
||||
|
||||
/* Inputs & Selects
|
||||
*
|
||||
* Matches `.field__control` in primitives.css (same border/radius/padding/
|
||||
* focus formula) so an unclassed input still looks right. Checkboxes and
|
||||
* Matches `.field__control` in primitives.css (hairline, 40px, 2px accent
|
||||
* ring on focus) so an unclassed input still looks right. Checkboxes and
|
||||
* radios are excluded — they keep their native intrinsic size and just pick
|
||||
* up the accent color, not the text-field chrome. */
|
||||
* up the accent color. */
|
||||
input:not([type='checkbox']):not([type='radio']),
|
||||
select,
|
||||
textarea {
|
||||
@@ -308,12 +335,23 @@ textarea {
|
||||
min-height: var(--control-h);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-base);
|
||||
line-height: 1.25;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
padding-right: var(--sp-6);
|
||||
background-image: var(--icon-chevron-down);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--sp-3) center;
|
||||
background-size: 12px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
@@ -321,26 +359,28 @@ select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 0 0 3px var(--c-focus-ring);
|
||||
box-shadow: 0 0 0 2px var(--c-accent);
|
||||
}
|
||||
|
||||
/* Same crisp keyboard-focus outline as `.field__control:focus-visible`
|
||||
* (primitives.css) for an unclassed input/select/textarea — see that rule's
|
||||
* comment. */
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
background-color: var(--c-bg);
|
||||
color: var(--c-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
input[type='checkbox'],
|
||||
input[type='radio'] {
|
||||
width: auto;
|
||||
@@ -348,11 +388,8 @@ input[type='radio'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Number inputs — hide the native up/down spinner everywhere. It doubles
|
||||
* the field's effective click target with no matching `.field__control`
|
||||
* affordance and its width isn't accounted for by any of this app's `ch`-
|
||||
* based sizing. Callers still get numeric-only mobile keyboards via
|
||||
* `inputmode="numeric"` on the element itself. */
|
||||
/* Number inputs — hide the native up/down spinner everywhere. Callers still
|
||||
* get numeric-only mobile keyboards via `inputmode="numeric"`. */
|
||||
input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
@@ -363,9 +400,6 @@ input[type='number']::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Date inputs — same height/padding/font as `.field__control` by default
|
||||
* (the shared `input:not([type=checkbox]):not([type=radio])` rule above
|
||||
* already covers it), just with a visibly clickable calendar affordance. */
|
||||
input[type='date']::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
@@ -379,7 +413,7 @@ label {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
/* Tables — no header fill; one hairline per row. */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -387,15 +421,15 @@ table {
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background-color: var(--c-bg);
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-sm);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
/* Design-system primitives
|
||||
*
|
||||
* Every button, card, choice-card, form field, alert, badge, eyebrow and
|
||||
* modal shell in the app is built from the classes below — defined ONCE
|
||||
* here instead of once per feature/page. See AGENTS.md's "fix the shared
|
||||
* rule once" guidance. Feature CSS files (calendar.css, header.css,
|
||||
* preset-rail.css, ...) keep only the rules that are genuinely specific to
|
||||
* that feature (e.g. the calendar day-cell grid, the QR image box).
|
||||
* Every button, card, choice-card, form field, alert, badge, eyebrow,
|
||||
* segmented control, disclosure and modal shell in the app is built from the
|
||||
* classes below — defined ONCE here instead of once per feature/page.
|
||||
* Feature CSS files keep only the rules that are genuinely specific to that
|
||||
* feature (the calendar day-cell grid, the QR image box, ...).
|
||||
*
|
||||
* Principles: whitespace and type carry hierarchy; a hairline only where a
|
||||
* boundary is unavoidable; one shadow (--shadow-float) and only on floating
|
||||
* layers; no gradients, no uppercase, nothing below 12px.
|
||||
*/
|
||||
|
||||
/* Buttons
|
||||
* ------------------------------------------------------------------------
|
||||
* The bare `button` element (see global.css) is a plain reset — no color,
|
||||
* no chrome. Every visible button in the app opts into one of these
|
||||
* variants explicitly, so a semantic element that happens to be a
|
||||
* `<button>` (a card, a chip, an icon) never inherits button colors it
|
||||
* didn't ask for.
|
||||
*/
|
||||
* primary = accent fill · secondary = hairline outline · ghost = text only.
|
||||
* 40px tall, 32px for --sm, radius 8. */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
padding: 0 var(--sp-4);
|
||||
min-height: var(--control-h);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--fs-sm);
|
||||
@@ -29,7 +29,7 @@
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -56,14 +56,18 @@
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background-color: var(--c-surface);
|
||||
background-color: transparent;
|
||||
border-color: var(--c-border);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
border-color: var(--c-text-faint);
|
||||
background-color: var(--c-surface);
|
||||
}
|
||||
|
||||
.btn--secondary:active:not(:disabled) {
|
||||
background-color: var(--c-accent-weak);
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@@ -92,6 +96,7 @@
|
||||
border: 0;
|
||||
color: var(--c-text-muted);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn--link:hover:not(:disabled) {
|
||||
@@ -99,9 +104,32 @@
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
min-height: 32px;
|
||||
font-size: var(--fs-xs);
|
||||
padding: 0 var(--sp-3);
|
||||
min-height: var(--control-h-sm);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
/* Chip — a small, pill-shaped toggle/filter. Pressed state = accent tint. */
|
||||
.btn--chip {
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-3);
|
||||
border-radius: var(--radius-full);
|
||||
border-color: var(--c-border);
|
||||
background-color: transparent;
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.btn--chip:hover:not(:disabled) {
|
||||
color: var(--c-text);
|
||||
border-color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.btn--chip[aria-pressed='true'],
|
||||
.btn--chip[aria-checked='true'] {
|
||||
background-color: var(--c-accent-weak);
|
||||
border-color: transparent;
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.btn--icon {
|
||||
@@ -118,10 +146,14 @@
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.btn--icon.btn--sm {
|
||||
width: var(--control-h-sm);
|
||||
height: var(--control-h-sm);
|
||||
}
|
||||
|
||||
.btn--block {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Pins a button (typically a destructive one) to the start of a
|
||||
@@ -131,17 +163,19 @@
|
||||
}
|
||||
|
||||
/* Cards
|
||||
* ------------------------------------------------------------------------ */
|
||||
* ------------------------------------------------------------------------
|
||||
* A surface with a hairline — used for the front door (auth), the
|
||||
* onboarding choices and nothing that already sits inside another card. */
|
||||
.card {
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
|
||||
.card--tinted {
|
||||
background-color: var(--c-accent-weak);
|
||||
border-color: var(--c-accent-weak);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.card--dashed {
|
||||
@@ -149,22 +183,22 @@
|
||||
}
|
||||
|
||||
/* Choice card — a card that is also a clickable, single-select choice (the
|
||||
* onboarding solo/couple picker). Built on the same chrome as `.card`. */
|
||||
* onboarding solo/couple picker). */
|
||||
.choice-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-5);
|
||||
text-align: left;
|
||||
color: var(--c-text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
|
||||
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.choice-card:hover {
|
||||
border-color: var(--c-accent);
|
||||
border-color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.choice-card:focus-visible {
|
||||
@@ -191,7 +225,8 @@
|
||||
}
|
||||
|
||||
/* Form fields
|
||||
* ------------------------------------------------------------------------ */
|
||||
* ------------------------------------------------------------------------
|
||||
* Hairline, 40px, focus = 2px accent ring. */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -209,33 +244,37 @@
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--c-surface);
|
||||
font-size: var(--fs-base);
|
||||
line-height: 1.25;
|
||||
color: var(--c-text);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.field__control:hover:not(:disabled):not(:focus) {
|
||||
border-color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.field__control:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 0 0 3px var(--c-focus-ring);
|
||||
box-shadow: 0 0 0 2px var(--c-accent);
|
||||
}
|
||||
|
||||
/* Keyboard focus gets a crisp solid outline on top of the soft mouse-focus
|
||||
* box-shadow above — matching `.btn:focus-visible`/`.choice-card:focus-visible`
|
||||
* elsewhere in this file. The box-shadow ring alone reads as faint against
|
||||
* the light surface/border palette; text inputs need the same visible ring
|
||||
* every other focusable control gets. */
|
||||
.field__control:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field__control[aria-invalid='true'] {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.field__control[aria-invalid='true']:focus {
|
||||
border-color: var(--c-danger);
|
||||
box-shadow: 0 0 0 2px var(--c-danger);
|
||||
}
|
||||
|
||||
.field__helper {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-muted);
|
||||
@@ -253,35 +292,32 @@
|
||||
}
|
||||
|
||||
/* Alerts
|
||||
* ------------------------------------------------------------------------ */
|
||||
* ------------------------------------------------------------------------
|
||||
* Tint + coloured text; no border — the tint is the boundary. */
|
||||
.alert {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background-color: var(--c-danger-weak);
|
||||
border-color: var(--c-danger);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.alert--success {
|
||||
background-color: var(--c-success-weak);
|
||||
border-color: var(--c-success);
|
||||
color: var(--c-success);
|
||||
}
|
||||
|
||||
.alert--warning {
|
||||
background-color: var(--c-warning-weak);
|
||||
border-color: var(--c-warning);
|
||||
color: var(--c-warning);
|
||||
}
|
||||
|
||||
.alert--info {
|
||||
background-color: var(--c-accent-weak);
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
@@ -295,6 +331,7 @@
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge--accent {
|
||||
@@ -318,25 +355,121 @@
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* A small, muted, sentence-case label above a heading or a block — never
|
||||
* uppercase; the size and colour alone set it apart from body text. */
|
||||
.eyebrow {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
color: var(--c-text-muted);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Segmented control
|
||||
* ------------------------------------------------------------------------
|
||||
* A row of mutually exclusive options (SV | EN, week/month, ...). The
|
||||
* pressed option carries the accent tint; the others are plain text. */
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.segmented__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
color: var(--c-text-muted);
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.segmented__option:hover:not(:disabled) {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.segmented__option[aria-pressed='true'],
|
||||
.segmented__option[aria-checked='true'] {
|
||||
background-color: var(--c-accent-weak);
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.segmented__option:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Disclosure
|
||||
* ------------------------------------------------------------------------
|
||||
* A `<details>` whose summary reads as a row you can open — a hairline
|
||||
* above it, not a bordered box. */
|
||||
.disclosure {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.disclosure:last-child {
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.disclosure__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
min-height: var(--control-h);
|
||||
padding: var(--sp-3) 0;
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.disclosure__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.disclosure__summary::after {
|
||||
content: '';
|
||||
width: var(--icon-sm);
|
||||
height: var(--icon-sm);
|
||||
flex-shrink: 0;
|
||||
background-image: var(--icon-chevron-down);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 12px 8px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.disclosure[open] > .disclosure__summary::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.disclosure__summary:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.disclosure__body {
|
||||
padding: 0 0 var(--sp-5);
|
||||
}
|
||||
|
||||
/* Modal shell
|
||||
* ------------------------------------------------------------------------
|
||||
* Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and
|
||||
* QRCodeModal so every modal's overlay/header/body/footer share one
|
||||
* formula. Each modal's own CSS file keeps only its content-specific rules
|
||||
* (fields, QR image box, status text, ...).
|
||||
*/
|
||||
* QRCodeModal. The one floating layer in the app, so the one place
|
||||
* --shadow-float is used. Header and footer have no rules of their own;
|
||||
* the footer is transparent — spacing does the separating. */
|
||||
.modal__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -351,8 +484,8 @@
|
||||
max-height: 85vh;
|
||||
overflow: hidden;
|
||||
background-color: var(--c-surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.modal__content--md {
|
||||
@@ -371,20 +504,20 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-5) var(--sp-5) 0;
|
||||
}
|
||||
|
||||
.modal__header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5);
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-5);
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
@@ -393,9 +526,8 @@
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-top: 1px solid var(--c-border);
|
||||
background-color: var(--c-bg);
|
||||
padding: 0 var(--sp-5) var(--sp-5);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Inline delete-confirmation row: swaps a footer's normal button set for a
|
||||
@@ -417,6 +549,44 @@
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Text utilities
|
||||
* ------------------------------------------------------------------------ */
|
||||
/* Tabular figures for any count that sits in a column or changes in place. */
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Screen-reader-only text. */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Skeleton — a quiet placeholder block while a page's data loads. */
|
||||
.skeleton {
|
||||
display: block;
|
||||
background-color: var(--c-border);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout helpers
|
||||
* ------------------------------------------------------------------------ */
|
||||
.stack {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* S7 - Responsive & Accessibility
|
||||
/* Responsive & Accessibility
|
||||
*
|
||||
* The ONLY file in the app allowed to contain @media (enforced by
|
||||
* styles.test.ts) — every viewport/motion/contrast override lives here so
|
||||
@@ -7,21 +7,15 @@
|
||||
|
||||
/* Mobile - Small screens (max 640px) */
|
||||
@media (max-width: 640px) {
|
||||
/* Sticky header with less padding */
|
||||
.header {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
/* Header — the same four groups (brand / nav / identity / tools) reorder
|
||||
* into three rows: brand + language + logout, the nav as a full-width
|
||||
* row, then the child select full-width below. 16px gutters. */
|
||||
.header__bar {
|
||||
row-gap: var(--sp-3);
|
||||
padding: 0 var(--sp-4);
|
||||
row-gap: var(--sp-2);
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
/* #5 — reorders the same four groups (brand/nav/identity/logout — no
|
||||
* markup change, just `order`) into: brand + logout on row 1, the nav as
|
||||
* a full-width row of three equal buttons, then the child select
|
||||
* full-width below. The parent name is dropped at this width — the child
|
||||
* select alone already identifies "whose plan this is" without a second,
|
||||
* name-only line competing for the same row. */
|
||||
.header__brand {
|
||||
order: 1;
|
||||
flex: 1 1 auto;
|
||||
@@ -32,7 +26,7 @@
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.header__logout {
|
||||
.header__tools {
|
||||
order: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -42,7 +36,7 @@
|
||||
flex: 0 0 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-2);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.header__nav-link {
|
||||
@@ -59,6 +53,7 @@
|
||||
.header__child-select {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.header__parent-name {
|
||||
@@ -66,11 +61,9 @@
|
||||
}
|
||||
|
||||
/* Main content — the container picks up the horizontal gutter; sections
|
||||
* themselves stay at zero horizontal padding at every width (see
|
||||
* layout.css's `.dashboard__section` etc. base rule) so this must only
|
||||
* ever add top spacing/margin here, never re-introduce side padding. */
|
||||
[data-testid='layout-main'] {
|
||||
padding: var(--sp-4);
|
||||
* themselves stay at zero horizontal padding at every width. */
|
||||
.layout__main {
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
}
|
||||
|
||||
.dashboard__section,
|
||||
@@ -143,8 +136,7 @@
|
||||
}
|
||||
|
||||
/* Delete-confirmation row: the warning text gets its own line, and the
|
||||
* two buttons split the row evenly (still equal height, still no
|
||||
* mid-phrase wrapping) rather than stacking into three separate rows. */
|
||||
* two buttons split the row evenly. */
|
||||
.modal__delete-confirm-text {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
@@ -168,10 +160,6 @@
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading-spinner__icon {
|
||||
font-size: var(--icon-md);
|
||||
}
|
||||
|
||||
/* Calendar responsiveness */
|
||||
.calendar__grid {
|
||||
font-size: var(--fs-sm);
|
||||
@@ -186,11 +174,9 @@
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
/* #4 — at 390px the month/year selects (each ~12ch/7ch min-width) plus the
|
||||
* two prev/next icon buttons are tight against the header's title font
|
||||
* size; dropping the select text down one step keeps the whole header row
|
||||
* comfortably inside the viewport (it still wraps cleanly via
|
||||
* `.calendar__title`'s `flex-wrap` if a locale's month name is wider). */
|
||||
/* At 390px the month/year selects plus the two prev/next icon buttons are
|
||||
* tight against the header's title size; one step down keeps the whole
|
||||
* row inside the viewport. */
|
||||
.calendar__month-select,
|
||||
.calendar__year-select {
|
||||
font-size: var(--fs-lg);
|
||||
@@ -198,15 +184,10 @@
|
||||
|
||||
/* The fixed-width preset rail sits beside the month grid on desktop; on
|
||||
* mobile the layout stacks and the rail goes full-width below the grid.
|
||||
* `align-items: stretch` overrides the base rule's `flex-start` (there to
|
||||
* top-align the rail next to the grid in the row layout) — without it, a
|
||||
* column-direction flex item is sized by its own shrink-to-fit content
|
||||
* width instead of stretching to the container's width. `.calendar__main`
|
||||
* (a CSS Grid with a fixed 50px week-number column) then sized itself off
|
||||
* its grid tracks' max-content width — well past the viewport — instead
|
||||
* of the actual space available, which is exactly what caused the
|
||||
* mobile horizontal-scroll bug once a band's content gave those tracks
|
||||
* something to size around. */
|
||||
* `align-items: stretch` overrides the base rule's `flex-start` — without
|
||||
* it a column-direction flex item is sized by its own shrink-to-fit
|
||||
* content width, which is what once let `.calendar__main` grow past the
|
||||
* viewport. */
|
||||
.calendar__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -217,9 +198,7 @@
|
||||
}
|
||||
|
||||
/* A horizontally-scrollable row of presets, not a stack — keeps the rail
|
||||
* from pushing the month grid far down the page on a short viewport,
|
||||
* without ever growing the page itself wider than the viewport (the
|
||||
* scrolling is contained to this row, see `overflow-x` below). */
|
||||
* from pushing the month grid far down the page on a short viewport. */
|
||||
.preset-rail__buttons {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
@@ -233,11 +212,7 @@
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
/* Footer — same side gutter as [data-testid='layout-main'] at this width. */
|
||||
footer {
|
||||
padding: var(--sp-4) 0;
|
||||
}
|
||||
|
||||
/* Footer — same side gutter as .layout__main at this width. */
|
||||
.footer__inner {
|
||||
padding: 0 var(--sp-4);
|
||||
}
|
||||
@@ -253,8 +228,8 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
[data-testid='layout-main'] {
|
||||
padding: var(--sp-4);
|
||||
.layout__main {
|
||||
padding: var(--sp-6) var(--sp-5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,12 +249,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode */
|
||||
/* High Contrast Mode — the darkest step of the accent, pure black text and
|
||||
* a visibly darker hairline. */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--c-text: #000;
|
||||
--c-accent: #1f4d2b;
|
||||
--c-border: #333;
|
||||
--c-text-muted: #333;
|
||||
--c-accent: #1d3ca3;
|
||||
--c-border: #555;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
@@ -291,11 +268,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Color Scheme Preference - Dark Mode (future-proofing) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Currently not implemented, but prepared */
|
||||
}
|
||||
|
||||
/* Keyboard Navigation */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
|
||||
@@ -114,4 +114,43 @@ describe('style guard', () => {
|
||||
}
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
// The type scale bottoms out at 12px (--fs-xs: .75rem). A smaller step
|
||||
// used to exist for badges and band labels and made the calendar
|
||||
// unreadable; nothing may reintroduce one.
|
||||
it('defines no --fs-* token below .75rem (12px)', () => {
|
||||
const content = fs.readFileSync(GLOBAL_CSS, 'utf-8')
|
||||
const tooSmall: string[] = []
|
||||
for (const match of content.matchAll(/(--fs-[\w-]+):\s*([\d.]+)rem/g)) {
|
||||
const [, name, value] = match
|
||||
if (name !== undefined && value !== undefined && Number(value) < 0.75) {
|
||||
tooSmall.push(`${name}: ${value}rem`)
|
||||
}
|
||||
}
|
||||
expect(tooSmall).toEqual([])
|
||||
// ...and feature CSS may only ever size text via the scale.
|
||||
const offenders: string[] = []
|
||||
for (const file of cssFiles) {
|
||||
const fileContent = fs.readFileSync(file, 'utf-8')
|
||||
if (/--fs-(2xs|3xs|4xs|5xs)\b/.test(fileContent)) {
|
||||
offenders.push(path.relative(SRC_ROOT, file))
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
// Fonts and the one inline chevron are the only external/inline
|
||||
// resources, and both are declared once in global.css (as @font-face and
|
||||
// the --icon-chevron-down token). Feature CSS references the token.
|
||||
it('keeps url() out of feature CSS — fonts and inline images live in global.css', () => {
|
||||
const offenders: string[] = []
|
||||
for (const file of cssFiles) {
|
||||
if (file === GLOBAL_CSS) continue
|
||||
const content = fs.readFileSync(file, 'utf-8')
|
||||
if (content.includes('url(')) {
|
||||
offenders.push(path.relative(SRC_ROOT, file))
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getColorForType, getLabelForType } from './leaveTypes'
|
||||
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from './leaveTypes'
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
describe('getColorForType', () => {
|
||||
it('returns the vacation color', () => {
|
||||
expect(getColorForType('vacation')).toBe('#4CAF50')
|
||||
it('returns the vacation fill token', () => {
|
||||
expect(getColorForType('vacation')).toBe('var(--c-leave-vacation)')
|
||||
})
|
||||
|
||||
it('returns the sjukpenning color', () => {
|
||||
expect(getColorForType('sjukpenning')).toBe('#FF9800')
|
||||
it('returns the sjukpenning fill token', () => {
|
||||
expect(getColorForType('sjukpenning')).toBe('var(--c-leave-sjukpenning)')
|
||||
})
|
||||
|
||||
it('returns the lagsta color', () => {
|
||||
expect(getColorForType('lagsta')).toBe('#2196F3')
|
||||
it('returns the lagsta fill token', () => {
|
||||
expect(getColorForType('lagsta')).toBe('var(--c-leave-lagsta)')
|
||||
})
|
||||
|
||||
it('falls back to grey for an unknown type', () => {
|
||||
expect(getColorForType('something-else')).toBe('#9E9E9E')
|
||||
it('falls back to the faint text colour for an unknown type', () => {
|
||||
expect(getColorForType('something-else')).toBe('var(--c-text-faint)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInkForType', () => {
|
||||
it('returns the matching ink token for each known type', () => {
|
||||
expect(getInkForType('vacation')).toBe('var(--c-leave-vacation-ink)')
|
||||
expect(getInkForType('sjukpenning')).toBe('var(--c-leave-sjukpenning-ink)')
|
||||
expect(getInkForType('lagsta')).toBe('var(--c-leave-lagsta-ink)')
|
||||
})
|
||||
|
||||
it('falls back to surface ink for an unknown type', () => {
|
||||
expect(getInkForType('something-else')).toBe('var(--c-surface)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeModifier', () => {
|
||||
it('returns a type-* modifier suffix for each known type', () => {
|
||||
expect(getTypeModifier('vacation')).toBe('type-vacation')
|
||||
expect(getTypeModifier('sjukpenning')).toBe('type-sjukpenning')
|
||||
expect(getTypeModifier('lagsta')).toBe('type-lagsta')
|
||||
})
|
||||
|
||||
it('returns type-unknown for an unknown type', () => {
|
||||
expect(getTypeModifier('something-else')).toBe('type-unknown')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import type { TranslationKey } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Leave types are told apart by fill / ink / outline, not by hue — the
|
||||
* values live as `--c-leave-*` tokens in `styles/global.css` and these
|
||||
* helpers hand back `var(...)` references so a palette change never needs a
|
||||
* code change. jsdom preserves `var(...)` strings on inline styles, so tests
|
||||
* can still assert on them.
|
||||
*/
|
||||
|
||||
const KNOWN_TYPES = ['vacation', 'sjukpenning', 'lagsta'] as const
|
||||
type KnownType = (typeof KNOWN_TYPES)[number]
|
||||
|
||||
function isKnownType(type: string): type is KnownType {
|
||||
return (KNOWN_TYPES as readonly string[]).includes(type)
|
||||
}
|
||||
|
||||
/** Background fill for a leave type (band, timeline bar, legend swatch). */
|
||||
export function getColorForType(type: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
vacation: '#4CAF50',
|
||||
sjukpenning: '#FF9800',
|
||||
lagsta: '#2196F3',
|
||||
}
|
||||
return colorMap[type] ?? '#9E9E9E'
|
||||
return isKnownType(type) ? `var(--c-leave-${type})` : 'var(--c-text-faint)'
|
||||
}
|
||||
|
||||
/** Text colour that reads on top of `getColorForType(type)`. */
|
||||
export function getInkForType(type: string): string {
|
||||
return isKnownType(type) ? `var(--c-leave-${type}-ink)` : 'var(--c-surface)'
|
||||
}
|
||||
|
||||
/**
|
||||
* BEM modifier suffix for a leave type, for callers that need a class hook
|
||||
* (lägsta's outline/hatch can't be expressed as a single inline colour):
|
||||
* `` `calendar-band--${getTypeModifier(type)}` ``.
|
||||
*/
|
||||
export function getTypeModifier(type: string): string {
|
||||
return isKnownType(type) ? `type-${type}` : 'type-unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user