feat(ui): company monogram in user menu + mobile web touch polish (#1531)

* feat(ui): replace generic building icon with company monogram in user menu

The company row and switcher flyout in the sidebar user menu showed
lucide Building2 for every company. Render the company's initial in a
small rounded square instead (square = company, circle = person), so
each company gets a mark of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mobile): touch behavior polish for the mobile web experience

- kill -webkit-tap-highlight-color flash; touch-action: manipulation on
  interactive elements (no double-tap-to-zoom wait); user-select: none
  on buttons (long-press no longer enters text selection)
- overscroll-behavior-y: contain on html/body: pull-to-refresh no longer
  hijacks list scrolling, inner scrollers stop chaining to the document
  (contain, not none, so iOS rubber-banding survives)
- 16px font-size floor for form fields on coarse pointers: iOS Safari
  stops zooming into focused inputs; desktop keeps text-sm
- min-h-screen -> min-h-dvh everywhere: correct height under collapsing
  mobile browser chrome, identical on desktop
- active: variants mirror hover: on Button: Tailwind 4 gates hover:
  behind (hover: hover), so touch devices previously got zero pointer
  feedback
- theme-color now tracks the app: default was a leftover blue #304D83;
  SSR emits white and ThemeColorSync mirrors the computed --background
  into the meta tag across dark mode and palette switches

Hover-stuck-after-tap and viewport-fit/safe-area were already covered
(Tailwind 4 hover gating; existing viewportFit: cover + safe-area
utilities).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log monogram and overscroll decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-13 10:41:45 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 38a890c8d1
commit 8f1b1fb5cd
29 changed files with 147 additions and 48 deletions
+2
View File
@@ -876,6 +876,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-11] Anthropic, Vercel and Supabase removed from the portal directory: all three email their invoices to European customers, so listing them told the user to go and log in for a document already in their inbox. The directory's bar is "does not send the invoice", not "also has a portal", and the poll it was seeded from asked which portals people log into, which people answered with where an invoice can ALSO be found. The same objection may reach further down the list; an entry is a claim that the invoice cannot be had any other way and is worth checking per vendor.
[2026-08-11] Portal URLs are swept by scripts/check-portal-urls.mts rather than trusted: the directory shipped with 18 hand-written paths, none opened, the file said so and shipped anyway, and a founder then hit a 404 on Google Workspace (/ac/billing/history). A sweep found GitHub's /settings/billing 404 too. Rule now is the shallowest URL that certainly resolves: landing one click short of the invoice costs little, landing on an error page spends the trust the feature runs on. Google, OpenAI and Hetzner refuse automated requests, so they cannot be swept and are kept shallow deliberately; only a genuine 404 fails the script, since failing on an unreachable host would train people to ignore it. Trygg Hansa removed: neither candidate URL could be reached at all.
[2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes.
[2026-08-12] Company mark in user menu = initial monogram, not another lucide glyph: any stock icon is equally generic; square-vs-circle distinguishes company from person.
[2026-08-12] overscroll-behavior-y: contain (not none) on html/body: contain kills Android pull-to-refresh and scroll chaining but preserves iOS rubber-banding; none would deaden document scroll.
[2026-08-12] Fortnox and Bokio receipt imports share one private attachment adapter: keeping period resolution, WORM upload, per-verifikat hash deduplication, content sniffing, and best-effort counters in one loop prevents provider branches from drifting on compliance and idempotency.
[2026-08-12] Provider receipt imports derive a document UUID from company, verifikat, and content hash: the existing document primary key becomes an atomic cross-request claim without a migration, while each attempt uses a unique storage object and a losing insert verifies the retained winner before removing only its own unreferenced object.
[2026-08-12] The Fortnox migration completion prompt reports dry-run scanned attachments as "found", not would-link counts: provider availability is known before download, while content duplicates and final importability are only honest after the user starts the idempotent import.
+1 -1
View File
@@ -120,7 +120,7 @@ function SetPasswordContent() {
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-background p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-background p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
+3 -3
View File
@@ -368,7 +368,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const webmailHint = detectWebmailHint(email, branding.authEmailFrom)
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
@@ -428,7 +428,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
// Reset password form
if (showResetPassword) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
@@ -493,7 +493,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + (googleAuthEnabled ? 1 : 0)
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<header className="text-center mb-8">
<h1 className="sr-only">{tAuth('login_title')}</h1>
+2 -2
View File
@@ -175,7 +175,7 @@ function MfaEnrollContent() {
// Step 1: Show enroll button
if (!qrCode) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
@@ -229,7 +229,7 @@ function MfaEnrollContent() {
// Step 2: Show QR code and verification
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
+1 -1
View File
@@ -170,7 +170,7 @@ function MfaVerifyContent() {
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
+3 -3
View File
@@ -384,7 +384,7 @@ function RegisterPageContent() {
if (duplicateEmail) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
@@ -437,7 +437,7 @@ function RegisterPageContent() {
const webmailHint = detectWebmailHint(email, branding.authEmailFrom)
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
@@ -485,7 +485,7 @@ function RegisterPageContent() {
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<header className="text-center mb-8">
<h1 className="sr-only">{t('create_account')}</h1>
+1 -1
View File
@@ -208,7 +208,7 @@ function ResetPasswordInner() {
: t('subtitle')
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-frame p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
+4 -4
View File
@@ -39,7 +39,7 @@ const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
* 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 ' +
'safe-area-main-padding md:!pb-0 relative bg-background min-h-dvh ' +
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[var(--agent-dock-w)] md:h-[calc(100vh-20px)] ' +
'md:overflow-y-auto md:rounded-xl md:border md:border-border ' +
'md:transition-[margin-left,margin-right] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]'
@@ -112,7 +112,7 @@ export default async function DashboardLayout({
<SessionTimeoutController />
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
<div className="min-h-dvh bg-frame md:flex md:flex-col">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
@@ -238,7 +238,7 @@ export default async function DashboardLayout({
<SessionTimeoutController />
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
<div className="min-h-dvh bg-frame md:flex md:flex-col">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
@@ -332,7 +332,7 @@ export default async function DashboardLayout({
<CompanyTabSync />
<div
id="dash-shell"
className="min-h-screen bg-frame md:flex md:flex-col"
className="min-h-dvh bg-frame md:flex md:flex-col"
style={{ '--nav-w': navCollapsed ? '64px' : '248px' } as React.CSSProperties}
>
{/* Skip to content link for keyboard/screen reader users */}
+1 -1
View File
@@ -35,7 +35,7 @@ export default async function OnboardingLayout({
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="min-h-dvh bg-background flex items-center justify-center">
{user && <SessionTimeoutController />}
<OnboardingBackdrop />
+1 -1
View File
@@ -12,7 +12,7 @@ export function generateMetadata(): Metadata {
export default function DPAPage() {
const { appName, legalEntity, privacyEmail } = getBranding()
return (
<div className="min-h-screen bg-background py-12 px-4">
<div className="min-h-dvh bg-background py-12 px-4">
<div className="max-w-3xl mx-auto space-y-8">
<div className="text-center space-y-2">
<h1 className="font-display text-3xl md:text-4xl tracking-tight text-foreground">
+4 -4
View File
@@ -102,7 +102,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center">
<div className="min-h-dvh bg-gradient-to-b from-slate-50 to-white flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground">Laddar...</p>
@@ -113,7 +113,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
if (error && !invoice) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
<div className="min-h-dvh bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
<Card className="max-w-md w-full">
<CardContent className="pt-6 text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
@@ -132,7 +132,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
// Already responded view
if (invoice.alreadyResponded || successMessage) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
<div className="min-h-dvh bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
<Card className="max-w-md w-full">
<CardContent className="pt-6 text-center">
<CheckCircle className="h-12 w-12 text-success mx-auto mb-4" />
@@ -166,7 +166,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
const feeDueSeparately = invoice.feeDueSeparately ?? 0
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="min-h-dvh bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-lg mx-auto">
{/* Header */}
<div className="text-center mb-8">
+1 -1
View File
@@ -12,7 +12,7 @@ export function generateMetadata(): Metadata {
export default function PrivacyPolicyPage() {
const { appName, legalEntity, privacyEmail } = getBranding()
return (
<div className="min-h-screen bg-background py-12 px-4">
<div className="min-h-dvh bg-background py-12 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">
+1 -1
View File
@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function CompaniesLoading() {
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4" aria-busy="true">
<main className="flex min-h-dvh items-center justify-center bg-background p-4" aria-busy="true">
<div className="w-full max-w-lg space-y-6 rounded-lg border bg-card p-6">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-12 w-full" />
+1 -1
View File
@@ -36,7 +36,7 @@ export default async function NewCompanyPage() {
}
return (
<div className="min-h-screen bg-background">
<div className="min-h-dvh bg-background">
<SessionTimeoutController />
<OnboardingBackdrop />
<OnboardingJourney teamId={teamId} mode="add" />
+1 -1
View File
@@ -63,7 +63,7 @@ export default function GlobalError({
</head>
<body>
{phase === "fallback" ? (
<div className="flex min-h-screen items-center justify-center p-8">
<div className="flex min-h-dvh items-center justify-center p-8">
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold">Något gick fel</h2>
<p className="text-muted-foreground">
+49
View File
@@ -364,6 +364,55 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* ── Touch / mobile-web behavior ──
Hover styling is already safe: Tailwind 4 gates every hover: variant
behind @media (hover: hover), so no stuck-hover rules are needed here. */
/* Kill the gray/blue tap flash (inherited property, one declaration covers all) */
html {
-webkit-tap-highlight-color: transparent;
}
/* Stop pull-to-refresh from hijacking list scrolling and stop inner scrollers
from chaining into the document. `contain`, not `none`: none would also kill
iOS rubber-banding, which should stay. */
html,
body {
overscroll-behavior-y: contain;
}
/* Remove the double-tap-to-zoom wait on interactive elements so taps commit
immediately; pinch zoom and panning are unaffected. */
a,
button,
label,
summary,
input,
select,
textarea,
[role="button"] {
touch-action: manipulation;
}
/* Long-press on a button must not enter text selection */
button,
summary,
[role="button"] {
-webkit-user-select: none;
user-select: none;
}
/* iOS Safari zooms into any focused field whose font-size is under 16px.
Touch-primary devices get a 16px floor; desktop keeps text-sm form fields.
Unlayered on purpose so it also beats layered text-[13px]-style utilities
on bespoke inputs. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]),
select,
textarea {
font-size: max(16px, 1em);
}
}
/* Display typography - Fraunces */
.font-display {
+1 -1
View File
@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function InviteLoading() {
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4" aria-busy="true">
<main className="flex min-h-dvh items-center justify-center bg-background p-4" aria-busy="true">
<div className="w-full max-w-md space-y-5 rounded-lg border bg-card p-6">
<Skeleton className="mx-auto h-12 w-12 rounded-full" />
<Skeleton className="mx-auto h-7 w-56" />
+2 -2
View File
@@ -197,14 +197,14 @@ export default function InvitePage() {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="min-h-dvh flex items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="min-h-screen flex flex-col bg-background">
<div className="min-h-dvh flex flex-col bg-background">
<header className="relative bg-[#141414] text-white overflow-hidden">
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
+2
View File
@@ -8,6 +8,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip-provider";
import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt";
import { ThemeColorSync } from "@/components/system/ThemeColorSync";
import { ThemeProvider } from "@/components/theme-provider";
import { PaletteProvider } from "@/components/providers/PaletteProvider";
import { SWRProvider } from "@/components/providers/SWRProvider";
@@ -96,6 +97,7 @@ export default async function RootLayout({
{children}
<Toaster />
<DeployReloadPrompt />
<ThemeColorSync />
<ScrollbarReveal />
</TooltipProvider>
</SWRProvider>
+1 -1
View File
@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function PayslipLoading() {
return (
<main className="flex min-h-screen items-center justify-center bg-background px-5" aria-busy="true">
<main className="flex min-h-dvh items-center justify-center bg-background px-5" aria-busy="true">
<div className="w-full max-w-2xl space-y-6 rounded-lg border bg-card p-6">
<div className="space-y-2">
<Skeleton className="h-8 w-56" />
+2 -2
View File
@@ -19,7 +19,7 @@ const MONTH_NAMES = [
function MessageShell({ title, body }: { title: string; body: string }) {
return (
<main className="min-h-screen bg-background flex items-center justify-center px-5">
<main className="min-h-dvh bg-background flex items-center justify-center px-5">
<div className="max-w-md w-full rounded-lg border border-border p-6 space-y-2">
<h1 className="font-display text-2xl tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{body}</p>
@@ -99,7 +99,7 @@ export default async function PayslipPage({
const monthName = MONTH_NAMES[run.period_month - 1]
return (
<main className="min-h-screen bg-background flex items-center justify-center px-5 py-10">
<main className="min-h-dvh bg-background flex items-center justify-center px-5 py-10">
<div className="max-w-md w-full rounded-lg border border-border p-6 space-y-6">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{company.name}</p>
+3 -3
View File
@@ -70,7 +70,7 @@ export default function SandboxPage() {
// Loading state while checking auth
if (isLoggedIn === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-background to-primary/[0.03]">
<div className="min-h-dvh flex items-center justify-center bg-gradient-to-b from-background to-primary/[0.03]">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
@@ -79,7 +79,7 @@ export default function SandboxPage() {
// Already logged in as a real user
if (isLoggedIn) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<BrandWordmark size="hero" className="mb-2" />
@@ -107,7 +107,7 @@ export default function SandboxPage() {
// Sandbox landing
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-dvh flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<BrandWordmark size="hero" className="mb-2" />
+1 -1
View File
@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
export function AuthPageSkeleton() {
return (
<main className="flex min-h-screen items-center justify-center bg-frame p-4" aria-busy="true">
<main className="flex min-h-dvh items-center justify-center bg-frame p-4" aria-busy="true">
<div className="w-full max-w-sm">
<Skeleton className="mx-auto mb-8 h-12 w-48" />
<div className="space-y-4 rounded-xl border border-border bg-background p-6">
+15 -3
View File
@@ -10,7 +10,6 @@ import { performCompanySwitch } from '@/lib/company/switch-client'
import { useToast } from '@/components/ui/use-toast'
import { SupportLink } from '@/components/ui/support-link'
import {
Building2,
Check,
ChevronsUpDown,
ChevronRight,
@@ -58,6 +57,19 @@ function accountInitial(name: string | null, email: string | null): string {
return '?'
}
// Company monogram: first letter in a small rounded square. Square = company,
// circle = person (the avatar above), so the two identity marks stay distinct.
function CompanyMark({ name }: { name: string }) {
return (
<span
aria-hidden="true"
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[4px] bg-secondary text-[9px] font-semibold uppercase leading-none text-foreground"
>
{name.trim().charAt(0) || '?'}
</span>
)
}
/**
* Sticky bottom-of-sidebar user block: avatar initials, name, active company,
* chevron. Opens an upward popover aligned with the nav column holding
@@ -246,7 +258,7 @@ export default function UserMenu({
aria-expanded={companiesOpen}
className={cn(menuRow, companiesOpen && 'bg-secondary/60 text-foreground')}
>
<Building2 className="h-4 w-4 flex-shrink-0" />
<CompanyMark name={company?.name || tSwitcher('default_company_name')} />
<span className="flex-1 truncate">
{company?.name || tSwitcher('default_company_name')}
</span>
@@ -288,7 +300,7 @@ export default function UserMenu({
isPending && 'opacity-50',
)}
>
<Building2 className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<CompanyMark name={c.name} />
<span className="min-w-0 flex-1 truncate">{c.name}</span>
{role !== 'owner' && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/60">
+1 -1
View File
@@ -27,7 +27,7 @@ interface DocsLayoutProps {
export function DocsLayout({ currentPath, children }: DocsLayoutProps) {
return (
<div className="min-h-screen bg-background">
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-border">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/docs/api" className="font-display text-xl tracking-tight" style={{ fontWeight: 700 }}>
+28
View File
@@ -0,0 +1,28 @@
'use client'
import { useEffect } from 'react'
/**
* Mirrors the app's real background into <meta name="theme-color"> so the
* browser chrome (iOS status bar, Android address bar) always matches what
* is on screen. A static viewport color cannot: dark mode is a class toggle
* (next-themes) and color palettes swap --background at runtime via
* data-palette, both on <html>.
*/
export function ThemeColorSync() {
useEffect(() => {
const root = document.documentElement
const sync = () => {
const bg = getComputedStyle(root).getPropertyValue('--background').trim()
if (!bg) return
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', `hsl(${bg})`)
}
sync()
const observer = new MutationObserver(sync)
observer.observe(root, { attributes: true, attributeFilter: ['class', 'data-palette'] })
return () => observer.disconnect()
}, [])
return null
}
+10 -7
View File
@@ -11,20 +11,23 @@ const buttonVariants = cva(
{
variants: {
variant: {
// active: mirrors hover: on every variant. Tailwind 4 gates hover:
// behind (hover: hover), so on touch devices these are the only
// pointer-down feedback a button gives.
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
"bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/90",
outline:
"border border-input bg-transparent hover:bg-secondary",
"border border-input bg-transparent hover:bg-secondary active:bg-secondary",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/70",
"bg-secondary text-secondary-foreground hover:bg-secondary/70 active:bg-secondary/70",
ghost:
"hover:bg-secondary hover:text-secondary-foreground",
"hover:bg-secondary hover:text-secondary-foreground active:bg-secondary active:text-secondary-foreground",
link:
"text-primary underline-offset-4 hover:underline",
"text-primary underline-offset-4 hover:underline active:underline",
success:
"bg-success text-success-foreground hover:bg-success/90",
"bg-success text-success-foreground hover:bg-success/90 active:bg-success/90",
},
size: {
default: "px-4 py-[7px] text-[13px]",
+1 -1
View File
@@ -52,7 +52,7 @@ describe('branding service', () => {
expect(b.faviconPath).toBe('/favicon.ico')
expect(b.appleTouchIconPath).toBe('/icons/icon-192.png')
expect(b.pwaIconBasePath).toBe('/icons')
expect(b.themeColor).toBe('#304D83')
expect(b.themeColor).toBe('#ffffff')
expect(b.manifestThemeColor).toBe('#1a1a1a')
expect(b.manifestBackgroundColor).toBe('#ffffff')
expect(b.hiddenNavHrefs).toEqual([])
+4 -1
View File
@@ -75,7 +75,10 @@ const DEFAULT_BRANDING: BrandingConfig = {
faviconPath: '/favicon.ico',
appleTouchIconPath: '/icons/icon-192.png',
pwaIconBasePath: '/icons',
themeColor: '#304D83',
// SSR-initial browser-chrome color (iOS status bar / Android address bar).
// Matches light --background; after hydration ThemeColorSync keeps the meta
// tag in step with the active theme and palette.
themeColor: '#ffffff',
manifestThemeColor: '#1a1a1a',
manifestBackgroundColor: '#ffffff',
hiddenNavHrefs: [],