Settle on one visual language

The interface had drifted into two of everything. The calendar and its
popovers were built around a blue accent the rest of the app had long since
replaced with green, five different sizes served the same class of small
label, and page sections were a stack of identical bordered cards that gave
no sense of what mattered most. Sections now separate by space and a hairline,
the overview leads with its headline figure at a size that says so, and the
palette, type scale and spacing live in one place as variables rather than
scattered literals.

Two things were not styled at all. The sign-in screen was an unstyled form in
the top-left corner, and the tip cards had no rules whatsoever despite their
markup already carrying a severity, so every tip rendered identically with a
plain green square for its dismiss control.

The calendar was also unusable on a narrow screen: the preset rail never
collapsed and covered the last three columns of the grid.
This commit is contained in:
2026-09-18 01:39:34 +02:00
parent f2b3920cbc
commit 296b596778
15 changed files with 757 additions and 433 deletions
+37 -29
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { useWorkspace } from '@/context/WorkspaceContext'
import { useTranslation } from '@/i18n'
import './auth.css'
export function Auth() {
const { login, register, isAuthenticated } = useAuth()
@@ -38,38 +39,45 @@ export function Auth() {
}
return (
<main>
<h1>{isRegister ? t('auth.createAccountTitle') : t('auth.loginTitle')}</h1>
<form onSubmit={(e) => void handleSubmit(e)}>
{isRegister && (
<main className="auth-page">
<div className="auth-page__card">
<p className="auth-page__brand">{t('app.name')}</p>
<h1 className="auth-page__title">{isRegister ? t('auth.createAccountTitle') : t('auth.loginTitle')}</h1>
<form className="auth-page__form" onSubmit={(e) => void handleSubmit(e)}>
{isRegister && (
<input
placeholder={t('auth.namePlaceholder')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
)}
<input
placeholder={t('auth.namePlaceholder')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
type="email"
data-testid="auth-email"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
)}
<input
type="email"
data-testid="auth-email"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
data-testid="auth-password"
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit" data-testid="auth-submit">
{isRegister ? t('auth.registerButton') : t('auth.loginButton')}
<input
type="password"
data-testid="auth-password"
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && (
<p role="alert" className="auth-page__error">
{error}
</p>
)}
<button type="submit" data-testid="auth-submit">
{isRegister ? t('auth.registerButton') : t('auth.loginButton')}
</button>
</form>
<button type="button" className="auth-page__switch button--ghost" onClick={() => setIsRegister((v) => !v)}>
{isRegister ? t('auth.haveAccountButton') : t('auth.createAccountButton')}
</button>
</form>
<button type="button" onClick={() => setIsRegister((v) => !v)}>
{isRegister ? t('auth.haveAccountButton') : t('auth.createAccountButton')}
</button>
</div>
</main>
)
}
+11 -17
View File
@@ -229,23 +229,17 @@ export function Dashboard(): ReactNode {
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
<section className="dashboard__section">
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleFetchFK}
disabled={!selectedChildId}
style={{ padding: '0.5rem 1rem', cursor: selectedChildId ? 'pointer' : 'not-allowed' }}
>
{t('dashboard.fetchFromFk')}
</button>
<button
onClick={() => setShowManualFetchModal(true)}
disabled={!selectedChildId}
style={{ padding: '0.5rem 1rem', cursor: selectedChildId ? 'pointer' : 'not-allowed' }}
>
{t('dashboard.enterManually')}
</button>
</div>
<section className="dashboard__section dashboard__fetch-actions">
<button onClick={handleFetchFK} disabled={!selectedChildId}>
{t('dashboard.fetchFromFk')}
</button>
<button
onClick={() => setShowManualFetchModal(true)}
disabled={!selectedChildId}
className="button--secondary"
>
{t('dashboard.enterManually')}
</button>
</section>
{selectedChild && (
+56
View File
@@ -0,0 +1,56 @@
/* Login/register — the app's front door, rendered outside <Layout> (no
* header yet, there's no session). Previously a bare, unstyled <main> that
* relied entirely on the global element resets; this gives it the same
* centered-card treatment as the onboarding wizard it leads into. */
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-5);
}
.auth-page__card {
width: 100%;
max-width: 380px;
}
.auth-page__brand {
margin: 0 0 var(--sp-5) 0;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--c-text-muted);
text-align: center;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.auth-page__title {
text-align: center;
margin-bottom: var(--sp-5);
}
.auth-page__form {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.auth-page__form button[type='submit'] {
margin-top: var(--sp-2);
}
.auth-page__error {
margin: 0;
padding: var(--sp-3) var(--sp-4);
background-color: var(--c-danger-weak);
color: var(--c-danger);
border-left: 4px solid var(--c-danger);
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
}
.auth-page__switch {
width: 100%;
}
+137 -140
View File
@@ -1,58 +1,53 @@
.calendar {
max-width: 920px;
margin: 0 auto;
padding: 16px;
}
.calendar__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
margin-bottom: var(--sp-5);
}
.calendar__header button {
min-width: 36px;
min-height: 36px;
padding: 8px 12px;
padding: var(--sp-2) var(--sp-3);
background: none;
color: var(--c-text);
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 18px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
font-size: var(--fs-lg);
cursor: pointer;
transition: background-color 0.2s;
transition: background-color 0.2s, border-color 0.2s;
}
.calendar__header button:hover {
background-color: #f0f0f0;
background-color: var(--c-bg);
border-color: var(--c-text-muted);
}
.calendar__title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #333;
font-size: var(--fs-xl);
font-weight: 700;
color: var(--c-text);
display: flex;
gap: 8px;
gap: var(--sp-2);
align-items: center;
justify-content: center;
}
.calendar__month-select,
.calendar__year-select {
background: #fff;
border: 1px solid #d0d0d0;
color: #333;
font-size: 20px;
font-weight: 600;
background: var(--c-surface);
border: 1px solid var(--c-border);
color: var(--c-text);
font-size: var(--fs-xl);
font-weight: 700;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-sm);
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
appearance: none;
padding-right: 28px;
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="%23333" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>');
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-repeat: no-repeat;
background-position: right 6px center;
background-size: 12px;
@@ -60,28 +55,28 @@
.calendar__month-select:hover,
.calendar__year-select:hover {
background-color: #f0f0f0;
border-color: #0066cc;
color: #0066cc;
background-color: var(--c-bg);
border-color: var(--c-accent);
color: var(--c-accent);
}
.calendar__month-select:focus-visible,
.calendar__year-select:focus-visible {
outline: 2px solid #0066cc;
outline: 2px solid var(--c-accent);
outline-offset: 2px;
border-color: #0066cc;
border-color: var(--c-accent);
}
.calendar__month-select:active,
.calendar__year-select:active {
background-color: #e0e0e0;
background-color: var(--c-border);
}
.calendar__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-bottom: 8px;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
/* The weeks themselves now live in a separate flex column below the header
@@ -93,114 +88,114 @@
.calendar__weeks {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px;
gap: var(--sp-2);
margin-bottom: var(--sp-5);
}
.calendar__weekday {
padding: 12px 8px;
padding: var(--sp-3) var(--sp-2);
text-align: center;
font-weight: 600;
font-size: 13px;
color: #666;
font-size: var(--fs-xs);
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.04em;
}
.calendar-day {
min-height: 84px;
padding: 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background-color: #fff;
padding: var(--sp-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background-color: var(--c-surface);
cursor: pointer;
display: flex;
flex-direction: column;
transition: all 0.2s;
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
position: relative;
user-select: none;
-webkit-user-select: none; /* Safari */
}
.calendar-day:hover {
border-color: #0066cc;
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.1);
background-color: #f9fbff;
border-color: var(--c-accent);
box-shadow: 0 2px 8px rgba(47, 111, 79, 0.12);
background-color: var(--c-accent-weak);
}
.calendar-day:focus-visible {
outline: 2px solid #0066cc;
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.calendar-day--disabled {
background-color: #fafafa;
color: #999;
background-color: var(--c-bg);
color: var(--c-text-faint);
cursor: default;
}
.calendar-day--disabled:hover {
border-color: #e0e0e0;
border-color: var(--c-border);
box-shadow: none;
background-color: #fafafa;
background-color: var(--c-bg);
}
.calendar-day--has-events {
background-color: #fafbff;
background-color: rgba(47, 111, 79, 0.04);
}
.calendar-day__number {
font-weight: 600;
font-size: 14px;
color: #333;
margin-bottom: 4px;
font-size: var(--fs-sm);
color: var(--c-text);
margin-bottom: var(--sp-1);
}
.calendar-day--disabled .calendar-day__number {
color: #bbb;
color: var(--c-text-faint);
}
.calendar-day--today .calendar-day__number {
background-color: #0066cc;
color: #fff;
background-color: var(--c-accent);
color: var(--c-surface);
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: var(--fs-xs);
}
.calendar-day--selected {
background-color: #f9fbff;
border-color: #0066cc;
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.1);
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
box-shadow: 0 2px 8px rgba(47, 111, 79, 0.18);
}
.calendar-day--selected:hover {
background-color: #f0f8ff;
background-color: var(--c-accent-weak);
}
/* §3.4 — statutory red days / klämdagar shading. Color is never the only
signal: each also gets a text badge (see .calendar-day__type-badge) and a
title/aria-label carrying the holiday name. */
.calendar-day--red-day {
background-color: #fdeceb;
border-color: #d9776e;
background-color: var(--c-danger-weak);
border-color: var(--c-redday-border);
}
.calendar-day--red-day:hover {
background-color: #fbdedb;
background-color: var(--c-redday-hover);
}
.calendar-day--klamdag {
background-color: #fef8e7;
border-color: #d9b23c;
background-color: var(--c-klamdag-bg);
border-color: var(--c-klamdag-border);
}
.calendar-day--klamdag:hover {
background-color: #fcf0c8;
background-color: var(--c-klamdag-hover);
}
/* Plain Saturday — neutral grey, deliberately NOT red. Matches the Swedish
@@ -208,17 +203,17 @@
an ordinary rest day. See CalendarDay.tsx for the weekday-based split
(the backend collapses both into a single "weekend" day_type). */
.calendar-day--weekend {
background-color: #f0f0f0;
border-color: #cfcfcf;
background-color: var(--c-weekend-bg);
border-color: var(--c-weekend-border);
}
.calendar-day--weekend:hover {
background-color: #e8e8e8;
background-color: var(--c-weekend-hover);
}
.calendar-day__holiday-name {
font-size: 10px;
color: #a8453d;
font-size: var(--fs-2xs);
color: var(--c-danger);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -240,7 +235,7 @@
font-size: 9px;
font-weight: 700;
line-height: 1;
color: #fff;
color: var(--c-surface);
background-color: rgba(0, 0, 0, 0.4);
border-radius: 3px;
padding: 1px 3px;
@@ -277,7 +272,7 @@
.calendar-week {
display: grid;
grid-template-columns: repeat(7, 1fr);
column-gap: 8px;
column-gap: var(--sp-2);
row-gap: 3px;
}
@@ -292,7 +287,10 @@
/* One continuous band segment for this week row. Square by default —
.calendar-band--start/--end round only the true first/last row of the
whole (possibly multi-week) band, so a period crossing a week boundary
reads as one connected object, not a chain of separate chips. */
reads as one connected object, not a chain of separate chips.
Border radius here is intentionally NOT --radius-sm: it's asserted
literally (4px/0px) by frontend/e2e/tests/calendar-band.spec.ts, so it
has its own --radius-band token instead of following the shared scale. */
.calendar-band {
position: relative;
display: flex;
@@ -305,17 +303,17 @@
}
.calendar-band--start {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
border-top-left-radius: var(--radius-band);
border-bottom-left-radius: var(--radius-band);
}
.calendar-band--end {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
border-top-right-radius: var(--radius-band);
border-bottom-right-radius: var(--radius-band);
}
.calendar-band:focus-visible {
outline: 2px solid #0066cc;
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
@@ -330,7 +328,7 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
30%, 70% {
box-shadow: 0 0 0 3px #ffd54f, 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: 0 0 0 3px var(--c-flash), 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
@@ -340,8 +338,8 @@
display: flex;
align-items: center;
padding: 0 6px;
font-size: 11px;
color: #fff;
font-size: var(--fs-2xs);
color: var(--c-surface);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 1;
@@ -393,7 +391,7 @@
}
.calendar-event__label {
font-size: 12px;
font-size: var(--fs-2xs);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
@@ -416,31 +414,30 @@
}
.calendar__legend {
padding: 16px;
background-color: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: var(--sp-4);
background-color: var(--c-bg);
border-radius: var(--radius-sm);
}
.calendar__legend h3 {
margin: 0 0 12px 0;
font-size: 14px;
margin: 0 0 var(--sp-3) 0;
font-size: var(--fs-sm);
font-weight: 600;
color: #333;
color: var(--c-text);
}
.calendar__legend-items {
display: flex;
gap: 24px;
gap: var(--sp-5);
flex-wrap: wrap;
}
.calendar__legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #666;
gap: var(--sp-2);
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.calendar__legend-swatch {
@@ -449,32 +446,32 @@
justify-content: center;
width: 16px;
height: 16px;
border-radius: 2px;
border-radius: 3px;
flex-shrink: 0;
font-size: 11px;
font-weight: bold;
color: #fff;
color: var(--c-surface);
}
.calendar__legend-swatch--dubbeldag {
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid #999;
color: #333;
border: 1px solid var(--c-text-faint);
color: var(--c-text);
}
.calendar__legend-swatch--red-day {
background-color: #fdeceb;
border: 1px solid #d9776e;
background-color: var(--c-danger-weak);
border: 1px solid var(--c-redday-border);
}
.calendar__legend-swatch--klamdag {
background-color: #fef8e7;
border: 1px solid #d9b23c;
background-color: var(--c-klamdag-bg);
border: 1px solid var(--c-klamdag-border);
}
.calendar__legend-swatch--override {
background-color: #fff;
border: 2px dashed #666;
background-color: var(--c-surface);
border: 2px dashed var(--c-text-muted);
}
/* §Task7 — the calendar becomes the plan: a side rail of presets next to
@@ -484,7 +481,7 @@
popover (see PlannedDayModal's "Den här dagen är" switch). */
.calendar__layout {
display: flex;
gap: 20px;
gap: var(--sp-5);
align-items: flex-start;
}
@@ -494,22 +491,22 @@
}
.calendar__sync-indicator {
font-size: 12px;
color: #666;
margin: 0 0 12px 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin: 0 0 var(--sp-3) 0;
}
.calendar__toast {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
margin-bottom: 16px;
background-color: #eef6ff;
border: 1px solid #b8d9ff;
border-radius: 4px;
font-size: 13px;
color: #003a70;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
margin-bottom: var(--sp-4);
background-color: var(--c-accent-weak);
border: 1px solid var(--c-accent);
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
color: var(--c-text);
}
.calendar__toast-message {
@@ -517,19 +514,19 @@
}
.calendar__toast-undo {
padding: 4px 10px;
border: 1px solid #0066cc;
border-radius: 4px;
background: #fff;
color: #0066cc;
padding: var(--sp-1) var(--sp-3);
border: 1px solid var(--c-accent);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-accent);
cursor: pointer;
font-size: 12px;
font-size: var(--fs-xs);
font-weight: 600;
}
.calendar__toast-undo:hover:not(:disabled) {
background-color: #0066cc;
color: #fff;
background-color: var(--c-accent);
color: var(--c-surface);
}
.calendar__toast-undo:disabled {
@@ -540,10 +537,10 @@
.calendar__toast-dismiss {
background: none;
border: none;
font-size: 18px;
font-size: var(--fs-lg);
line-height: 1;
cursor: pointer;
color: #003a70;
color: var(--c-text-muted);
padding: 0 2px;
}
@@ -555,12 +552,12 @@
display: flex;
align-items: center;
justify-content: center;
padding: 12px 8px;
padding: var(--sp-3) var(--sp-2);
font-weight: 600;
font-size: 12px;
color: #999;
background-color: #fafafa;
border-right: 1px solid #d0d0d0;
font-size: var(--fs-xs);
color: var(--c-text-faint);
background-color: var(--c-bg);
border-right: 1px solid var(--c-border);
pointer-events: none;
user-select: none;
}
@@ -569,12 +566,12 @@
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
padding: var(--sp-2);
font-weight: 600;
font-size: 11px;
color: #999;
border-right: 1px solid #d0d0d0;
background-color: #fafafa;
font-size: var(--fs-2xs);
color: var(--c-text-faint);
border-right: 1px solid var(--c-border);
background-color: var(--c-bg);
pointer-events: none;
user-select: none;
min-height: 84px;
+15 -7
View File
@@ -1,21 +1,29 @@
/* Onboarding wizard (first-run: add child, solo/couple, balances ladder) */
/* Onboarding wizard (first-run: add child, solo/couple, balances ladder) —
* a step here should breathe: it's a handful of fields at a time, not a
* dense page, so it gets more top/bottom air than the dashboard/settings
* content sections do. */
.onboarding {
max-width: 60ch;
margin: 0 auto;
padding-top: var(--sp-6);
}
.onboarding__indicator {
color: var(--c-text-muted);
font-size: var(--fs-sm);
margin-bottom: var(--sp-4);
font-size: var(--fs-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--sp-5);
}
.onboarding__step h1 {
margin-bottom: var(--sp-2);
margin-bottom: var(--sp-3);
}
.onboarding__step > p {
color: var(--c-text-muted);
margin-bottom: var(--sp-5);
margin-bottom: var(--sp-6);
}
.onboarding__partner {
@@ -41,7 +49,7 @@
border: 1px solid var(--c-border);
border-radius: var(--radius);
background-color: var(--c-surface);
padding: var(--sp-4);
padding: var(--sp-5);
}
.onboarding__ladder-item h2 {
@@ -51,7 +59,7 @@
.onboarding__ladder-item p {
color: var(--c-text-muted);
margin-bottom: var(--sp-3);
margin-bottom: var(--sp-4);
}
.onboarding__hint {