Files
accounted/.agents/skills/vercel-react-view-transitions/references/implementation.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

183 lines
9.9 KiB
Markdown

# 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>` and `router.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 `viewTransitionName` isolation.
- **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:
```jsx
<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`:
```jsx
startTransition(() => {
addTransitionType('nav-forward');
router.push('/detail/1');
});
```
Then wrap each **page component** (not layout) in a type-keyed `<ViewTransition>`:
```jsx
<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:
```jsx
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 `enter` with `exit` — without an exit animation, the old page disappears instantly while the new one animates in.
- Always include `default: "none"` in type map objects and `default="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) or `default="none"`.
## Step 5: Add Suspense Reveals
For every `<Suspense>` boundary identified in Step 1, wrap the fallback and content in separate `<ViewTransition>`s:
```jsx
<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:
```jsx
// 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`/`exit` provide 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** — without `default="none"`, it fires the browser's default cross-fade on every transition (every navigation, every Suspense resolve, every revalidation). Always set `default="none"` and explicitly enable only the triggers you want.
- **Directional `<ViewTransition>` in a layout** — layouts persist across navigations and never unmount/remount. `enter`/`exit` props 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.md` handle staggered timing, motion blur on morphs, and reduced motion. Copy them; don't reinvent them.
- **Missing `default: "none"` in type-keyed objects** — TypeScript requires a `default` key, 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 `viewTransitionName` CSS to trigger animations** — React only calls `document.startViewTransition` when `<ViewTransition>` components are in the tree. A bare `viewTransitionName` style is for isolating elements from a parent's snapshot, not for triggering animations.
- **`update` trigger for same-route navigations** — nested VTs inside the content steal the mutation from the parent, so `update` never fires on the outer VT. Use `key` + `name` + `share` instead.
- **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 synchronous `popstate`, incompatible with view transitions. Use `router.push()` with an explicit URL.
---
For Next.js-specific implementation steps (config flag, `transitionTypes` on `<Link>`, same-route dynamic segments), see `nextjs.md`.