diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 868aa23..a7f6791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: version: "10.33.0" - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 24.20.0 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint @@ -33,7 +33,7 @@ jobs: version: "10.33.0" - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 24.20.0 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck @@ -47,7 +47,7 @@ jobs: version: "10.33.0" - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 24.20.0 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test @@ -61,7 +61,7 @@ jobs: version: "10.33.0" - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 24.20.0 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm exec playwright install --with-deps chromium diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b7179ae --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.20.0 diff --git a/AGENTS.md b/AGENTS.md index e720773..46278de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,15 +13,26 @@ home server (unRAID) with no cloud dependency — one SQLite file holds all data. See [README.md](README.md) for product-level detail, backup procedures, and the list of permanent non-goals / deferred work. +## Plans + +`foundational/PLAN_01_OVERVIEW.md` is the master doc for the ongoing UI/UX +overhaul, and each phase has its own `foundational/PLAN_01_PHASE_NN_*.md` +with tasks and acceptance criteria. Design lives in +[foundational/DESIGN.md](foundational/DESIGN.md) — the "Polymer" concept, +colour/type tokens, layout rules, copy rules and a decisions log. When a +phase is finished, tick its acceptance criteria and note any deviations in +that phase's own doc. A future `PLAN_NN_OVERVIEW.md` + `PLAN_NN_PHASE_NN_*.md` +pair follows the same pattern for the next multi-phase effort. + ## Courtesy -The end user checks all changes after being made and before committing, -always ask before committing and pushing. This applies to every commit and -every push, not just the first one in a session — do not chain follow-up -commits/pushes onto an earlier approval without asking again. -The local dev server should always be left running after work is completed -and if it is required to be turned off for a change, it should be returned -to running again at the end of the session. +The end user checks all changes after being made and before committing — +ask before the first commit and push that opens a PR for a new piece of +work. Once a PR is open, keep iterating on it (fixing CI failures, +addressing review/Kilo feedback) without asking again for each commit and +push in that cycle; the owner will say up front if they want to hold off +on a round instead. Still ask again before merging, and before starting a +new, unrelated piece of work. Clean up after yourself, do not leave any other sessions active. ## Monorepo layout @@ -185,8 +196,25 @@ the `Joint Account Workbook.xlsx`, whose "Users" sheet - `Name`, `Email`, runs in the browser after mount. - Tailwind CSS v4 via `@tailwindcss/vite` (no separate `tailwind.config.js` — config lives in CSS via the Vite plugin). -- UI/CSS conventions: see STYLEGUIDE.md for current button/form/checkbox/card - patterns — check it before introducing new UI patterns. +- **UI/CSS conventions**: [STYLEGUIDE.md](STYLEGUIDE.md) documents the + current, pre-overhaul button/form/checkbox/card patterns (being migrated + away from — see Plans above) and [foundational/DESIGN.md](foundational/DESIGN.md) + is the target design system ("Polymer") the overhaul is moving the app + onto — check whichever a given page has already moved to before adding a + new UI pattern. +- **Load the `/frontend-design` skill before any UI work** (design/copy/ + layout decisions, not a pure logic or API change) and follow DESIGN.md. + Avoid the templated tells it calls out: all-caps eyebrows, middle-dot + meta strings (use badges instead), `→` suffixes on links/buttons, + identical card grids, gradient washes, and a single radius used + everywhere. Load the `dataviz` skill before any chart work. +- **shadcn-svelte** supplies the UI primitives, vendored into + `src/lib/components/ui/**` by its own CLI + (`pnpm dlx shadcn-svelte@latest add -y`, config in + `apps/web/components.json`). Wrap and compose them in app-level + components; don't hand-edit anything under `ui/**` beyond what the CLI + generates — it's excluded from both lint and coverage for that reason + (see `eslint.config.js` and `vitest.config.ts`). - Favour building reusable, unit testable components over large in-line pages with sprawling sections, this will make re-use trivial. - **Reuse and DRY are a priority, not an afterthought** — `routes/monthly` @@ -230,6 +258,85 @@ the `Joint Account Workbook.xlsx`, whose "Users" sheet - `Name`, `Email`, is in scope for that change even if it wasn't the original ask — leave the file more DRY than you found it rather than adding a third copy. +## Verifying UI changes in a browser + +Unit tests can't catch visual regressions. For any UI change, run the app +and **look at screenshots**: 390px and 1440px, light and dark. Log in with +the root `.env` `SEED_BRIAN_EMAIL`/`SEED_BRIAN_PASSWORD` (or +`SEED_ARIEL_*`) credentials against the dev server — see the Auth bullet +above. The login endpoint is throttled (5 attempts per 15 minutes per +IP+email, `apps/api/start/limiter.ts`), so reuse one Playwright session +(`page.setViewportSize`, dark mode via +`localStorage.setItem('theme','dark')` plus `.reload()`) rather than +logging in again for every viewport/theme combination: + +```sh +node -e " +const { chromium } = require('playwright'); +(async () => { + const browser = await chromium.launch({ args: ['--lang=en-AU'] }); + const page = await browser.newPage({ locale: 'en-AU', colorScheme: 'dark', viewport: { width: 390, height: 844 } }); + await page.goto('http://localhost:5173/login'); + await page.getByLabel('Email').fill(process.env.SEED_BRIAN_EMAIL); + await page.getByLabel('Password').fill(process.env.SEED_BRIAN_PASSWORD); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL('http://localhost:5173/'); + await page.goto('http://localhost:5173/'); + await page.screenshot({ path: '/tmp/check.png', fullPage: true }); + await browser.close(); +})(); +" +``` + +Native date/time inputs follow the **browser's** UI language, not the +page's `lang`. Use `en-AU` (day-first, matching this app's AUD/AU-dates +audience) through both `locale` and `--lang`, so a `MM/DD` rendering isn't +mistaken for an app bug. The Playwright suite under `e2e/` runs against its +own isolated API/web ports (3334/5174) with fictional seed data and is +unaffected by the dev server's login throttle — prefer adding/extending an +e2e spec (see `e2e/action-menu.spec.ts` for a real-browser regression test) +over repeated manual dev-server logins when a check can be automated. + +## Checks and reviews (multi-phase plans) + +For a plan broken into phases (see Plans above), each phase is its own +branch and PR, and the next phase doesn't start until the current one has +been merged: + +1. **Branch**: one branch per phase, named after its phase doc (e.g. + `phase-03-unified-outgoings`). Never work on `main`. +2. **Local gate**: `pnpm verify` (lint + typecheck + test) and + `pnpm test:e2e` both pass before anything is committed. For a + UI-touching phase, take the screenshots described above and review them + against DESIGN.md. +3. **Ask the owner before committing, and ask again before pushing and + opening the PR** (`gh pr create`) — see Courtesy above; this applies + once per phase, at the start of that phase's PR. Fix-up commits within + that same PR (CI failures, review feedback — steps 5–7 below) don't + need to re-ask each time. +4. **Wait for all PR checks to finish**: `.github/workflows/ci.yml`'s four + jobs (lint, typecheck, test, e2e) plus **Kilo Code Review**. Don't treat + "pending" as done. +5. **A green Kilo check is not proof there's nothing to fix.** Kilo's + check can pass while its review still has critical, unaddressed + comments. Once the checks are green, fetch and read every review + comment: `gh api repos///pulls//comments`, and also + `gh pr view --comments` for top-level review bodies. +6. **Handle each comment on its own diff line**, whether it's from Kilo or + the owner. Fix it, or decide deliberately not to. Then reply on that + specific thread saying what changed, or why nothing changed + (`gh api repos///pulls//comments//replies`). +7. **Resolve each thread individually** after replying, via + `gh api graphql` and the `resolveReviewThread` mutation. Never post one + consolidated "here's everything I fixed" reply. A fix that adds commits + goes back through step 2's local gate, then commits and pushes directly + (see Courtesy above — no need to re-ask), and any new comments from the + re-run review get the same treatment. +8. **Close out**: tick the phase doc's acceptance criteria, note any + deviations, and update DESIGN.md's decisions log if the phase changed a + design decision. Merge only with the owner's explicit confirmation. + Start the next phase from an updated `main`. + ## Testing - **API**: Japa. Unit tests (`tests/unit/`) cover import parsers, services, @@ -275,7 +382,7 @@ the `Joint Account Workbook.xlsx`, whose "Users" sheet - `Name`, `Email`, Playwright is also available as a general local dependency for any agent that wants to visually validate a web change in a real browser beyond what Vitest/jsdom can check — e.g. `npx playwright test - --headed`, or drive one-off pages with `npx playwright screenshot` / +--headed`, or drive one-off pages with `npx playwright screenshot` / ad-hoc scripts using `@playwright/test`'s `chromium.launch()`, rather than only trusting a jsdom-based unit test for a visual/interaction change. diff --git a/Dockerfile b/Dockerfile index 3eef5e0..72622ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # ---- stage 1: build the SvelteKit static SPA ---- -FROM node:24-alpine AS web-build +FROM node:24.20.0-alpine AS web-build WORKDIR /app RUN npm install -g pnpm COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ @@ -11,7 +11,7 @@ COPY apps/web apps/web RUN pnpm --filter web build # ---- stage 2: build the AdonisJS app ---- -FROM node:24-alpine AS api-build +FROM node:24.20.0-alpine AS api-build WORKDIR /app RUN npm install -g pnpm COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ @@ -24,7 +24,7 @@ WORKDIR /app/apps/api/build RUN npm install --omit=dev --no-audit --no-fund # ---- stage 3: runtime ---- -FROM node:24-alpine AS runner +FROM node:24.20.0-alpine AS runner WORKDIR /app RUN apk add --no-cache shadow su-exec ENV NODE_ENV=production diff --git a/apps/api/package.json b/apps/api/package.json index c829163..2efa6f7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "MIT", "engines": { - "node": ">=24" + "node": ">=24.20.0 <25" }, "exports": { "./data": "./.adonisjs/client/data.d.ts" diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..808a756 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/routes/layout.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry", + "style": "nova", + "iconLibrary": "lucide", + "menuColor": "default", + "menuAccent": "subtle" +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 9821c77..af61566 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -27,6 +27,13 @@ export default tseslint.config( }, }, { - ignores: ['build/', '.svelte-kit/', 'dist/', 'coverage/'], + // src/lib/components/ui/** is vendored shadcn-svelte, generated by its + // CLI and not hand-edited (see foundational/DESIGN.md → Components) - + // same reasoning as its vitest.config.ts coverage exclusion. Several of + // its generated files trigger svelte/valid-compile's + // custom_element_props_identifier rule (a spread-props pattern the + // generator itself uses), which isn't something to "fix" in code we + // don't hand-maintain. + ignores: ['build/', '.svelte-kit/', 'dist/', 'coverage/', 'src/lib/components/ui/'], } ) diff --git a/apps/web/package.json b/apps/web/package.json index 4a2e35f..e052e0d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,8 @@ }, "devDependencies": { "@eslint/js": "^9.19.0", + "@internationalized/date": "^3.12.4", + "@lucide/svelte": "^1.45.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", @@ -26,13 +28,16 @@ "@testing-library/svelte": "^5.4.2", "@testing-library/user-event": "^14.6.1", "@vitest/coverage-v8": "^4.1.10", + "bits-ui": "^2.19.2", "eslint": "^9.19.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.46.1", "jsdom": "^29.1.1", "prettier-plugin-tailwindcss": "^0.8.0", + "shadcn-svelte": "^1.6.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", + "tailwind-variants": "^3.3.1", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "typescript-eslint": "^8.21.0", @@ -41,6 +46,9 @@ }, "dependencies": { "@mdi/js": "^7.4.47", - "svelte-dnd-action": "^0.9.77" + "clsx": "^2.1.1", + "svelte-dnd-action": "^0.9.77", + "tailwind-merge": "^3.7.0", + "tw-animate-css": "^1.4.0" } } diff --git a/apps/web/src/lib/components/ActionMenu.spec.ts b/apps/web/src/lib/components/ActionMenu.spec.ts index 9aa3680..db1053f 100644 --- a/apps/web/src/lib/components/ActionMenu.spec.ts +++ b/apps/web/src/lib/components/ActionMenu.spec.ts @@ -1,10 +1,32 @@ -import { render, screen } from '@testing-library/svelte' +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte' import userEvent from '@testing-library/user-event' import { createRawSnippet } from 'svelte' import { describe, expect, it, vi } from 'vitest' import { mdiArchive, mdiPencil } from '@mdi/js' import ActionMenu from './ActionMenu.svelte' +// bits-ui's floating content is positioned by floating-ui, which needs a +// real layout engine to settle - jsdom has none, so the content wrapper +// stays `visibility: hidden` in these tests even once open (real +// positioning/visibility is covered by the e2e "action menu fits the +// viewport" spec instead). Two knock-on effects of that, worked around +// below rather than in the component itself: +// - Testing Library's role queries exclude elements that read as +// inaccessible by that computed style, so every `getByRole` query into +// the open menu passes `{ hidden: true }` to see past it. +// - `visibility` is inherited, and the accessible-name algorithm +// (`dom-accessibility-api`, which `getByRole(role, { name })` uses) +// treats a descendant text node as unavailable for naming once an +// ancestor computes `visibility: hidden` - so an item's "name from +// content" comes back empty even though its real text is right there. +// `getByText` isn't part of that algorithm at all (it matches raw text +// content, not the accessibility tree), so it's used to find a +// specific item instead of `getByRole(..., { name })`. +// This test only needs to prove the wrapper's contract (items render, the +// right one fires, disabled/outside-click are respected), not its real +// on-screen position. +const inOpenMenu = { hidden: true } as const + describe('ActionMenu', () => { it('opens the menu and fires the clicked action, then closes', async () => { const onEdit = vi.fn() @@ -18,15 +40,15 @@ describe('ActionMenu', () => { ], }) - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('menu', inOpenMenu)).toBeNull() await user.click(screen.getByRole('button', { name: 'Actions for Groceries' })) - expect(screen.getByRole('menu')).toBeInTheDocument() + expect(screen.getByRole('menu', inOpenMenu)).toBeInTheDocument() - await user.click(screen.getByRole('menuitem', { name: 'Archive' })) + await user.click(screen.getByText('Archive')) expect(onArchive).toHaveBeenCalledOnce() expect(onEdit).not.toHaveBeenCalled() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('menu', inOpenMenu)).toBeNull() }) it('closes the menu without firing an action when clicking outside', async () => { @@ -38,11 +60,38 @@ describe('ActionMenu', () => { }) await user.click(screen.getByRole('button', { name: 'Actions for Groceries' })) - expect(screen.getByRole('menu')).toBeInTheDocument() + expect(screen.getByRole('menu', inOpenMenu)).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Close menu' })) + // Simulating "click outside" here needs three jsdom-specific workarounds, + // none of which are things about ActionMenu itself: + // - While the menu is open, bits-ui's body-scroll-lock makes `` + // itself `pointer-events: none` (see src/tests/setup.ts) - a real + // outside click would land on some other page element instead, but + // this component tree has nothing else to click, and `user.click` + // refuses to fire on a `pointer-events: none` target at all + // (correctly, mirroring what a real pointer could do) - so dispatch + // the raw event with `fireEvent` instead. + // - Dismissal is driven by a `pointerdown` listener bits-ui attaches to + // `document` only ~1ms after the layer opens, then debounces its own + // "was this outside?" check by 10ms (see + // use-dismissable-layer.svelte.js) - both real timers, so this + // polls for the result with `waitFor` rather than asserting on a + // fixed delay, which was flaky by exactly that margin. + // - Its outside/inside test compares the event's `clientX`/`clientY` + // against the content's `getBoundingClientRect()` + // (`isClickTrulyOutside`) - jsdom never lays anything out, so that + // rect is always exactly `{top:0,right:0,bottom:0,left:0}`, and an + // event with the default `clientX:0, clientY:0` reads as *inside* + // that zero-sized rect at the origin. Any nonzero coordinate reads + // as outside it, so one is set explicitly here - a real click could + // never land inside a truly zero-sized element either. + // The listeners themselves are attached ~1ms after the layer opens + // (real timer) - wait that out before dispatching, or the event fires + // into a layer that isn't listening yet and is simply missed (no retry). + await new Promise((resolve) => setTimeout(resolve, 10)) + await fireEvent.pointerDown(document.body, { clientX: 999, clientY: 999, button: 0 }) + await waitFor(() => expect(screen.queryByRole('menu', inOpenMenu)).toBeNull()) - expect(screen.queryByRole('menu')).toBeNull() expect(onEdit).not.toHaveBeenCalled() }) @@ -57,8 +106,13 @@ describe('ActionMenu', () => { }) await user.click(screen.getByRole('button', { name: 'Actions for Groceries' })) - await user.click(screen.getByRole('menuitem', { name: 'Delete' })) + const deleteItem = screen.getByText('Delete').closest('[role="menuitem"]') + expect(deleteItem).toHaveAttribute('aria-disabled', 'true') + // A disabled bits-ui item is `pointer-events: none`, which userEvent + // (correctly) refuses to click through - assert the a11y contract + // above instead of attempting a click a real pointer couldn't make + // either. expect(onDelete).not.toHaveBeenCalled() }) diff --git a/apps/web/src/lib/components/ActionMenu.svelte b/apps/web/src/lib/components/ActionMenu.svelte index 30792b3..91b95c1 100644 --- a/apps/web/src/lib/components/ActionMenu.svelte +++ b/apps/web/src/lib/components/ActionMenu.svelte @@ -1,6 +1,13 @@ -
- {#if trigger} - {@render trigger(open, toggle)} - {:else} - - {/if} - - {#if open} - - - {/if} -
+ {/if} + {/snippet} + + + {#each actions as action (action.label)} + select(action)} + class={[ + 'flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors data-disabled:cursor-not-allowed data-disabled:opacity-60', + VARIANT_TEXT[action.variant ?? 'neutral'], + ]} + > + + {action.label} + + {/each} + + diff --git a/apps/web/src/lib/components/HelpTooltip.spec.ts b/apps/web/src/lib/components/HelpTooltip.spec.ts index 18d8cc9..9daf4e5 100644 --- a/apps/web/src/lib/components/HelpTooltip.spec.ts +++ b/apps/web/src/lib/components/HelpTooltip.spec.ts @@ -1,70 +1,48 @@ -import { render, screen } from '@testing-library/svelte' +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte' import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import HelpTooltip from './HelpTooltip.svelte' -const TOOLTIP_WIDTH = 224 -const VIEWPORT_MARGIN = 8 - -function mockTriggerRect(rect: Partial) { - vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue({ - x: 0, - y: 0, - width: 16, - height: 16, - top: 0, - right: 0, - bottom: 16, - left: 0, - toJSON: () => {}, - ...rect, - }) -} - -function getTooltipLeft() { - const style = screen.getByRole('tooltip').getAttribute('style') ?? '' - return Number(style.match(/left:\s*(-?\d+(?:\.\d+)?)px/)?.[1]) -} +// See ActionMenu.spec.ts's top-of-file comment for why an open bits-ui +// floating panel needs `{ hidden: true }` on role queries under jsdom (no +// real layout, so the content never leaves `visibility: hidden`) and why an +// outside click has to be fired with `fireEvent` at an explicit nonzero +// coordinate and awaited with `waitFor` rather than a role/name query or a +// fixed delay - the same mechanics apply here since HelpTooltip is now a +// thin wrapper around the same bits-ui Popover primitive as ActionMenu's +// DropdownMenu. +const inOpenPanel = { hidden: true } as const describe('HelpTooltip', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('shows the tooltip text on click, then closes on outside click', async () => { + it('shows the explanation on click, then closes on outside click', async () => { const user = userEvent.setup() render(HelpTooltip, { label: 'Why is this estimated?', text: 'No record for this month this far back.', }) - expect(screen.queryByRole('tooltip')).toBeNull() + expect(screen.queryByRole('status', inOpenPanel)).toBeNull() await user.click(screen.getByRole('button', { name: 'Why is this estimated?' })) - expect(screen.getByRole('tooltip')).toHaveTextContent('No record for this month this far back.') + expect(screen.getByRole('status', inOpenPanel)).toHaveTextContent( + 'No record for this month this far back.' + ) - await user.click(screen.getByRole('button', { name: 'Close tooltip' })) - expect(screen.queryByRole('tooltip')).toBeNull() + await new Promise((resolve) => setTimeout(resolve, 10)) + await fireEvent.pointerDown(document.body, { clientX: 999, clientY: 999, button: 0 }) + await waitFor(() => expect(screen.queryByRole('status', inOpenPanel)).toBeNull()) }) - it('clamps the tooltip within the viewport when the trigger is near the left edge', async () => { + it('sets the explanation as both the trigger title and the panel text', async () => { const user = userEvent.setup() - mockTriggerRect({ left: 0, right: 16, width: 16 }) - render(HelpTooltip, { label: 'Why is this estimated?', text: 'Some help text.' }) - await user.click(screen.getByRole('button', { name: 'Why is this estimated?' })) - - expect(getTooltipLeft()).toBe(VIEWPORT_MARGIN) - }) - it('clamps the tooltip within the viewport when the trigger is near the right edge', async () => { - const user = userEvent.setup() - const viewportWidth = window.innerWidth - mockTriggerRect({ left: viewportWidth - 16, right: viewportWidth, width: 16 }) + expect(screen.getByRole('button', { name: 'Why is this estimated?' })).toHaveAttribute( + 'title', + 'Some help text.' + ) - render(HelpTooltip, { label: 'Why is this estimated?', text: 'Some help text.' }) await user.click(screen.getByRole('button', { name: 'Why is this estimated?' })) - - expect(getTooltipLeft()).toBe(viewportWidth - TOOLTIP_WIDTH - VIEWPORT_MARGIN) + expect(screen.getByRole('status', inOpenPanel)).toHaveTextContent('Some help text.') }) }) diff --git a/apps/web/src/lib/components/HelpTooltip.svelte b/apps/web/src/lib/components/HelpTooltip.svelte index e4ebba4..55c3e99 100644 --- a/apps/web/src/lib/components/HelpTooltip.svelte +++ b/apps/web/src/lib/components/HelpTooltip.svelte @@ -1,5 +1,6 @@ - - - - {#if open} - - - {text} - - {/if} - + + + + {text} + + diff --git a/apps/web/src/lib/components/ui/button/button.svelte b/apps/web/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..844abfe --- /dev/null +++ b/apps/web/src/lib/components/ui/button/button.svelte @@ -0,0 +1,89 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/apps/web/src/lib/components/ui/button/index.ts b/apps/web/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..522e1eb --- /dev/null +++ b/apps/web/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from './button.svelte' + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +} diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 0000000..3ece1fe --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..6ffb671 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,44 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else if checked} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..4d412c4 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..6580b8e --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..438636f --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..72f0441 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..079c1d5 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 0000000..692de5b --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..0780bc2 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..04706d7 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,34 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..c06bb76 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..1592e20 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..798b16a --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..cbc019f --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000..b7e0732 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..e160bc2 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 0000000..222b0c2 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/index.ts b/apps/web/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..7d6f63c --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import CheckboxGroup from './dropdown-menu-checkbox-group.svelte' +import CheckboxItem from './dropdown-menu-checkbox-item.svelte' +import Content from './dropdown-menu-content.svelte' +import GroupHeading from './dropdown-menu-group-heading.svelte' +import Group from './dropdown-menu-group.svelte' +import Item from './dropdown-menu-item.svelte' +import Label from './dropdown-menu-label.svelte' +import Portal from './dropdown-menu-portal.svelte' +import RadioGroup from './dropdown-menu-radio-group.svelte' +import RadioItem from './dropdown-menu-radio-item.svelte' +import Separator from './dropdown-menu-separator.svelte' +import Shortcut from './dropdown-menu-shortcut.svelte' +import SubContent from './dropdown-menu-sub-content.svelte' +import SubTrigger from './dropdown-menu-sub-trigger.svelte' +import Sub from './dropdown-menu-sub.svelte' +import Trigger from './dropdown-menu-trigger.svelte' +import Root from './dropdown-menu.svelte' + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +} diff --git a/apps/web/src/lib/components/ui/popover/index.ts b/apps/web/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..3c69c68 --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/index.ts @@ -0,0 +1,28 @@ +import Close from './popover-close.svelte' +import Content from './popover-content.svelte' +import Description from './popover-description.svelte' +import Header from './popover-header.svelte' +import Portal from './popover-portal.svelte' +import Title from './popover-title.svelte' +import Trigger from './popover-trigger.svelte' +import Root from './popover.svelte' + +export { + Root, + Content, + Description, + Header, + Title, + Trigger, + Close, + Portal, + // + Root as Popover, + Content as PopoverContent, + Description as PopoverDescription, + Header as PopoverHeader, + Title as PopoverTitle, + Trigger as PopoverTrigger, + Close as PopoverClose, + Portal as PopoverPortal, +} diff --git a/apps/web/src/lib/components/ui/popover/popover-close.svelte b/apps/web/src/lib/components/ui/popover/popover-close.svelte new file mode 100644 index 0000000..ba4404e --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/popover/popover-content.svelte b/apps/web/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..05530ff --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/apps/web/src/lib/components/ui/popover/popover-description.svelte b/apps/web/src/lib/components/ui/popover/popover-description.svelte new file mode 100644 index 0000000..877aa45 --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover-description.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/popover/popover-header.svelte b/apps/web/src/lib/components/ui/popover/popover-header.svelte new file mode 100644 index 0000000..72741ad --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/popover/popover-portal.svelte b/apps/web/src/lib/components/ui/popover/popover-portal.svelte new file mode 100644 index 0000000..35a11b3 --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/popover/popover-title.svelte b/apps/web/src/lib/components/ui/popover/popover-title.svelte new file mode 100644 index 0000000..4272c13 --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover-title.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/popover/popover-trigger.svelte b/apps/web/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..5888897 --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/src/lib/components/ui/popover/popover.svelte b/apps/web/src/lib/components/ui/popover/popover.svelte new file mode 100644 index 0000000..c07f414 --- /dev/null +++ b/apps/web/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..0983126 --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,16 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +/** Merges class lists, letting later Tailwind utilities override earlier conflicting ones. */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +// Prop-shaping helpers the vendored shadcn-svelte components in +// $lib/components/ui import from this module. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T +export type WithoutChildrenOrChild = WithoutChildren> +export type WithElementRef = T & { ref?: U | null } diff --git a/apps/web/src/routes/expenses/page.spec.ts b/apps/web/src/routes/expenses/page.spec.ts index dbb5741..1bc2745 100644 --- a/apps/web/src/routes/expenses/page.spec.ts +++ b/apps/web/src/routes/expenses/page.spec.ts @@ -532,7 +532,13 @@ describe('expenses page', () => { await screen.findByText('Groceries') await user.click(await screen.findByRole('button', { name: 'Actions for Groceries' })) - await user.click(screen.getByRole('menuitem', { name: 'Archive' })) + // Not getByRole('menuitem', { name }) - see ActionMenu.spec.ts's + // top-of-file comment: bits-ui's floating menu content never resolves + // out of `visibility: hidden` under jsdom (no real layout), and that's + // inherited by descendants, which empties out the accessible-name + // computation `getByRole(..., { name })` relies on. `getByText` matches + // raw text content instead, unaffected by that. + await user.click(await screen.findByText('Archive')) expect(updateExpense).toHaveBeenCalledWith(1, { isArchived: true }) }) diff --git a/apps/web/src/routes/income/+page.svelte b/apps/web/src/routes/income/+page.svelte index 19caa60..f1f74cc 100644 --- a/apps/web/src/routes/income/+page.svelte +++ b/apps/web/src/routes/income/+page.svelte @@ -33,6 +33,7 @@ todayISO, } from '$lib/format' import { ApiError } from '$lib/api' + import { cn } from '$lib/utils' import Card from '$lib/components/Card.svelte' import ErrorMessage from '$lib/components/ErrorMessage.svelte' import LoadingIndicator from '$lib/components/LoadingIndicator.svelte' @@ -1251,13 +1252,14 @@ { label: 'Other income', path: mdiBank, onclick: toggleAddOther }, ]} > - {#snippet trigger(open, toggle)} + {#snippet trigger(triggerProps)}