Files
accounted/.agents/skills/vercel-react-view-transitions/references/patterns.md
T
Jakob WennbergandClaude Opus 4.7 f8f49f8426 Inbox page-count gate + DataList/DropdownMenu primitives (#554)
* feat(inbox): skip AI extraction for multi-page PDFs (#553)

Bedrock churns for minutes on multi-page PDFs (sales reports, bank
statements, contracts) and returns nothing useful. Above 3 pages we now
skip extraction entirely and mark the row with extraction_skipped=true;
the document still lands in the inbox and can be attached or converted
manually. Same gate applies to the /items/:id/attach path. Client can
also opt out via skip_extraction=true (skip_reason=client_opt_out).

The InvoiceInboxWorkspace renders an "Inte AI-tolkad" badge for skipped
rows, distinct from the "Felaktig" failure state (status='error').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): introduce DataList + DropdownMenu primitives, roll out across list pages

DataList replaces the per-row Card pattern across Granskning, Transactions,
Invoices, Supplier invoices, and Pending. One bordered container with
hairline rows matches the flat-with-hairlines aesthetic in CLAUDE.md —
no shadows, no state-tinted borders, secondary token for selected/hover.

DropdownMenu fills the gap for row-level action menus on TransactionInboxCard,
TransactionHistoryList, and the page-level action menus on /transactions and
/pending. Replaces ad-hoc Popover + buttons constructions.

Migrates list pages and the transaction inbox/history components onto the
new primitives. No behavior change beyond the visual unification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add agent skills + gnubok domain skills, gitignore compliance reports

.agents/skills/ + skills-lock.json + symlinks under .claude/skills/ check in
the vercel-labs/agent-skills set pinned by the local skill manager
(deploy-to-vercel, vercel-cli-with-tokens, react-best-practices,
composition-patterns, react-native-skills, react-view-transitions,
web-design-guidelines). Keeps the team on the same versions.

.claude/skills/industry/ + .claude/skills/modifier/ are hand-authored
vertical and entity-modifier skills for the specialized accountant agent
— industries (konsult-it, e-handel, bygg-hantverk, reklambyra, saas-ai)
and entity overlays (holding-ab, single-shareholder-ab-fmb, mixed-
verksamhet). Project-owned content; lives in the repo by design.

Also gitignores .compliance-reports/ — those are large generated
SARIF/dossier artifacts from the compliance scanner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(transactions): unify inbox/history chrome, drop swipe flow

The transactions page mixed two-tier filtering, a swipe-view detour, and
per-row Card chrome that didn't carry its weight. This pass collapses
those into a single editorial-list surface and removes the unused swipe
path entirely.

User-visible changes:
- Mode toggle (Att bokföra / Alla transaktioner) moved from a Tabs row
  under the header into a dropdown to the right of a unified search bar.
  Search now persists when switching modes.
- Removed the swipe categorization view ("Gå igenom alla") and its
  trigger button. The 800-line SwipeCategorizationView component is
  deleted; suggestion-fetching shrinks to what the template picker
  still consumes.
- Inbox rows now show one primary action: invoice/supplier-invoice
  match shortcut when auto-detected, else "Bokför". A new visible Link2
  icon button opens the customer or supplier invoice picker manually
  (chosen by amount sign). Delete becomes a plain trash button — no
  overflow menu since it only ever held one item.
- Bulk action bar swaps "Markera som privat" for "Ta bort" with a
  single combined confirmation.
- Built SupplierInvoicePicker mirroring InvoicePicker so expense
  transactions can be matched to supplier invoices from the inbox.
  Wired through /api/transactions/{id}/match-supplier-invoice.
- Template picker dialog renamed to "Bokför transaktion"; "Bokför
  manuellt…" and "Matcha med faktura…" promoted from muted ghost
  buttons at the bottom to outline buttons at the top, above the
  template list.
- Breathing room: row padding py-3 → py-4, primary text text-sm →
  text-base, amount text-base, button heights h-8 → h-9, trailing
  gap-2 → gap-3 (in the DataList primitive itself, so every list
  benefits slightly).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: align package-lock.json with merged package.json

The merge resolution took origin/main's package-lock.json (which dropped
pdf-lib) but kept our package.json (which still requires pdf-lib for the
invoice-inbox extension's PDFDocument import). `npm ci` rejected the
mismatch.

Regenerate the lock from the merged package.json so both files agree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: regenerate package-lock.json with npm@10 for CI compat

Local npm@11 produced a lock that npm@10 (CI) rejected with
"Missing: @swc/helpers@0.5.21". Regenerated with npm@10
--package-lock-only so CI can install.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): supplier-invoice status-leak guard + drop dead prop

Two follow-ups from the merge-risk audit:

- SupplierInvoicePicker now mirrors InvoicePicker's status-leak guard:
  if a supplier invoice is still 'approved'/'overdue' but already has a
  payment voucher attached (journal_entry_id on supplier_invoice_payments),
  hide it. Closes a UX race window between payment and status flip.
  Partially-paid invoices still pass through.
- Drop the unused onMarkPrivate prop on TransactionInboxCard and the
  matching handleMarkPrivate wrapper in the parent. Both became dead
  when the swipe-categorisation flow was removed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:12:21 +02:00

263 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Patterns and Guidelines
## Searchable Grid with `useDeferredValue`
`useDeferredValue` makes filter updates a transition, activating `<ViewTransition>`:
```tsx
'use client';
import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';
export default function SearchableGrid({ itemsPromise }) {
const [search, setSearch] = useState('');
const deferredSearch = useDeferredValue(search);
return (
<>
<input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
<ViewTransition>
<Suspense fallback={<GridSkeleton />}>
<ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />
</Suspense>
</ViewTransition>
</>
);
}
```
Per-item `<ViewTransition name={...}>` inside a deferred list triggers cross-fades on every keystroke. Fix with `default="none"`:
```tsx
{filteredItems.map(item => (
<ViewTransition key={item.id} name={`item-${item.id}`} share="morph" default="none">
<ItemCard item={item} />
</ViewTransition>
))}
```
## Card Expand/Collapse with `startTransition`
Toggle between grid and detail view with shared element morph:
```tsx
'use client';
import { useState, useRef, startTransition, ViewTransition } from 'react';
export default function ItemGrid({ items }) {
const [expandedId, setExpandedId] = useState(null);
const scrollRef = useRef(0);
return expandedId ? (
<ViewTransition enter="slide-in" name={`item-${expandedId}`}>
<ItemDetail
item={items.find(i => i.id === expandedId)}
onClose={() => {
startTransition(() => {
setExpandedId(null);
setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);
});
}}
/>
</ViewTransition>
) : (
<div className="grid grid-cols-3 gap-4">
{items.map(item => (
<ViewTransition key={item.id} name={`item-${item.id}`}>
<ItemCard
item={item}
onSelect={() => {
scrollRef.current = window.scrollY;
startTransition(() => setExpandedId(item.id));
}}
/>
</ViewTransition>
))}
</div>
);
}
```
## Type-Safe Transition Helpers
Use `as const` arrays and derived types to prevent ID clashes:
```tsx
const transitionTypes = ['default', 'transition-to-detail', 'transition-to-list'] as const;
const animationTypes = ['auto', 'none', 'animate-slide-from-left', 'animate-slide-from-right'] as const;
type TransitionType = (typeof transitionTypes)[number];
type AnimationType = (typeof animationTypes)[number];
type TransitionMap = { default: AnimationType } & Partial<Record<Exclude<TransitionType, 'default'>, AnimationType>>;
export function HorizontalTransition({ children, enter, exit }: {
children: React.ReactNode;
enter: TransitionMap;
exit: TransitionMap;
}) {
return <ViewTransition enter={enter} exit={exit}>{children}</ViewTransition>;
}
```
## Cross-Fade Without Remount
Omit `key` to trigger an update (cross-fade) instead of exit + enter. Avoids Suspense remount/refetch:
```jsx
<ViewTransition>
<TabPanel tab={activeTab} />
</ViewTransition>
```
Use `key` when content identity changes (state resets). Omit for cross-fades (tabs, panels, carousel).
## Isolate Elements from Parent Animations
### Persistent Layout Elements
Persistent elements (headers, navbars, sidebars) get captured in the page's transition snapshot. Fix with `viewTransitionName`:
```jsx
<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
```
Then add the persistent element isolation CSS from `css-recipes.md`. For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround from `css-recipes.md`.
### Floating Elements
Give popovers/tooltips their own `viewTransitionName`:
```jsx
<SelectPopover style={{ viewTransitionName: 'popover' }}>{options}</SelectPopover>
```
Global fix: see persistent element isolation in `css-recipes.md`.
## Shared Controls Between Skeleton and Content
Give matching controls in fallback and content the same `viewTransitionName`:
```jsx
// Fallback
<input disabled placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
// Content
<input placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
```
Don't put manual `viewTransitionName` on the root DOM node inside `<ViewTransition>` — React's auto-generated name overrides it.
## Reusable Animated Collapse
```jsx
function AnimatedCollapse({ open, children }) {
if (!open) return null;
return (
<ViewTransition enter="expand-in" exit="collapse-out">
{children}
</ViewTransition>
);
}
// Usage: toggle with startTransition
<button onClick={() => startTransition(() => setOpen(o => !o))}>Toggle</button>
<AnimatedCollapse open={open}><SectionContent /></AnimatedCollapse>
```
## Preserve State with Activity
```jsx
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<ViewTransition enter="slide-in" exit="slide-out">
<Sidebar />
</ViewTransition>
</Activity>
```
## Exclude Elements with `useOptimistic`
`useOptimistic` values update before the transition snapshot, excluding them from animation. Use for controls (labels); use committed state for animated content:
```tsx
const [sort, setSort] = useState('newest');
const [optimisticSort, setOptimisticSort] = useOptimistic(sort);
function cycleSort() {
const nextSort = getNextSort(optimisticSort);
startTransition(() => {
setOptimisticSort(nextSort); // before snapshot — no animation
setSort(nextSort); // between snapshots — animates
});
}
<button>Sort: {LABELS[optimisticSort]}</button>
{items.sort(comparators[sort]).map(item => (
<ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
))}
```
---
## View Transition Events
Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return a cleanup function. `onShare` takes precedence over `onEnter`/`onExit`.
```jsx
<ViewTransition
onEnter={(instance, types) => {
const anim = instance.new.animate(
[{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
{ duration: 300, easing: 'ease-out' }
);
return () => anim.cancel();
}}
>
<Component />
</ViewTransition>
```
The `instance` object: `instance.old`, `instance.new`, `instance.group`, `instance.imagePair`, `instance.name`.
The `types` array (second argument) lets you vary animation based on transition type.
---
## Animation Timing
| Interaction | Duration |
|------------|----------|
| Direct toggle (expand/collapse) | 100–200ms |
| Route transition (slide) | 150–250ms |
| Suspense reveal (skeleton → content) | 200–400ms |
| Shared element morph | 300–500ms |
---
## Troubleshooting
**VT not activating:** Ensure `<ViewTransition>` comes before any DOM node. Ensure state update is inside `startTransition`.
**"Two ViewTransition components with the same name":** Names must be globally unique. Use IDs: `name={`hero-${item.id}`}`.
**`router.back()` and browser back/forward skip animation:** Use `router.push()` with an explicit URL instead. See SKILL.md "router.back() and Browser Back Button."
**`flushSync` skips animations:** Use `startTransition` instead.
**Only updates animate (no enter/exit):** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.
**Layout VT prevents page VTs from animating:** Nested VTs never fire enter/exit inside a parent VT. If your layout has a VT wrapping `{children}`, page-level enter/exit will silently not work. Remove the layout VT.
**List reorder not animating with `useOptimistic`:** Optimistic values resolve before snapshot. Use committed state for list order.
**TS error "Property 'default' is missing":** Type-keyed objects require a `default` key.
**Hash fragments cause scroll jumps:** Navigate without hash; scroll programmatically after navigation.
**Backdrop-blur flickers:** Use the backdrop-blur workaround from `css-recipes.md`.
**`border-radius` lost during transitions:** Apply `border-radius` directly to the captured element.
**Skeleton controls slide away:** Give matching controls the same `viewTransitionName`.
**Batching:** Multiple updates during animation are batched. A→B→C→D becomes B→D.