2b5b813b7a
* refactor(invoices): extract editor payload builders with parity tests Extract the three near-identical inline payload builders in InvoiceEditor.tsx (handleConfirm, saveDraftData, saveEdit) and the self-billed body mapper into pure functions in lib/invoices/editor-payload.ts. Zero behavioral change: the new lib module carries a 300-case parity suite asserting JSON byte equality against verbatim copies of the legacy inline recipes across the full mode x deduction x dimensions x ore-rounding matrix. This is the byte-compatibility ratchet under the upcoming editor re-layout: the repo renders no components in tests, so the wire bodies are what CI can pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): rebuild the invoice editor as the snabbflöde single column Reshape InvoiceEditor to the approved prototype: one 640px column with uppercase section labels and honest state marks (RequiredMark asterisks, sage check on a picked customer, muted row counts), a dense in-table rows surface with a unified last-row entry (autocomplete over the artikelregister, italic ghost cells, Enter commits free text and lands in the price cell, ArrowDown+Enter commits an article through the same applyArticle side effects), hover-revealed 24px row controls with 40px coarse-pointer targets and per-row aria-labels, a Förval chip line whose collapsed settings re-surface as chips whenever a value deviates from its default (critical in edit/copy so PATCH never round-trips invisible values), a single ochre next-step line (aria-live polite) that doubles as the invalid-submit focus router, and a sticky bottom action bar with the live total: position sticky in both hosts, never fixed, since DialogContent's transform re-anchors fixed children in bare mode. Behavioral deltas, all pre-decided: the primary action is never disabled pre-click for writable users (viewers keep the lock+tooltip treatment); client-side validation failures route focus instead of toasting; genuine field errors stay terracotta and field-adjacent while the two ochre disclosures (taxed-where-performed, labor-only) demote to muted text; committed free-text rows expose a quiet Spara-som-artikel link; the review dialog lists the applied förval (currency, öre rounding, payment-link state); a freshly committed row gets a brief background settle that collapses under prefers-reduced-motion. ArticleCombobox gains the missing combobox ARIA (listbox/option roles, aria-controls, aria-activedescendant only after explicit arrowing). New pure module invoice-editor-flow.ts pins the next-step priority order, the Förval chip derivation and the suggestion filter with unit tests. All payload builders, submit targets and the VAT baseline refs are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): editor review nits: orphaned keys, housing gate, listbox ARIA Three review findings on the snabbflode editor: - Delete 13 orphaned invoice_editor keys from both message files (subtitle_*, add_row, remove_row, remove_row_aria, details_card_title, save_as_draft_short, validation_toast_*, delivery_date_placeholder); each verified unused on the branch, sv/en parity kept. - Gate the housing next-step on a claimed deduction amount so it matches the ROT/RUT claim card's mount condition: a ROT-flagged line with a zero amount mounts no card, and the ochre link would try to focus an unmounted field. Extracted as deriveRequiresHousing in the flow module with a test proven to fail on the old gate. - Move the entry-row popover hint out of the role=listbox element (listbox children must be options) into a sibling inside the absolute wrapper, referenced via aria-describedby on the combobox input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): drop the in-editor faktura/sjalvfaktura tabs The Ny faktura split button already chooses the mode (?self=1); a second switcher inside the editor was double steering. The mode is now fixed for the editor's lifetime and the heading (Registrera sjalvfaktura) carries the distinction. Orphaned tab keys removed from both message files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): wrap sticky-bar actions so they fit small viewports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): stop dialog grid item overflowing small viewports min-w-0 on the editor root: DialogContent is display:grid, so the row grid's min-w otherwise forces the column past narrow screens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): lift assistant FAB above the standalone editor's action bar The rebuilt editor introduces the first page-level sticky bottom bar; the assistant FAB (fixed, z-30) covered its Spara/Granska buttons on the /invoices/[id]/edit page. The editor now sets body[data-page-bottom-bar] in non-bare mode and AgentTrigger lifts to bottom-20 when it is present. 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>
853 lines
27 KiB
CSS
853 lines
27 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; }
|
|
|
|
/* Quiet settle for a freshly committed row (invoice editor line entry): a
|
|
brief beige wash that fades to transparent, confirming where the row
|
|
landed without moving anything. The global reduced-motion rule above
|
|
collapses it to an instant no-op. */
|
|
@keyframes row-settle {
|
|
from { background-color: hsl(var(--secondary) / 0.7); }
|
|
to { background-color: transparent; }
|
|
}
|
|
.row-settle {
|
|
animation: row-settle 0.6s ease-out both;
|
|
}
|
|
|
|
/* 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;
|
|
}
|