Files
accounted/app/globals.css
T
93e99012d7 feat(supplier-invoices): dokument-forst editor rebuild (prototype shell + 4 flow optimizations) (#1653)
* refactor(supplier-invoices): extract payload builder and form hooks, pin wire contract with parity tests

Zero visual/behavioral change. Pulls the pure payload builder
(buildSupplierInvoicePayload + inferVatTreatment + vatRateFromAi) out of
NewSupplierInvoiceForm into lib/supplier-invoices/form-payload.ts and pins
it with a mode/feature-matrix parity test suite (document_id vs inbox,
privately paid due-date default, reverse charge rate forcing, accrual
attach/drop, dimensions bags, apply_slp validity, FX parsing, empty-string
stripping, ore_rounding passthrough).

Also extracts, verbatim: the VatRateCell/RcRateSelect cells, the reference
data loading hook (suppliers/accounts/settings/periods), the inbox AI
prefill hook (exposing applyInboxItem for reuse), and the submit
orchestration hook (endpoint chooser, three submit paths, duplicate-number
conflict recovery, inbox field sync-back).

Deliberately NOT moved: the effect-ordering couplings
(pendingAccountFillRef/accountFillTick supplier-defaults dance, the
icke-momsregistrerad gross-up re-run keyed on hasPrefilled, the RC
accrual-clearing effect, per-currency FX touched flags) stay in the
component untouched; their ordering semantics are load-bearing.

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

* feat(supplier-invoices): dokument-forst editor rebuild with prototype shell and four flow optimizations

Rebuilds NewSupplierInvoiceForm to the approved Leverantorsflodet prototype:
single 640px column, flat sections (Underlag first, then Leverantor,
Fakturauppgifter, Kontering, Forval, Summering), honest state marks
(RequiredMark, sage checks for binary facts, muted row counts), a single
ochre next-step line (aria-live polite) whose link focuses the missing
field, and a sticky bottom action bar with the live total that binds to the
dialog scroll container in bare mode and the page panel scroll standalone.

Dokument-forst (1): the standalone upload now tries the invoice-inbox
pipeline over HTTP first (POST upload, poll items/:id past 'processing'),
then runs the same applyInboxItem prefill path as an inbox arrival
(settle tint on filled fields, reset(getValues()) dirty baseline, submit
through the convert endpoint so the document links and the item is stamped).
Extension off or extraction failed degrades to the plain /api/documents
attachment; manual entry is never blocked.

Total cross-check (2): optional "Totalt enligt fakturan" field in
Summering, client-only compare against the displayed payable (sage match
line, terracotta diff line), prefilled from extraction totals.

Duplicate advisory (3): new index-only GET /api/supplier-invoices/exists
(withRouteContext + validateQuery, mirrors the partial unique index's
credited/reversed exclusion, full route tests), debounce-called on
fakturanummer change; terracotta field-adjacent line with a link to the
existing invoice. The structured 409 conflict dialog stays the backstop.

Terms-based due date (4): muted caption "Fran leverantorens villkor
(N dagar)" when auto-set, re-derives on invoice-date and supplier change,
stops the moment the user or the AI supplies a date; terms 0 leaves the
field empty with "Star pa fakturan".

OCR hint (5): "Anvands i betalningsfilen." under the payment reference when
the chosen supplier has bankgiro or plusgiro.

Table model: rows start empty; the ghost tfoot entry row (never part of
form state) commits an account via the existing AccountCombobox (opens on
focus, Enter commits) and moves focus to the new row's amount cell; the
supplier default/history fill plants the first row when the table is empty.
Row controls are hover-revealed via HOVER_REVEAL_CLASS at a 24px hit area
with per-row aria-labels carrying the description. The primary button is
never disabled pre-click for writable users (in-flight only); every
submit-time hard block stays in onSubmit; viewers keep the lock treatment.

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

* fix(supplier-invoices): re-run gross-up per apply, guard deferred prefill, honest un-plant

- Gross-up/zero-rate pass for icke momsregistrerade re-runs per applied
  extraction (applyCount bumps in applyInboxItem) instead of keying on the
  one-shot hasPrefilled flag: a remove + re-upload could previously push AI
  25 % rates to the convert endpoint with the moms columns hidden.
- Deferred extraction on the standalone upload path no longer overwrites what
  the user typed mid-poll: the result auto-applies only while the form is
  pristine (live isDirty ref), otherwise it is buffered behind a quiet
  "Tolkning klar" click-to-apply line. Inbox arrivals are unchanged.
- Supplier-switch un-plant keeps rows the user edited in ANY field, not just
  amount (plant-time snapshot compare in lib/supplier-invoices/planted-rows.ts,
  since dirtyFields is unreliable for appended array rows), clearing only the
  stale account; untouched plant-created rows are still removed and rows that
  existed before the fill are never removed.
- default_expense_account plants now register in plantedRef too, so a supplier
  switch un-plants them under the same rules as history plants.
- applyInboxItem reads suppliers through a ref: the 90 s poll no longer
  resolves matched suppliers against a stale empty list.
- The duplicate advisory bumps its seq in the clear branch, so an in-flight
  exists response cannot resurrect a warning under a cleared field.
- Drop 7 orphaned supplier_invoice_editor keys from both message files.

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

* fix(supplier-invoices): retry the entry-row focus hand-off on the next frame

A single requestAnimationFrame after appending the row can fire before the
new amount input's ref is mounted, silently dropping the focus hand-off
(observed in headless verification). One retry frame makes the signature
interaction reliable.

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

* fix(supplier-invoices): deterministic entry-row focus hand-off via effect

The rAF retry still lost to the dialog focus scope re-parking focus when
the entry input remounts mid-commit. An effect keyed on the pending row
index runs after the new row's input has mounted and wins deterministically.

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

* fix(supplier-invoices): comma-tolerant amount cell and surviving focus routing

The focus trace exposed two real issues behind a probe mystery: the amount
cell was type=number (ArrowDown decrements money by 0.01, Enter fires the
form's implicit submit mid-edit, and Swedish comma decimals are rejected
outright), and the supplier menu's close-autofocus yanked focus back to
the trigger, undoing the routed hand-off to the invoice-number field.

AmountCell mirrors VatRateCell's draft pattern: text input with decimal
inputMode, digits-and-one-separator whitelist, Enter commits via blur.
The supplier DropdownMenuContent prevents default close autofocus.

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

* fix(supplier-invoices): show comma decimals in the amount cell display

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

* fix(supplier-invoices): stop dialog grid item overflowing small viewports

min-w-0 on the form root (DialogContent is display:grid, so the kontering
table's min-w otherwise forces the column past narrow screens) and wrap
the sticky-bar action cluster.

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>
2026-08-18 09:50:48 +02:00

841 lines
26 KiB
CSS

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
/* ─────────────────────────────────────────────────────────────────────────
* Spacing scale: locked.
* Card padding: p-6. Section gap: space-y-8. Form/control gap: gap-4.
* Allowed Tailwind values: 1, 2, 3, 4, 6, 8, 10, 12. No 2.5 / 5.
* Sidebar width is md:w-64 (256px); main offset matches md:pl-64.
* ───────────────────────────────────────────────────────────────────────── */
:root {
color-scheme: light;
/* Sidebar column width (frame layout). Default for shells that don't
override it; the dashboard shell (#dash-shell) sets it inline from
user_preferences.ui_state (248px expanded / 64px collapsed rail) and
the nav toggle updates it client-side. aside and <main> both read the
variable so they stay in lockstep. */
--nav-w: 248px;
/* Width the docked assistant panel claims from the page panel. Default is
the plain frame gutter, so the layout is correct before any JS runs; the
agent provider raises it while the panel is docked and clears it again. */
--agent-dock-w: 10px;
/* Grayscale Chrome Palette: paper white + warm beige */
--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%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 100%;
--secondary: 40 11% 89%; /* Warm beige paper chip */
--secondary-foreground: 0 0% 9%;
--muted: 40 8% 93%;
--muted-foreground: 0 0% 40%;
--accent: 40 8% 93%;
--accent-foreground: 0 0% 9%;
--destructive: 6 36% 48%; /* Muted terracotta */
--destructive-foreground: 0 0% 100%;
--border: 45 5% 85%;
--input: 45 5% 85%;
--ring: 0 0% 9%;
/* Radius */
--radius: 0.5rem;
/* Success: Sage green */
--success: 155 25% 40%;
--success-foreground: 0 0% 100%;
/* Warning: Muted ochre */
--warning: 38 55% 50%;
--warning-foreground: 0 0% 9%;
/* Attn: darker ochre for the one-sentence attention line (.attn / AttnLine),
derived from --warning but AA-safe as small text on the page background. */
--attn: 38 50% 34%;
--warm-accent: 38 45% 52%;
/* Chart colors */
--chart-1: 155 25% 40%;
--chart-2: 6 36% 48%;
--chart-3: 38 55% 50%;
--chart-4: 0 0% 50%;
/* Custom tokens */
--font-display: var(--font-hedvig-serif), Georgia, serif;
--font-body: var(--font-geist-sans), system-ui, sans-serif;
/* Shadows: barely-there; chrome relies on hairline borders */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.03), 0 1px 1px rgba(15, 23, 42, 0.02);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.03);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.05);
/* Animation */
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
/* Strong ease-out for content entry. The --ease-out above is easeOutQuad,
the weak built-in shape; it is deliberately left alone because this :root
block is unlayered and therefore shadows Tailwind's own `ease-out` token,
so retiming it would retime every ease-out utility in the app. New entry
animations should use this one. */
--ease-emphasized: cubic-bezier(0.23, 1, 0.32, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-base: 300ms;
--duration-slow: 500ms;
}
/* While the assistant panel's width handle is being dragged, the page margin
must track the cursor 1:1: the page panel's 300ms margin transition would
trail the drag and leave a gap. AgentSheet sets the attribute for the
duration of the drag only. */
:root[data-agent-resizing] #main-content {
transition: none !important;
}
.dark {
color-scheme: dark;
--background: 0 0% 7%;
--foreground: 0 0% 87%;
--frame: 0 0% 5%;
--card: 0 0% 11%;
--card-foreground: 0 0% 87%;
--popover: 0 0% 11%;
--popover-foreground: 0 0% 87%;
--primary: 0 0% 87%;
--primary-foreground: 0 0% 7%;
--secondary: 0 0% 14%;
--secondary-foreground: 0 0% 87%;
--muted: 0 0% 16%;
--muted-foreground: 0 0% 50%;
--accent: 0 0% 16%;
--accent-foreground: 0 0% 87%;
--destructive: 6 32% 56%;
--destructive-foreground: 0 0% 100%;
--border: 45 4% 18%;
--input: 45 4% 18%;
--ring: 0 0% 87%;
--success: 155 22% 48%;
--success-foreground: 0 0% 100%;
--warning: 38 50% 55%;
--warning-foreground: 38 50% 90%;
--attn: 38 45% 62%;
--warm-accent: 38 42% 58%;
--chart-1: 155 22% 48%;
--chart-2: 6 32% 56%;
--chart-3: 38 50% 55%;
--chart-4: 0 0% 55%;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
}
/* Color palettes are independent of appearance mode. next-themes continues
to own the .dark class while data-palette replaces only semantic chrome
tokens. Preview swatches share these rules so their colors cannot drift. */
:root[data-palette="neutral"],
[data-palette-preview="neutral"] {
--background: 0 0% 100%;
--primary: 0 0% 9%;
--secondary: 40 11% 89%;
--border: 45 5% 85%;
}
.dark[data-palette="neutral"],
.dark [data-palette-preview="neutral"] {
--background: 0 0% 7%;
--primary: 0 0% 87%;
--secondary: 0 0% 14%;
--border: 45 4% 18%;
}
:root[data-palette="indigo"],
[data-palette-preview="indigo"] {
--background: 228 42% 98%;
--foreground: 229 30% 14%;
--frame: 228 52% 94%;
--card: 0 0% 100%;
--card-foreground: 229 30% 14%;
--popover: 0 0% 100%;
--popover-foreground: 229 30% 14%;
--primary: 231 48% 34%;
--primary-foreground: 225 40% 98%;
--secondary: 229 42% 89%;
--secondary-foreground: 229 30% 14%;
--muted: 228 36% 93%;
--muted-foreground: 229 20% 38%;
--accent: 228 36% 93%;
--accent-foreground: 229 30% 14%;
--border: 229 24% 78%;
--input: 229 24% 78%;
--ring: 231 48% 34%;
}
.dark[data-palette="indigo"],
.dark [data-palette-preview="indigo"] {
--background: 231 28% 7%;
--foreground: 228 22% 89%;
--frame: 232 34% 4%;
--card: 231 24% 11%;
--card-foreground: 228 22% 89%;
--popover: 231 24% 11%;
--popover-foreground: 228 22% 89%;
--primary: 228 58% 74%;
--primary-foreground: 231 25% 10%;
--secondary: 231 28% 16%;
--secondary-foreground: 228 22% 89%;
--muted: 230 23% 18%;
--muted-foreground: 228 16% 64%;
--accent: 230 23% 18%;
--accent-foreground: 228 22% 89%;
--border: 231 20% 26%;
--input: 231 20% 26%;
--ring: 228 58% 74%;
}
:root[data-palette="forest"],
[data-palette-preview="forest"] {
--background: 145 25% 98%;
--foreground: 150 24% 12%;
--frame: 140 38% 93%;
--card: 0 0% 100%;
--card-foreground: 150 24% 12%;
--popover: 0 0% 100%;
--popover-foreground: 150 24% 12%;
--primary: 151 42% 28%;
--primary-foreground: 140 30% 98%;
--secondary: 140 30% 87%;
--secondary-foreground: 150 24% 12%;
--muted: 140 24% 92%;
--muted-foreground: 148 18% 34%;
--accent: 140 24% 92%;
--accent-foreground: 150 24% 12%;
--border: 142 20% 76%;
--input: 142 20% 76%;
--ring: 151 42% 28%;
}
.dark[data-palette="forest"],
.dark [data-palette-preview="forest"] {
--background: 150 22% 6%;
--foreground: 140 15% 88%;
--frame: 150 28% 4%;
--card: 150 19% 10%;
--card-foreground: 140 15% 88%;
--popover: 150 19% 10%;
--popover-foreground: 140 15% 88%;
--primary: 145 38% 66%;
--primary-foreground: 150 18% 8%;
--secondary: 150 22% 16%;
--secondary-foreground: 140 15% 88%;
--muted: 150 20% 18%;
--muted-foreground: 143 14% 62%;
--accent: 150 20% 18%;
--accent-foreground: 140 15% 88%;
--border: 150 17% 26%;
--input: 150 17% 26%;
--ring: 145 38% 66%;
}
:root[data-palette="sand"],
[data-palette-preview="sand"] {
--background: 42 52% 97%;
--foreground: 28 23% 13%;
--frame: 39 56% 91%;
--card: 42 45% 98%;
--card-foreground: 28 23% 13%;
--popover: 42 45% 98%;
--popover-foreground: 28 23% 13%;
--primary: 24 36% 30%;
--primary-foreground: 42 35% 98%;
--secondary: 38 42% 85%;
--secondary-foreground: 28 23% 13%;
--muted: 40 38% 90%;
--muted-foreground: 28 18% 34%;
--accent: 40 38% 90%;
--accent-foreground: 28 23% 13%;
--border: 36 28% 74%;
--input: 36 28% 74%;
--ring: 24 36% 30%;
}
.dark[data-palette="sand"],
.dark [data-palette-preview="sand"] {
--background: 28 22% 7%;
--foreground: 39 20% 88%;
--frame: 25 26% 4%;
--card: 29 20% 10%;
--card-foreground: 39 20% 88%;
--popover: 29 20% 10%;
--popover-foreground: 39 20% 88%;
--primary: 35 44% 66%;
--primary-foreground: 28 18% 9%;
--secondary: 30 25% 16%;
--secondary-foreground: 39 20% 88%;
--muted: 30 22% 18%;
--muted-foreground: 34 15% 63%;
--accent: 30 22% 18%;
--accent-foreground: 39 20% 88%;
--border: 31 18% 26%;
--input: 31 18% 26%;
--ring: 35 44% 66%;
}
@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));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-attn: hsl(var(--attn));
--color-warm-accent: hsl(var(--warm-accent));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--font-sans: var(--font-body);
--font-mono: var(--font-geist-mono), monospace;
--font-serif: var(--font-display);
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-body);
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
-webkit-font-smoothing: antialiased;
-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 - Hedvig Letters Serif */
.font-display {
font-family: var(--font-display);
font-optical-sizing: auto;
}
/* Layered so utility classes (e.g. font-sans on a pane title) can override:
unlayered element rules would beat Tailwind's layered utilities.
Hedvig ships weight 400 only; anything heavier is browser-synthesized. */
@layer base {
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 400;
letter-spacing: -0.015em;
font-optical-sizing: auto;
}
}
/* Number styling - tabular for alignment */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Text balance for better line wrapping on descriptions */
.text-balance {
text-wrap: balance;
}
/* Safe area padding for mobile devices with notch/home indicator */
.safe-area-bottom {
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
}
/* Main content padding: clears bottom nav + safe area on mobile */
.safe-area-main-padding {
padding-bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
}
/* Animation Keyframes */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes gentlePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Three-dot "typing" indicator used by the agent chat pre-token bubble.
Each dot rides the same wave but with staggered animation-delay so the
bounce reads left-to-right. */
@keyframes typingDot {
0%, 60%, 100% { transform: translateY(0); opacity: 0.35; }
30% { transform: translateY(-3px); opacity: 1; }
}
.animate-typing-dot {
animation: typingDot 1.1s ease-in-out infinite;
}
/* Marching-ants dashed border used by the import entity selector. */
@keyframes marchingAnts {
to { stroke-dashoffset: -14; }
}
.animate-marching-ants {
animation: marchingAnts 1.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-typing-dot {
animation: gentlePulse 1.6s ease-in-out infinite;
transform: none;
}
.animate-marching-ants {
animation: none;
}
}
/* ─────────────────────────────────────────────────────────────────────────
* Enter/exit animation utilities (animate-in / animate-out vocabulary).
* The codebase uses the shadcn/tailwindcss-animate classes but no animate
* plugin was ever installed, so Tailwind v4 silently dropped them all:
* every popover, dialog and slide-over appeared instantly. These implement
* the exact subset in use, plugin-free. The duration and ease utilities
* compose via Tailwind v4's --tw-duration and --tw-ease variables; the
* global prefers-reduced-motion block collapses them like everything else.
* ───────────────────────────────────────────────────────────────────────── */
@keyframes accEnter {
from {
opacity: var(--tw-enter-opacity, 1);
transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0)
scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1);
}
}
@keyframes accExit {
to {
opacity: var(--tw-exit-opacity, 1);
transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0)
scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), 1);
}
}
@utility animate-in {
animation-name: accEnter;
animation-duration: var(--tw-duration, 150ms);
animation-timing-function: var(--tw-ease, cubic-bezier(0.32, 0.72, 0, 1));
animation-fill-mode: both;
}
@utility animate-out {
animation-name: accExit;
animation-duration: var(--tw-duration, 150ms);
animation-timing-function: var(--tw-ease, cubic-bezier(0.32, 0.72, 0, 1));
animation-fill-mode: both;
}
@utility fade-in { --tw-enter-opacity: 0; }
@utility fade-in-0 { --tw-enter-opacity: 0; }
@utility fade-out { --tw-exit-opacity: 0; }
@utility fade-out-0 { --tw-exit-opacity: 0; }
@utility zoom-in-95 { --tw-enter-scale: 0.95; }
@utility zoom-out-95 { --tw-exit-scale: 0.95; }
@utility zoom-in-98 { --tw-enter-scale: 0.98; }
@utility zoom-out-98 { --tw-exit-scale: 0.98; }
@utility slide-in-from-top { --tw-enter-translate-y: -100%; }
@utility slide-in-from-top-1 { --tw-enter-translate-y: -0.25rem; }
@utility slide-in-from-top-2 { --tw-enter-translate-y: -0.5rem; }
@utility slide-in-from-top-full { --tw-enter-translate-y: -100%; }
@utility slide-in-from-bottom { --tw-enter-translate-y: 100%; }
@utility slide-in-from-bottom-1 { --tw-enter-translate-y: 0.25rem; }
@utility slide-in-from-bottom-2 { --tw-enter-translate-y: 0.5rem; }
@utility slide-in-from-left { --tw-enter-translate-x: -100%; }
@utility slide-in-from-left-1 { --tw-enter-translate-x: -0.25rem; }
@utility slide-in-from-left-2 { --tw-enter-translate-x: -0.5rem; }
@utility slide-in-from-right { --tw-enter-translate-x: 100%; }
@utility slide-in-from-right-2 { --tw-enter-translate-x: 0.5rem; }
@utility slide-in-from-right-full { --tw-enter-translate-x: 100%; }
@utility slide-out-to-top { --tw-exit-translate-y: -100%; }
@utility slide-out-to-bottom { --tw-exit-translate-y: 100%; }
@utility slide-out-to-left { --tw-exit-translate-x: -100%; }
@utility slide-out-to-right { --tw-exit-translate-x: 100%; }
@utility slide-out-to-right-full { --tw-exit-translate-x: 100%; }
/* Staggered entrance animation (convention 11). This is the standard content
entry on every migrated page, so its budget matters more than any single
surface: 300ms per item on the emphasized curve, in 40ms steps.
It used to be 500ms per item in 80ms steps, which put the 10th row at
1220ms, and it only defined delays for children 1-10, so on any list longer
than ten rows children 11+ inherited delay 0 and arrived BEFORE the middle
of the list.
The cap is on the delay, never on the animation: `both` holds a child at
opacity 0 until its delay elapses, so excluding rows 11+ from the animation
(nth-child(-n+10)) would paint them at full opacity while rows 1-10 were
still invisible. Rows carrying data-no-stagger opt out entirely, for the
case of a foldout <tr> that runs its own transition inside a staggered
tbody and would otherwise animate twice. */
.stagger-enter > *:not([data-no-stagger]) {
animation: slideUp var(--duration-base) var(--ease-emphasized) both;
}
.stagger-enter > *:nth-child(1) { animation-delay: 0ms; }
.stagger-enter > *:nth-child(2) { animation-delay: 40ms; }
.stagger-enter > *:nth-child(3) { animation-delay: 80ms; }
.stagger-enter > *:nth-child(4) { animation-delay: 120ms; }
.stagger-enter > *:nth-child(5) { animation-delay: 160ms; }
.stagger-enter > *:nth-child(6) { animation-delay: 200ms; }
.stagger-enter > *:nth-child(7) { animation-delay: 240ms; }
.stagger-enter > *:nth-child(8) { animation-delay: 280ms; }
.stagger-enter > *:nth-child(9) { animation-delay: 320ms; }
.stagger-enter > *:nth-child(n+10) { animation-delay: 360ms; }
/* Row exit (booking/ignore/delete in dry-table lists). The page keeps the
row rendered for a 350ms window (see finishBooking on /transactions) and
marks it .row-exit; the row fades fast, then the space closes by
transitioning the cell paddings/line metrics to zero. Table rows cannot
animate height directly, so fixed-height cell content (action pill,
badges) carries .row-collapsible: a numeric max-height at rest, so the
transition to 0 interpolates instead of snapping. The stagger-enter
animation on the same row has long finished; `both` fill keeps it inert.
All timings stay inside the 350ms removal window.
pointer-events only guards the pointer: the exiting row also carries the
`inert` attribute (set by the row components) so keyboard focus and
activation are blocked during the window too. */
.row-exit {
pointer-events: none;
}
.row-exit > td {
opacity: 0;
padding-top: 0;
padding-bottom: 0;
line-height: 0;
font-size: 0;
border-bottom-width: 0;
transition:
opacity 160ms var(--ease-out),
padding-top 240ms var(--ease-emphasized) 60ms,
padding-bottom 240ms var(--ease-emphasized) 60ms,
line-height 240ms var(--ease-emphasized) 60ms,
font-size 240ms var(--ease-emphasized) 60ms,
border-bottom-width 240ms var(--ease-emphasized) 60ms;
}
.row-collapsible {
/* Numeric rest value so the exit transition has something to interpolate
from; comfortably above the tallest row content (h-7 action pill). */
max-height: 2.5rem;
}
.row-exit .row-collapsible {
max-height: 0;
overflow: hidden;
transform: translateX(0.5rem);
transition:
max-height 240ms var(--ease-emphasized) 60ms,
transform 240ms var(--ease-out);
}
/* Reduced motion: the row is removed instantly; the delayed state cleanup
in the page is invisible. */
@media (prefers-reduced-motion: reduce) {
.row-exit {
display: none;
}
}
/* Utility animations */
.animate-fade-in {
animation: fadeIn var(--duration-base) var(--ease-out);
}
.animate-slide-up {
animation: slideUp var(--duration-slow) var(--ease-out);
}
.animate-scale-in {
animation: scaleIn var(--duration-base) var(--ease-out);
}
/* Chrome/Safari autofill repaints the field in a browser-chosen blue/yellow
that ignores the theme. Painting an inset shadow in the input's own surface
color over it keeps autofilled fields inside the achromatic palette; the
text and caret colors follow the foreground token. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px hsl(var(--card)) inset;
-webkit-text-fill-color: hsl(var(--foreground));
caret-color: hsl(var(--foreground));
}
/* Soft focus states */
.focus-ring:focus-visible {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--background)),
0 0 0 4px hsl(var(--primary) / 0.35);
}
/* Overlay-style scrollbars: invisible at rest, revealed only while the
container is actually scrolling. ScrollbarReveal (mounted in the root
layout) toggles .is-scrolling on the scrolled element; the thumb fades
back out when scrolling stops. The gutter is reserved either way, so
revealing never shifts layout. */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
}
.is-scrolling::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.25);
}
@supports (scrollbar-color: auto) {
/* Layered: the universal transition must not beat Tailwind's layered
transition-* utilities (unlayered rules win over layers regardless of
specificity: this silently killed every width/margin/color transition
in the app). scrollbar-color still animates via the base default. */
@layer base {
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 300ms ease-out;
}
}
.is-scrolling {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
}
/* Hover-reveal fallback for touch: coarse pointers have no hover, so the
controls that desktop reveals on hover (row checkboxes, chevrons, quiet
row actions) stay visible. Targets exactly the opacity-0 +
group-hover:opacity-100 pairing and nothing else. */
@media (pointer: coarse) {
.group .opacity-0.group-hover\:opacity-100 {
opacity: 1;
}
}
/* Always-visible thumb for modal scroll containers (DialogContent,
SlideOverBody): inside an overlay the scrollbar is the only cue that
content continues below the fold, so the at-rest auto-hide would read
as "the dialog is cut off". */
.scrollbar-visible::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.25);
}
@supports (scrollbar-color: auto) {
.scrollbar-visible {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
}
/* Hide scrollbar utility */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide number input spinners */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* Selection color */
::selection {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--foreground));
}
.dark ::selection {
background: hsl(var(--primary) / 0.3);
color: hsl(var(--foreground));
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.stagger-enter > * {
animation: none;
opacity: 1;
}
}
/* Indeterminate sweep for the receipt hunt.
A pass runs for over two minutes and reports nothing until it lands, so
there is no honest percentage to show inside one. This says "still working"
without inventing progress. Reduced motion holds it still and dims it
rather than removing it: the band still has to read as active. */
@keyframes hunt-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
.hunt-sweep {
animation: hunt-sweep 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hunt-sweep {
animation: none;
width: 100%;
opacity: 0.35;
}
}
/* Brief sage settle tint on tolkning-filled fields (supplier-invoice
dokument-först flow): the field flashes softly as the extraction lands and
fades back to its normal surface. Runs once per class application; the
global prefers-reduced-motion block collapses the animation, so reduced
motion sees the filled value with no flash. */
@keyframes prefill-settle {
0% { background-color: hsl(var(--success) / 0.16); }
100% { background-color: transparent; }
}
.prefill-settle {
animation: prefill-settle 0.9s ease-out both;
}