* 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>
9.9 KiB
Implementation Workflow
Follow these steps in order when adding view transitions to an app. Each step builds on the previous one.
Step 1: Audit the App
Before writing any code, scan the codebase thoroughly. Search for:
- Every
<Link>androuter.push— these are your navigation triggers. Open every file that contains one. - Every
<Suspense>boundary — each one is a candidate for a reveal animation. Check what its fallback renders. - Every page/route component — list them all. Each page needs a VT placement decision.
- Persistent elements — headers, navbars, sidebars, sticky controls that stay on screen across navigations. These need
viewTransitionNameisolation. - Shared visual elements — images, cards, or avatars that appear on both a source and target view (e.g., a thumbnail in a list and the same image on a detail page).
- Skeleton-to-content control pairs — if a Suspense fallback renders a control (search input, tab bar) that also exists in the real content, both need a matching
viewTransitionName.
Then classify every navigation and produce a navigation map:
| Route | Navigates to | Direction | VT pattern |
|-----------------|----------------------|--------------|-----------------------|
| / | /detail/[id] | forward | directional slide |
| /detail/[id] | / | back | directional slide |
| /detail/[id] | /detail/[other] | sequential | directional slide (ordered prev/next) or key+share crossfade |
| /tab/[a] | /tab/[b] | lateral | key+share crossfade |
| (Suspense) | (content loads) | — | slide-up reveal |
For each shared element (name prop), note every navigation where a pair forms and where it doesn't — this determines whether you need enter/exit as a fallback alongside share.
Step 2: Add CSS Recipes
Copy the complete CSS recipe set from css-recipes.md into your global stylesheet. This includes timing variables, shared keyframes, fade, slide (vertical), directional navigation (forward/back), shared element morph, persistent element isolation, and reduced motion.
Do not write your own animation CSS — the recipes handle staggered timing, motion blur on morphs, and reduced motion that are easy to get wrong. You can customize timing variables (--duration-exit, --duration-enter, --duration-move) after the initial setup.
Step 3: Isolate Persistent Elements
For every persistent element identified in Step 1, add a viewTransitionName style to pull it out of the page content's transition snapshot:
<header style={{ viewTransitionName: "site-header" }}>...</header>
Then add the persistent element isolation CSS from css-recipes.md (prevents the element from animating during page transitions). If the element uses backdrop-blur or backdrop-filter, use the backdrop-blur workaround from css-recipes.md instead.
If a Suspense fallback mirrors a persistent control (e.g., a skeleton search input), give both the real control and the skeleton the same viewTransitionName so they morph in place.
Step 4: Add Directional Page Transitions
For hierarchical navigations identified in Step 1, tag the navigation direction using addTransitionType inside startTransition:
startTransition(() => {
addTransitionType('nav-forward');
router.push('/detail/1');
});
Then wrap each page component (not layout) in a type-keyed <ViewTransition>:
<ViewTransition
enter={{
"nav-forward": "nav-forward",
"nav-back": "nav-back",
default: "none",
}}
exit={{
"nav-forward": "nav-forward",
"nav-back": "nav-back",
default: "none",
}}
default="none"
>
<div>...page content...</div>
</ViewTransition>
The nav-forward and nav-back CSS classes from css-recipes.md produce horizontal slides. For simpler apps where directional motion isn't needed, a bare <ViewTransition default="none"> wrapper with enter="fade-in" / exit="fade-out" works too.
Extract this into a reusable component so every page doesn't repeat the verbose type map:
export function DirectionalTransition({ children }: { children: React.ReactNode }) {
return (
<ViewTransition
enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
default="none"
>
{children}
</ViewTransition>
);
}
This also becomes the single place to adjust if you add new transition types later.
Rules:
- Always pair
enterwithexit— without an exit animation, the old page disappears instantly while the new one animates in. - Always include
default: "none"in type map objects anddefault="none"on the component — otherwise it fires on every transition. - Place the directional
<ViewTransition>in each page component, not in a layout. Layouts persist across navigations and never trigger enter/exit. - Only use directional slides for hierarchical navigation or ordered sequences (prev/next). Lateral/sibling navigation (tab-to-tab) should use a bare
<ViewTransition>(cross-fade) ordefault="none".
Step 5: Add Suspense Reveals
For every <Suspense> boundary identified in Step 1, wrap the fallback and content in separate <ViewTransition>s:
<Suspense
fallback={
<ViewTransition exit="slide-down">
<Skeleton />
</ViewTransition>
}
>
<ViewTransition enter="slide-up" default="none">
<AsyncContent />
</ViewTransition>
</Suspense>
This example uses slide-down / slide-up for directional vertical motion. For a simpler reveal, a bare <ViewTransition> around the <Suspense> gives a cross-fade with zero configuration. Choose based on the spatial meaning — consult the "Choosing the Right Animation Style" table in the main skill file.
Rules:
- Always use
default="none"on the content<ViewTransition>to prevent re-animation on revalidation or unrelated transitions. - Use simple string props (not type maps) on Suspense
<ViewTransition>s — Suspense resolves fire as separate transitions with no type, so type-keyed props won't match.
Step 6: Add Shared Element Transitions
For every shared visual element identified in Step 1, add matching named <ViewTransition> wrappers on both the source and target views:
// On the source view (e.g., list/grid page)
<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
<Image src={photo.src} ... />
</ViewTransition>
// On the target view (e.g., detail page) — same name
<ViewTransition name={`photo-${photo.id}`} share="morph">
<Image src={photo.src} ... />
</ViewTransition>
The share="morph" class uses the morph recipe from css-recipes.md (controlled duration + motion blur). For a simpler cross-fade, use share="auto" (browser default).
When list items contain shared elements, compose both patterns with two nested <ViewTransition> layers — see "Composing Shared Elements with List Identity" in SKILL.md.
Rules:
- Names must be globally unique — use prefixes like
photo-${id}. - Add
default="none"on list-side shared elements to prevent per-item cross-fades on filter/search updates.
Step 7: Verify Each Navigation Path
Walk through every row in the navigation map from Step 1 and confirm:
- Does the VT mount/unmount on this navigation, or does it stay mounted (same-route)?
- For named VTs: does a shared pair form? If not, does
enter/exitprovide a fallback? - Does
default="none"block an animation you actually want? - Do persistent elements stay static (not sliding with page content)?
- Do Suspense reveals animate independently from directional navigations?
If any path produces no animation or competing animations, revisit the relevant step.
Common Mistakes
- Bare
<ViewTransition>without props — withoutdefault="none", it fires the browser's default cross-fade on every transition (every navigation, every Suspense resolve, every revalidation). Always setdefault="none"and explicitly enable only the triggers you want. - Directional
<ViewTransition>in a layout — layouts persist across navigations and never unmount/remount.enter/exitprops won't fire on route changes. Place the outer type-keyed<ViewTransition>in each page component. - Fade-out exit with shared element morphs — the page dissolving conflicts with the morph. Use a directional slide exit instead.
- Writing custom animation CSS — the recipes in
css-recipes.mdhandle staggered timing, motion blur on morphs, and reduced motion. Copy them; don't reinvent them. - Missing
default: "none"in type-keyed objects — TypeScript requires adefaultkey, and without it the fallback is"auto"which fires on every transition. - Type maps on Suspense reveals — Suspense resolves fire as separate transitions with no type. Type-keyed props won't match — use simple string props instead.
- Raw
viewTransitionNameCSS to trigger animations — React only callsdocument.startViewTransitionwhen<ViewTransition>components are in the tree. A bareviewTransitionNamestyle is for isolating elements from a parent's snapshot, not for triggering animations. updatetrigger for same-route navigations — nested VTs inside the content steal the mutation from the parent, soupdatenever fires on the outer VT. Usekey+name+shareinstead.- Named VT in a reusable component — if a component with a named VT is rendered in both a modal/popover and a page, both mount simultaneously and break the morph. Make the name conditional or move it to the specific consumer.
router.back()for back navigation —router.back()triggers synchronouspopstate, incompatible with view transitions. Userouter.push()with an explicit URL.
For Next.js-specific implementation steps (config flag, transitionTypes on <Link>, same-route dynamic segments), see nextjs.md.