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