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: [],