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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user