From 8f1b1fb5cd9ffe00b3624afdcfce659227a46545 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 10:41:45 +0200 Subject: [PATCH] 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 * 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 * docs: log monogram and overscroll decisions Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + app/(auth)/account/set-password/page.tsx | 2 +- app/(auth)/login/login-client.tsx | 6 +-- app/(auth)/mfa/enroll/page.tsx | 4 +- app/(auth)/mfa/verify/page.tsx | 2 +- app/(auth)/register/page.tsx | 6 +-- app/(auth)/reset-password/page.tsx | 2 +- app/(dashboard)/layout.tsx | 8 ++-- app/(onboarding)/layout.tsx | 2 +- app/(public)/dpa/page.tsx | 2 +- app/(public)/invoice-action/[token]/page.tsx | 8 ++-- app/(public)/privacy/page.tsx | 2 +- app/companies/loading.tsx | 2 +- app/companies/new/page.tsx | 2 +- app/global-error.tsx | 2 +- app/globals.css | 49 ++++++++++++++++++++ app/invite/[token]/loading.tsx | 2 +- app/invite/[token]/page.tsx | 4 +- app/layout.tsx | 2 + app/payslip/[token]/loading.tsx | 2 +- app/payslip/[token]/page.tsx | 4 +- app/sandbox/page.tsx | 6 +-- components/auth/AuthPageSkeleton.tsx | 2 +- components/dashboard/UserMenu.tsx | 18 +++++-- components/docs/DocsLayout.tsx | 2 +- components/system/ThemeColorSync.tsx | 28 +++++++++++ components/ui/button.tsx | 17 ++++--- lib/branding/__tests__/service.test.ts | 2 +- lib/branding/service.ts | 5 +- 29 files changed, 147 insertions(+), 48 deletions(-) create mode 100644 components/system/ThemeColorSync.tsx diff --git a/DECISIONS.md b/DECISIONS.md index efaa7e8a..da72b33c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -876,6 +876,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(auth)/account/set-password/page.tsx b/app/(auth)/account/set-password/page.tsx index 72658707..f193d6ed 100644 --- a/app/(auth)/account/set-password/page.tsx +++ b/app/(auth)/account/set-password/page.tsx @@ -120,7 +120,7 @@ function SetPasswordContent() { } return ( -
+
diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx index f237980d..adf70b64 100644 --- a/app/(auth)/login/login-client.tsx +++ b/app/(auth)/login/login-client.tsx @@ -368,7 +368,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu const webmailHint = detectWebmailHint(email, branding.authEmailFrom) return ( -
+
@@ -428,7 +428,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu // Reset password form if (showResetPassword) { return ( -
+
@@ -493,7 +493,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + (googleAuthEnabled ? 1 : 0) return ( -
+

{tAuth('login_title')}

diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx index e46367f6..8f2a6c16 100644 --- a/app/(auth)/mfa/enroll/page.tsx +++ b/app/(auth)/mfa/enroll/page.tsx @@ -175,7 +175,7 @@ function MfaEnrollContent() { // Step 1: Show enroll button if (!qrCode) { return ( -
+
@@ -229,7 +229,7 @@ function MfaEnrollContent() { // Step 2: Show QR code and verification return ( -
+
diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index b099403b..25690ea3 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -170,7 +170,7 @@ function MfaVerifyContent() { } return ( -
+
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index ed9b3a06..10b31ee5 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -384,7 +384,7 @@ function RegisterPageContent() { if (duplicateEmail) { return ( -
+
@@ -437,7 +437,7 @@ function RegisterPageContent() { const webmailHint = detectWebmailHint(email, branding.authEmailFrom) return ( -
+
@@ -485,7 +485,7 @@ function RegisterPageContent() { } return ( -
+

{t('create_account')}

diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 6ad60d1d..ce8bf9a5 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -208,7 +208,7 @@ function ResetPasswordInner() { : t('subtitle') return ( -
+
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index b9594325..7fc20c27 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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({ -
+
-
+
{/* Skip to content link for keyboard/screen reader users */} diff --git a/app/(onboarding)/layout.tsx b/app/(onboarding)/layout.tsx index f69551da..ad019cc6 100644 --- a/app/(onboarding)/layout.tsx +++ b/app/(onboarding)/layout.tsx @@ -35,7 +35,7 @@ export default async function OnboardingLayout({ } return ( -
+
{user && } diff --git a/app/(public)/dpa/page.tsx b/app/(public)/dpa/page.tsx index 12aad4a0..0e5eedc1 100644 --- a/app/(public)/dpa/page.tsx +++ b/app/(public)/dpa/page.tsx @@ -12,7 +12,7 @@ export function generateMetadata(): Metadata { export default function DPAPage() { const { appName, legalEntity, privacyEmail } = getBranding() return ( -
+

diff --git a/app/(public)/invoice-action/[token]/page.tsx b/app/(public)/invoice-action/[token]/page.tsx index 21dce74b..298fe1c6 100644 --- a/app/(public)/invoice-action/[token]/page.tsx +++ b/app/(public)/invoice-action/[token]/page.tsx @@ -102,7 +102,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token: if (isLoading) { return ( -
+

Laddar...

@@ -113,7 +113,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token: if (error && !invoice) { return ( -
+
@@ -132,7 +132,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token: // Already responded view if (invoice.alreadyResponded || successMessage) { return ( -
+
@@ -166,7 +166,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token: const feeDueSeparately = invoice.feeDueSeparately ?? 0 return ( -
+
{/* Header */}
diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx index 4f19a953..189756a4 100644 --- a/app/(public)/privacy/page.tsx +++ b/app/(public)/privacy/page.tsx @@ -12,7 +12,7 @@ export function generateMetadata(): Metadata { export default function PrivacyPolicyPage() { const { appName, legalEntity, privacyEmail } = getBranding() return ( -
+

diff --git a/app/companies/loading.tsx b/app/companies/loading.tsx index 87a11e93..b6062a34 100644 --- a/app/companies/loading.tsx +++ b/app/companies/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton' export default function CompaniesLoading() { return ( -
+
diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx index e57cc3d0..07c15455 100644 --- a/app/companies/new/page.tsx +++ b/app/companies/new/page.tsx @@ -36,7 +36,7 @@ export default async function NewCompanyPage() { } return ( -
+
diff --git a/app/global-error.tsx b/app/global-error.tsx index ffff5c91..e8ea0d73 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -63,7 +63,7 @@ export default function GlobalError({ {phase === "fallback" ? ( -
+

Något gick fel

diff --git a/app/globals.css b/app/globals.css index 5c725377..983dfccd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 { diff --git a/app/invite/[token]/loading.tsx b/app/invite/[token]/loading.tsx index a1318361..ac571abc 100644 --- a/app/invite/[token]/loading.tsx +++ b/app/invite/[token]/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton' export default function InviteLoading() { return ( -

+
diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index d5859f42..c40ac47e 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -197,14 +197,14 @@ export default function InvitePage() { if (isLoading) { return ( -
+
) } return ( -
+
+ diff --git a/app/payslip/[token]/loading.tsx b/app/payslip/[token]/loading.tsx index 9ad9f684..2150139f 100644 --- a/app/payslip/[token]/loading.tsx +++ b/app/payslip/[token]/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton' export default function PayslipLoading() { return ( -
+
diff --git a/app/payslip/[token]/page.tsx b/app/payslip/[token]/page.tsx index b0c85f36..9c956b15 100644 --- a/app/payslip/[token]/page.tsx +++ b/app/payslip/[token]/page.tsx @@ -19,7 +19,7 @@ const MONTH_NAMES = [ function MessageShell({ title, body }: { title: string; body: string }) { return ( -
+

{title}

{body}

@@ -99,7 +99,7 @@ export default async function PayslipPage({ const monthName = MONTH_NAMES[run.period_month - 1] return ( -
+

{company.name}

diff --git a/app/sandbox/page.tsx b/app/sandbox/page.tsx index fcd931dc..3027a4be 100644 --- a/app/sandbox/page.tsx +++ b/app/sandbox/page.tsx @@ -70,7 +70,7 @@ export default function SandboxPage() { // Loading state while checking auth if (isLoggedIn === null) { return ( -
+
) @@ -79,7 +79,7 @@ export default function SandboxPage() { // Already logged in as a real user if (isLoggedIn) { return ( -
+
@@ -107,7 +107,7 @@ export default function SandboxPage() { // Sandbox landing return ( -
+
diff --git a/components/auth/AuthPageSkeleton.tsx b/components/auth/AuthPageSkeleton.tsx index 8fc0b166..b7857bce 100644 --- a/components/auth/AuthPageSkeleton.tsx +++ b/components/auth/AuthPageSkeleton.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton' export function AuthPageSkeleton() { return ( -
+
diff --git a/components/dashboard/UserMenu.tsx b/components/dashboard/UserMenu.tsx index ce428444..64616f20 100644 --- a/components/dashboard/UserMenu.tsx +++ b/components/dashboard/UserMenu.tsx @@ -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 ( + + ) +} + /** * 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')} > - + {company?.name || tSwitcher('default_company_name')} @@ -288,7 +300,7 @@ export default function UserMenu({ isPending && 'opacity-50', )} > - + {c.name} {role !== 'owner' && ( diff --git a/components/docs/DocsLayout.tsx b/components/docs/DocsLayout.tsx index ffe6473b..52f83be5 100644 --- a/components/docs/DocsLayout.tsx +++ b/components/docs/DocsLayout.tsx @@ -27,7 +27,7 @@ interface DocsLayoutProps { export function DocsLayout({ currentPath, children }: DocsLayoutProps) { return ( -
+
diff --git a/components/system/ThemeColorSync.tsx b/components/system/ThemeColorSync.tsx new file mode 100644 index 00000000..4ddb8c8d --- /dev/null +++ b/components/system/ThemeColorSync.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useEffect } from 'react' + +/** + * Mirrors the app's real background into 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 . + */ +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 +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx index b815e3cf..a909f23e 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -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]", diff --git a/lib/branding/__tests__/service.test.ts b/lib/branding/__tests__/service.test.ts index 9125e859..425e8933 100644 --- a/lib/branding/__tests__/service.test.ts +++ b/lib/branding/__tests__/service.test.ts @@ -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([]) diff --git a/lib/branding/service.ts b/lib/branding/service.ts index 81e9ab90..2279a235 100644 --- a/lib/branding/service.ts +++ b/lib/branding/service.ts @@ -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: [],