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:
Jakob Wennberg
2026-07-23 21:19:12 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 288915c152
commit 6b506a9ca8
25 changed files with 102 additions and 38 deletions
+1 -1
View File
@@ -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')}
+1 -1
View File
@@ -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>
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
)}
+19 -6
View File
@@ -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'}>
+1 -1
View File
@@ -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 })}
+6
View File
@@ -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));