+
-
{children}
+
+ {children} +
) diff --git a/frontend/src/components/layout/footer.css b/frontend/src/components/layout/footer.css index 7f9872a..08557b3 100644 --- a/frontend/src/components/layout/footer.css +++ b/frontend/src/components/layout/footer.css @@ -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; diff --git a/frontend/src/components/layout/header.css b/frontend/src/components/layout/header.css index bd0aaff..16ff1ec 100644 --- a/frontend/src/components/layout/header.css +++ b/frontend/src/components/layout/header.css @@ -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 ``s, so without this the global `a:hover` rule underlines - * the label on hover. */ +/* Nav links are `.btn.btn--ghost` ``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; +} diff --git a/frontend/src/components/layout/language-toggle.css b/frontend/src/components/layout/language-toggle.css new file mode 100644 index 0000000..a8b6fbb --- /dev/null +++ b/frontend/src/components/layout/language-toggle.css @@ -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); +} diff --git a/frontend/src/components/layout/layout.css b/frontend/src/components/layout/layout.css index 1709326..ac43d22 100644 --- a/frontend/src/components/layout/layout.css +++ b/frontend/src/components/layout/layout.css @@ -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 `
` 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); } - diff --git a/frontend/src/i18n/en/common.ts b/frontend/src/i18n/en/common.ts index 8128bbc..4df2603 100644 --- a/frontend/src/i18n/en/common.ts +++ b/frontend/src/i18n/en/common.ts @@ -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', diff --git a/frontend/src/i18n/sv/common.ts b/frontend/src/i18n/sv/common.ts index a751f8b..93e6340 100644 --- a/frontend/src/i18n/sv/common.ts +++ b/frontend/src/i18n/sv/common.ts @@ -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', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index ce59068..5760142 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -71,10 +71,6 @@ vi.mock('@/components/layout/Layout', () => ({ Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock('@/components/common/LoadingSpinner', () => ({ - LoadingSpinner: () =>
Loading...
, -})) - 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() - expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() }) it('renders 7 weekday headers in Monday-first Swedish order', () => { diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 02fd2f5..12dd076 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -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 + return ( + + + + ) } const year = currentMonth.getFullYear() @@ -1215,7 +1219,7 @@ export function Calendar(): ReactNode { {t('calendar.legendIncomeBased')}