feat(ui): frame-layout shell, pill buttons, 24px page titles (UI migration PR 1) (#1117)
* feat(ui): frame-layout shell, pill buttons, 24px page titles (UI migration PR 1) The visual shell from the concept, zero behavior change: - New --frame token pair (40 18% 96% light / 0 0% 5% dark); the dashboard wrapper is bg-frame and <main> becomes a rounded 12px panel with its own inner scroll (md-gated; mobile keeps document flow + bottom nav) - Sidebar goes borderless/transparent on the frame - Buttons are pills app-wide (radius 99px, default 7px/16px padding, 13px text, icon buttons become circles), set once in components/ui/button.tsx - PageHeader locked at exactly 24px/32px Hedvig serif - MainContainer resets panel scroll on route change (Next's window scroll-to-top never fires for an inner scroll container) - chat layout + extension workspaces switch viewport-height formulas to h-full so they fill the panel instead of overflowing it by 20px - .claude/rules/design.md rewritten with the 14 locked UI-migration conventions from dev_docs/ui_migration_plan.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): clamp hand-rolled page titles to the locked 24px/32px Transaktioner (TransactionStatusBar) and 13 other pages hand-roll their h1 instead of using PageHeader, so they kept text-3xl/4xl after the shell change. Clamp them all to font-display text-2xl leading-8. Onboarding heroes and headline numbers are intentionally untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): keep the frame strip above the panel on banner-less accounts <main>'s 10px top margin was the first in-flow margin inside the shell wrapper, so it collapsed through the wrapper and pushed the whole shell down, showing white body background above the panel instead of the warm frame strip (only visible on real accounts: the sandbox banner blocked the collapse). Flex containers never collapse child margins, so the shell wrappers become md:flex md:flex-col. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
288915c152
commit
6b506a9ca8
+29
-6
@@ -18,7 +18,7 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who
|
||||
|
||||
- **Palette**: Achromatic foundation. Pure white background, warm beige (`40 11% 89%`) for chips / active sidebar / hover / secondary buttons. Achromatic primary (no cool tint). Semantic colors (`--success` sage, `--warning` ochre, `--destructive` terracotta) exist but are **data-only**: they appear in charts and financial numbers (positive/negative deltas), never as chrome backgrounds. In chrome, only `--destructive` survives.
|
||||
- **Typography**: Hedvig Letters Serif for display headings, Geist (sans) for body, forms, and tables. Hedvig is single-weight (400): do not apply `font-medium` to display text; its natural high-contrast strokes carry the weight. Tabular numbers everywhere financial data appears.
|
||||
- **Surfaces**: Cards sit flat on the page: no shadow, full-opacity hairline border (`border-border`), `rounded-lg` (8px). Card background matches page background; the border carries hierarchy. Dark mode drops the warm tint from secondary for a pure-gray mood shift; light mode keeps the beige.
|
||||
- **Surfaces**: The page itself is a rounded panel (12px) floating on a warm-toned frame (`--frame`); the sidebar sits borderless on the frame. Cards sit flat on the page: no shadow, full-opacity hairline border (`border-border`), `rounded-lg` (8px). Card background matches page background; the border carries hierarchy. Dark mode drops the warm tint from secondary for a pure-gray mood shift; light mode keeps the beige.
|
||||
- **Spacing**: Generous whitespace. Dense data (tables, ledgers) uses tighter spacing but never feels cramped.
|
||||
- **Motion**: Functional, not decorative. No press-scale, no hover-lift, no spring overshoot. Hover state is a flat background shift (`bg-secondary/60`). `transition-colors duration-150` is the default. Stagger animations on list entry are fine. Respect `prefers-reduced-motion` (already wired).
|
||||
- **Icons**: Lucide: 15px in navigation, slightly larger in empty states.
|
||||
@@ -35,6 +35,25 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who
|
||||
|
||||
WCAG AA (4.5:1 text, 3:1 UI). Keyboard-navigable + visible focus rings. Respect `prefers-reduced-motion`. Color never sole state indicator. Touch targets ≥40px (44px for mobile-critical). Icon-only buttons need `aria-label`.
|
||||
|
||||
## Locked UI-migration conventions
|
||||
|
||||
Decided during the 2026-07 concept work (dev_docs/ui_migration_plan.md); they apply system-wide and override anything below that conflicts.
|
||||
|
||||
1. **Frame layout.** The page is a rounded panel (12px) on a warm-toned frame (`--frame`). The panel keeps `--background`; the sidebar is borderless on the frame.
|
||||
2. **Page title is exactly 24px/32px** Hedvig Letters Serif (`text-2xl leading-8` via PageHeader).
|
||||
3. **Buttons are pills.** Radius 99px, default padding 7px 16px, 13px text. Set once in `components/ui/button.tsx`, app-wide, never per page.
|
||||
4. **Table rows are one line.** Secondary info (descriptions, OCR, roles) belongs in the detail view or a click-popup, never as sub-rows in lists.
|
||||
5. **Chips mark exceptions.** Normal states render as muted text; Badge only when the row deviates. Same chip on every row means the chip is wrong.
|
||||
6. **Attention is one ochre sentence**, not a banner: the `.attn` pattern (12.5px, `--warning` tone, single line, optionally with an embedded action link). Max one per page.
|
||||
7. **Help text lives behind a "?"** right after the H1: a small (17px) circular button opening a popover anchored at the button. No instructional copy in the page flow.
|
||||
8. **One context picker per page, far right in the toolbar**: fiscal year or account/source as a chip-dropdown with a check on the active choice. A chip that looks like a picker must be a picker.
|
||||
9. **The primary action lives in the page header**, right side. Multiple create paths collapse into a split button whose caret menu remembers the last-used mode (persisted in `user_preferences`, not localStorage).
|
||||
10. **Confirm up front, don't comment afterwards.** Actions that post or send open a small confirm dialog describing the outcome ("Bokförs som verifikat A-217 ...") instead of writing outcome text into the page afterwards.
|
||||
11. **Content lands with stagger.** `.stagger-enter` is the standard entry for list/table content, on server render and on client-fetch completion alike.
|
||||
12. **Status colors are data, not chrome**: sage/ochre/terracotta only in numbers, exception chips and `.attn`.
|
||||
13. **Overlays**: centered modal for create/confirm (template, assistant, confirmations, settings); right slide-over for reviewing an object (Granskning detail). Both with veil, Esc, and click-outside.
|
||||
14. **Manual base, AI as opt-in.** Base flows work without AI; AI entry points are clearly labeled discrete choices (e.g. "Skapa med assistenten") and no AI suggestion posts without Granskning.
|
||||
|
||||
## Design System Tokens
|
||||
|
||||
**Spacing scale.** Only use Tailwind values `1, 2, 3, 4, 6, 8, 10, 12`. **Forbidden:** `2.5`, `5`, hardcoded pixels in page logic.
|
||||
@@ -52,10 +71,13 @@ WCAG AA (4.5:1 text, 3:1 UI). Keyboard-navigable + visible focus rings. Respect
|
||||
|
||||
Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail cards use `p-6`. Never mix `p-5`.
|
||||
|
||||
**Layout.**
|
||||
- Sidebar width: `md:w-64` (256px). Main content offset: `md:pl-64`.
|
||||
**Layout (frame layout).**
|
||||
- The dashboard wrapper is `bg-frame` (`--frame: 40 18% 96%` light, `0 0% 5%` dark). `<main>` is the page panel: `bg-background rounded-xl border border-border`, 10px margin against the frame, own inner scroll (`md:h-[calc(100vh-20px)] md:overflow-y-auto`). Defined once as `MAIN_PANEL_CLASS` in `app/(dashboard)/layout.tsx`; never restyle per page.
|
||||
- The panel is the desktop scroll container: `position: sticky` binds to it automatically; never assume `window` scroll on dashboard pages. Scroll reset on navigation lives in `MainContainer`.
|
||||
- Sidebar width: `md:w-64` (256px), borderless and transparent on the frame. Panel offset: `md:ml-64`.
|
||||
- Main container: `max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10` (via `components/dashboard/MainContainer.tsx`).
|
||||
- Page root: `<div className="space-y-8">`.
|
||||
- Mobile keeps the pre-frame layout: full-width document flow, bottom nav; the panel styles are `md:`-gated.
|
||||
|
||||
**Primitives: always use these, don't hand-roll.**
|
||||
|
||||
@@ -63,7 +85,7 @@ Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail ca
|
||||
|---|---|---|
|
||||
| Page title + action | `components/ui/page-header.tsx` `PageHeader` | Use this, not bespoke `<h1>` + `<p>` blocks. Drop the `description` prop when it just paraphrases the title. |
|
||||
| Data table | `components/ui/table.tsx` `Table / TableHeader / TableHead / TableRow / TableCell` | Header style is baked in: `text-[11px] font-medium uppercase tracking-wider text-muted-foreground`. Wrap in `<CardContent className="p-0">` when the table is a card's primary content. Add `tabular-nums` to numeric cells. |
|
||||
| Status indicator | `components/ui/badge.tsx` `<Badge variant>` | Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. |
|
||||
| Status indicator | `components/ui/badge.tsx` `<Badge variant>` | Chips mark exceptions only: normal states (Aktiv, Bokförd, Betald-i-tid) render as muted text (`text-muted-foreground text-xs`); Badge is reserved for rows that deviate (Utkast, Förfallen, Ej bokförd). A table where every row carries the same chip is wrong. Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. |
|
||||
| No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `<div className="flex flex-col items-center py-12">…</div>`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). |
|
||||
| Loading placeholder | `components/ui/skeleton.tsx` `<Skeleton>` | Don't hand-roll `bg-muted rounded animate-pulse` divs. |
|
||||
| Inline help / formulas | `components/ui/info-tooltip.tsx` `InfoTooltip` | Hover-revealed; don't use always-visible info buttons. |
|
||||
@@ -84,7 +106,7 @@ Never render raw `{x.invoice_date}` directly: always route through `formatDate()
|
||||
**Currency.** `formatCurrency(n, currency?)` from `lib/utils.ts`. Default SEK.
|
||||
|
||||
**Typography.**
|
||||
- Page title: use `PageHeader` (renders `font-display text-3xl md:text-4xl tracking-tight`). Do not hand-roll an `<h1>`.
|
||||
- Page title: use `PageHeader` (renders `font-display text-2xl leading-8 tracking-tight`, exactly 24px/32px, locked). Do not hand-roll an `<h1>`.
|
||||
- Card title: `<CardTitle className="text-base">` for sections, default for primary cards. The primitive already drops `font-medium`: do not add it back.
|
||||
- Section divider header inside a page: `<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">`.
|
||||
- Headline number: `font-display text-xl tabular-nums`. No `font-medium`: Hedvig's natural weight carries the gravitas.
|
||||
@@ -100,5 +122,6 @@ Never render raw `{x.invoice_date}` directly: always route through `formatDate()
|
||||
- `active:scale-[...]` on buttons. Buttons do not bounce.
|
||||
- `bg-gradient-to-*` on page or card backgrounds. Flat surfaces only.
|
||||
- `font-medium` on display elements (`font-display`, h1/h2/h3, CardTitle, PageHeader title). Hedvig is single-weight by design.
|
||||
- `rounded-xl` (12px) on cards. Cards are `rounded-lg` (8px). `rounded-xl` survives only on prominent hero-style surfaces if absolutely needed.
|
||||
- `rounded-xl` (12px) on cards. Cards are `rounded-lg` (8px). `rounded-xl` survives on the page panel (frame layout) and prominent hero-style surfaces only.
|
||||
- Overriding the Button pill radius per call site (`rounded-lg`, `rounded-md` on a `<Button>`). Buttons are pills app-wide; the radius lives in `components/ui/button.tsx` alone.
|
||||
- Opacity-suffixed border classes (`border-border/30`, `border-border/60`) on cards and primary surfaces. Use full-opacity `border-border`: the new border token is calibrated for that.
|
||||
|
||||
@@ -331,3 +331,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-23] Removed the accountingMethod parameter from calculateVatDeclaration (and the dead company_settings.accounting_method reads in the xlsx/pdf/eskd routes) instead of restoring the settings read: the value was verifiably unused (declared _accountingMethod, zero body references) because the method is baked into journal entry timing, and keeping an ignored parameter invites a future branch that silently sees the caller's hard-coded 'accrual'; the v1 API still accepts accounting_method for wire compat but its docs now state it has no effect.
|
||||
[2026-07-23] Closed the mark_entry_as_opening_balance TOCTOU (link committing between the RPC's EXISTS check and commit) with a transactions-side trigger (20260723190000, FOR KEY SHARE on journal_entries) instead of a shared advisory lock in every linking code path: the trigger enforces the invariant from both directions in one place, needs no app-code changes, and FOR KEY SHARE conflicts with exactly the RPC's FOR UPDATE and nothing weaker; prod verified to have zero pre-existing violating rows.
|
||||
[2026-07-23] Declined the suggested composite index (company_id, is_active, account_class, sort_order, id) on chart_of_accounts for list_company_accounts: the RPC exists to eliminate cross-region HTTP round trips, per-company row counts (p95 ~1250) make the filter+sort a few ms via the existing company_id index, and a 5-column index taxes every account write for no user-visible gain.
|
||||
[2026-07-21] Floden & agenter concept (dev_docs/flows_agents_concept.md + interactive artifact): founder locked hybrid builder (chat builds, linear flow view edits; no free node canvas), trust-ladder autonomy on pending_operations risk tiers, per-company before byra, ships alongside Assistenten. Engine recommendation is a hand-rolled Postgres state machine in lib/flows/ generalizing the webhook-dispatcher pattern; every external engine (Inngest/Trigger/Temporal/Vercel Workflow/Restate/DBOS/pg-boss) fails zero-extra-services self-host, Supabase-only durability, AGPL compatibility, or free days-long approval pauses. Flows compile deterministically at build time; LLM steps are explicit, marked step types. Awaiting founder visual sign-off before any implementation PR.
|
||||
[2026-07-21] Official Claude Code plugin ships in-tree (claude-plugin/ + root .claude-plugin/marketplace.json), not in the claude-for-swedish-small-business marketplace repo: the wrapper-reference sync test must live next to the MCP server source, and the official plugin belongs with the product. Wrappers are thin by design: regulatory knowledge stays server-side in agent_atom_registry and is loaded via gnubok_load_skill, never bundled. Plugin licensed MIT matching the gnubok-mcp precedent.
|
||||
[2026-07-22] Swish inflows via Swishrapport file import (#1114), not Swish API: the Commerce API cannot list incoming payments (only API-created ones) and prod data shows banks strip payer/message from PSD2 remittance info.
|
||||
[2026-07-22] Förening chart: hand-maintain from free BAS 2026 PDF, ship Idrottens BAS as a separate optional template (not merged): BAS machine-readable JSON is a paid proprietary product unfit for AGPL vendoring, and IBK 38xx/35xx collide with standard BAS meanings.
|
||||
[2026-07-23] UI-migration plan authored (dev_docs/ui_migration_plan.md): shell-first sequence (tokens/nav/primitives) then Bokforing/Transaktioner/Granskning/Kund+Lev-fakturor; concept artifact is the reference; 14 locked design conventions codified: chose PR-per-page over big-bang to keep founder visual sign-off per merge.
|
||||
[2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights.
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function ArticleDetailPage({
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{article.name}</h1>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{article.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={article.active ? 'success' : 'secondary'}>
|
||||
{article.active ? t('status_active') : t('status_inactive')}
|
||||
|
||||
@@ -339,7 +339,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl md:text-3xl tracking-tight font-mono">
|
||||
<h1 className="text-2xl leading-8 tracking-tight font-mono">
|
||||
{formatVoucher(entry)}
|
||||
</h1>
|
||||
<JournalEntryStatusBadge entry={entry} />
|
||||
|
||||
@@ -225,7 +225,7 @@ export default function YearEndPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h1 className="font-display text-3xl md:text-4xl tracking-tight">Årsbokslut</h1>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">Årsbokslut</h1>
|
||||
<div className="flex gap-2">
|
||||
<AgentSparkleButton
|
||||
intentId="bokslut.step"
|
||||
|
||||
@@ -364,7 +364,7 @@ export default function PeriodiseringWizardPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl md:text-4xl tracking-tight">
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">
|
||||
{closingYear ? `Periodisering: Bokslut ${closingYear}` : 'Periodisering'}
|
||||
</h1>
|
||||
<Button variant="outline" asChild>
|
||||
|
||||
@@ -44,8 +44,9 @@ export default async function ChatLayout({ children }: { children: React.ReactNo
|
||||
//
|
||||
// dvh handles mobile browser chrome shrinking on scroll. Mobile: subtract
|
||||
// the bottom nav (h-16 = 64px) + safe-area-inset-bottom so the chat
|
||||
// pane fills the visible viewport exactly. Desktop: full viewport.
|
||||
<div className="flex h-[calc(100dvh-4rem-env(safe-area-inset-bottom,0px))] md:h-screen">
|
||||
// pane fills the visible viewport exactly. Desktop: fill the frame
|
||||
// panel (<main> has an explicit height there, so h-full resolves).
|
||||
<div className="flex h-[calc(100dvh-4rem-env(safe-area-inset-bottom,0px))] md:h-full">
|
||||
<ChatSidebar initialConversations={conversations ?? []} />
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-background">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function CustomerDetailPage({
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{customer.name}</h1>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{customer.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(CUSTOMER_TYPE_KEY[customer.customer_type])}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ function TicSkeleton() {
|
||||
|
||||
function FullScreenWorkspaceSkeleton() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-1px)] p-4 md:p-6">
|
||||
<div className="h-[calc(100vh-1px)] md:h-full p-4 md:p-6">
|
||||
<div className="h-full flex flex-col rounded-lg border bg-card overflow-hidden">
|
||||
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
|
||||
@@ -188,7 +188,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{t('title')}</h1>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('subtitle', { number: invoice.invoice_number ?? '' })}
|
||||
</p>
|
||||
|
||||
@@ -655,7 +655,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<h1 className={cn('font-display text-2xl sm:text-3xl tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '-')}</h1>
|
||||
<h1 className={cn('font-display text-2xl leading-8 tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '-')}</h1>
|
||||
{isProforma && (
|
||||
<Badge variant="outline">{t('badge_proforma')}</Badge>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,19 @@ import {
|
||||
*/
|
||||
const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
|
||||
|
||||
/**
|
||||
* Frame layout: on desktop the page is a rounded panel floating on the
|
||||
* warm-toned frame (bg-frame on the wrapper div), with its own inner
|
||||
* scroll. 10px margin against the frame; height is the remaining
|
||||
* viewport. The sidebar (fixed, w-64) sits borderless on the frame, so
|
||||
* the panel starts at ml-64. On mobile the panel dissolves: full-width
|
||||
* document flow with the bottom nav, exactly as before.
|
||||
*/
|
||||
const MAIN_PANEL_CLASS =
|
||||
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
|
||||
'md:min-h-0 md:ml-64 md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' +
|
||||
'md:overflow-y-auto md:rounded-xl md:border md:border-border'
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
settingsModal,
|
||||
@@ -95,7 +108,7 @@ export default async function DashboardLayout({
|
||||
>
|
||||
<AgentSheetProvider>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
@@ -104,7 +117,7 @@ export default async function DashboardLayout({
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
className="safe-area-main-padding md:!pb-0 md:pl-64"
|
||||
className={MAIN_PANEL_CLASS}
|
||||
role="main"
|
||||
>
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
@@ -186,14 +199,14 @@ export default async function DashboardLayout({
|
||||
<CompanyProvider value={companyContextValue}>
|
||||
<AgentSheetProvider>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
{children}
|
||||
</div>
|
||||
@@ -265,7 +278,7 @@ export default async function DashboardLayout({
|
||||
}}
|
||||
>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
||||
{/* Skip to content link for keyboard/screen reader users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
@@ -284,7 +297,7 @@ export default async function DashboardLayout({
|
||||
userName={userProfile?.full_name ?? null}
|
||||
userEmail={user.email ?? null}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
|
||||
<MainContainer companyId={companyId}>{children}</MainContainer>
|
||||
</main>
|
||||
<AgentTrigger />
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
<Link href="/salary/employees" aria-label={t('form_back_to_employees')}><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">
|
||||
{employee.first_name} {employee.last_name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
|
||||
@@ -456,7 +456,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<h1 className="font-display text-2xl sm:text-3xl tracking-tight">
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">
|
||||
{t('arrival_header', { number: invoice.arrival_number })}
|
||||
</h1>
|
||||
<Badge variant={statusVariants[invoice.status] || 'secondary'}>
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function SupplierDetailPage() {
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{supplier.name}</h1>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{supplier.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{supplierTypeLabels[supplier.supplier_type]}
|
||||
{supplier.org_number && t('org_number_inline', { number: supplier.org_number })}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 9%;
|
||||
|
||||
/* Warm-toned frame behind the floating page panel (frame layout) */
|
||||
--frame: 40 18% 96%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
|
||||
@@ -83,6 +86,8 @@
|
||||
--background: 0 0% 7%;
|
||||
--foreground: 0 0% 87%;
|
||||
|
||||
--frame: 0 0% 5%;
|
||||
|
||||
--card: 0 0% 11%;
|
||||
--card-foreground: 0 0% 87%;
|
||||
|
||||
@@ -129,6 +134,7 @@
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-frame: hsl(var(--frame));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ConsultantEmptyState({ firstName }: ConsultantEmptyState
|
||||
return (
|
||||
<div className="stagger-enter">
|
||||
<header className="mb-16">
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
@@ -510,7 +510,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col border-r border-border bg-background">
|
||||
{/* Borderless on the frame: the panel next to it carries the border */}
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-transparent">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
|
||||
{/* Company switcher pinned to the top: the active company is
|
||||
the strongest piece of context for everything below it. */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
@@ -22,6 +23,15 @@ export function MainContainer({
|
||||
children: ReactNode
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// The panel (<main>) is its own scroll container on desktop, so Next's
|
||||
// built-in scroll-to-top on navigation (which targets the window) never
|
||||
// fires for it. Reset the panel scroll on every route change; hash-anchor
|
||||
// scrolling still works because pages call scrollIntoView themselves.
|
||||
useEffect(() => {
|
||||
document.getElementById('main-content')?.scrollTo(0, 0)
|
||||
}, [pathname])
|
||||
|
||||
// Full-bleed routes own their own padding + multi-pane layout. They
|
||||
// shouldn't sit inside max-w-5xl or any horizontal padding: that's what
|
||||
// causes a visible gap between the dashboard sidebar and the chat-sidebar
|
||||
|
||||
@@ -156,7 +156,7 @@ function deriveInboxStatus(item: InboxItem): InboxStatus {
|
||||
|
||||
function WorkspaceSkeleton() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-1px)] p-4 md:p-6">
|
||||
<div className="h-[calc(100vh-1px)] md:h-full p-4 md:p-6">
|
||||
<div className="h-full flex flex-col rounded-lg border bg-card overflow-hidden">
|
||||
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -712,7 +712,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-[calc(100vh-1px)] xl:h-[calc(100vh-1px)] p-4 md:p-6"
|
||||
className="min-h-[calc(100vh-1px)] md:min-h-full xl:h-full p-4 md:p-6"
|
||||
onDragOver={(e) => { e.preventDefault(); if (!isDragging) setIsDragging(true) }}
|
||||
onDragLeave={(e) => {
|
||||
// only clear when leaving the workspace itself, not children
|
||||
|
||||
@@ -1373,7 +1373,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Heading className={bare ? 'font-display text-xl tracking-tight' : 'font-display text-2xl md:text-3xl tracking-tight'}>
|
||||
<Heading className={bare ? 'font-display text-xl tracking-tight' : 'font-display text-2xl leading-8 tracking-tight'}>
|
||||
{titleText}
|
||||
{numberPreview && !isSelfBilled && (
|
||||
<span className={bare ? 'ml-2 text-muted-foreground tabular-nums text-lg' : 'ml-2 text-muted-foreground tabular-nums text-xl md:text-2xl'}>
|
||||
|
||||
@@ -1487,7 +1487,7 @@ export default function NewSupplierInvoiceForm({
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{t('page_title')}</h1>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('page_title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function TransactionStatusBar({
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl md:text-4xl tracking-tight">{t('page_title')}</h1>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('page_title')}</h1>
|
||||
{uncategorizedCount > 0 && mode === 'inbox' && (
|
||||
<p className="text-muted-foreground mt-1">
|
||||
<span className="text-foreground font-semibold">{uncategorizedCount}</span> {t('subtitle_to_post')}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Buttons are pills (radius 99px): a deliberate app-wide divergence from the
|
||||
// shadcn 8px default, locked in the UI-migration conventions. Change it here,
|
||||
// never per call site.
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-full font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -24,10 +27,10 @@ const buttonVariants = cva(
|
||||
"bg-success text-success-foreground hover:bg-success/90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-5 py-2 rounded-lg",
|
||||
sm: "h-9 px-4 text-xs rounded-lg",
|
||||
lg: "h-11 px-8 text-base rounded-lg",
|
||||
icon: "h-10 w-10 rounded-lg",
|
||||
default: "px-4 py-[7px] text-[13px]",
|
||||
sm: "h-9 px-4 text-xs",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -10,7 +10,8 @@ export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl md:text-4xl tracking-tight">{title}</h1>
|
||||
{/* Locked at exactly 24px/32px (UI-migration convention 2) */}
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user