c897a906df
* fix(ux): update lists in place on actions instead of takeover spinners and jumps Founder report: the app feels glitchy when clicking around, especially when deleting a row or booking something. The repo-wide anti-pattern behind it: single-row actions trigger whole-list skeleton/spinner takeovers (layout collapse, scroll jump, full stagger-enter replay), deletes give zero feedback then hard-jump, and the /transactions exit "animation" was filter-only and never animated. Per surface: - Never take over a rendered list for a background refresh. The skeleton/spinner swap is now reserved for an empty (or foreign) list on /transactions (fetchTransactions), /pending (fetchOperations, covering both listed Granskning findings, one file), kundfakturor (fetchInvoices), leverantörsfakturor (fetchInvoices, plus try/catch/finally so a failed fetch can no longer stick the skeleton or masquerade as an empty register) and the verifikat list (JournalEntryList now takes a refreshToken prop and refetches in place; /bookkeeping no longer key-remounts it into a spinner, so expansion/selection/pagination/scroll survive a created verifikat). Quiet inline Loader2 cues near the list headers on /transactions and /pending signal a background reconcile. - /transactions row exit: exiting rows (booked/ignored/deleted) stay rendered through the existing 350ms window with a real exit transition (.row-exit: fast fade, then the space closes by transitioning cell paddings/line metrics and a numeric max-height on the fixed-height cell spans) and pointer-events off. Instant removal under prefers-reduced-motion. Applied to the inbox cards, the skattekonto card and the history rows. - /transactions delete: routes through processingId (row spinner) and the exitingIds path, and decrements totalUncategorizedCount when the deleted row was pending (the realtime echo is not guaranteed for DELETE on a filtered subscription). - FyPicker double-fetch: the initial fetch now waits for FyPicker's onReady (fires after its persisted-scope restore), so mount does one correctly scoped fetch instead of racing an unscoped fetch against the restore refetch (list -> skeleton -> list on every visit). Period changes refetch background-only behind the client-filtered list. - Pagination survives realtime echoes: background refreshes re-fetch range(0, pagedCountRef) instead of resetting to the first 200 rows, so "Visa fler" pages no longer collapse after any action. Gates: full vitest suite green (14764 passed), tsc output byte-identical to the origin/main baseline, eslint 0 errors on touched files, check:guards green, package-lock untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): apply review round on action-feedback smoothness - /pending: sequence-guard fetchOperations so a stale previous-tab response can't overwrite the current tab's rows, counts, or loading cues - /pending: check res.ok on the pending fetch and both history fetches before applying payloads; failures keep current rows and surface the existing error toast - /transactions: reset fiscal scope (fyReady/fyPeriodId/fyPeriod) during render on company switch so FyPicker re-runs its persisted restore and stale bounds never scope a fetch for the wrong company - /transactions: drop a deleted row's id from selectedIds so the bulk bar can't act on a deleted row - row exit: add the inert attribute on exiting row wrappers alongside pointer-events so keyboard focus and activation are blocked too - JournalEntryList: preserve selection on refreshToken background refreshes (reconciled against the refreshed page); user-initiated reloads still clear it 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>
828 lines
26 KiB
CSS
828 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;
|
|
}
|
|
}
|