diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..953cbb37 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to Heliobond are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project intends to follow [Semantic Versioning](https://semver.org/) +once it cuts its first tagged release (it currently ships continuously from +`main` at `0.1.0`, per `package.json`). + +## How entries are added + +- Every PR that changes user-facing behaviour (a feature, a fix, a breaking + change) adds one line under **`[Unreleased]`**, in the category it belongs + to — `Added`, `Changed`, `Fixed`, `Removed`, `Security` — creating the + category heading if it doesn't exist yet. See [`CONTRIBUTING.md`](./CONTRIBUTING.md#development-workflow). +- Purely internal changes (refactors, tooling, formatting, CI, dependency + bumps with no behaviour change) don't need an entry. +- Word each entry from the user's or contributor's point of view, past tense, + one line, with the issue/PR number where useful — e.g. + `- Fixed the withdraw amount rounding to two decimals (#123).` +- When a release is cut, `[Unreleased]` is renamed to the new version and + date (`## [0.2.0] - 2026-09-01`), and a fresh empty `[Unreleased]` heading is + added above it. +- History prior to this file's creation isn't backfilled beyond the seed + entries below; see `git log` or the repo's GitHub releases for the full + commit history. + +## [Unreleased] + +### Added + +- Preemptive session timeout warning on auth forms, so in-progress form data + isn't silently lost (#352). +- Return projection on the investment form. + +### Changed + +- Auth login now detects existing social accounts during email login to + prevent duplicate accounts (#353). + +### Fixed + +- Investment form leading zeros, a nav prop mismatch, and decimal rounding. +- Text link and "Forgot Password" contrast on dark backgrounds (#351). +- Password reset emails now include an explicit token expiration time (#354). +- Investment hints, real-time fees, edit UX, and portfolio pending state. +- Bond filters, search, sort, and comparison. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e30e1f14..138f66ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,46 +54,98 @@ bun run test:e2e # Playwright end-to-end tests bun run start # serve the production build ``` -## Running tests +## Testing ### Unit and component tests (Vitest) The project uses [Vitest](https://vitest.dev) with a jsdom environment and [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro/) -for component rendering. +for component rendering (config: `vitest.config.mts`, `vitest.setup.ts`). ```bash bun run test # run all tests once and exit bun run test:ui # open the Vitest browser UI ``` -A shared render helper lives in `src/test/render.tsx`. It wraps components in -the i18n and theme providers the app uses, so component tests get a realistic -context. Import from there instead of `@testing-library/react` directly: +**Structure.** Unit and component tests are co-located with the code they +cover, as `.test.ts` / `.test.tsx` next to `.ts(x)` — e.g. +`src/components/Button.test.tsx`, `src/wallet/vault.test.ts`, +`src/hooks/useSessionTimeout.test.ts`. Tests that cover cross-cutting behaviour +rather than a single module (i18n catalog parity, shared bond math, contrast +ratios) live in `src/__tests__/` instead. Vitest picks up anything matching +`**/*.test.{ts,tsx}`, so a new test file just needs the right name and location +to be included automatically. + +**Helpers.** A shared render helper lives in `src/test/render.tsx`. It wraps +components in the `LocaleProvider` (i18n) and `ThemeProvider` the app uses at +runtime, so component tests get a realistic context instead of a bare tree. +Import `render` (and re-exported `@testing-library/react` utilities like +`screen`, `fireEvent`) from there instead of from `@testing-library/react` +directly: ```ts import { render, screen, fireEvent } from '@/test/render' + +test('renders the primary label', () => { + render() + expect(screen.getByRole('button', { name: 'Continue' })).toBeVisible() +}) ``` +If a test needs `next-intl` strings, they come from `messages/en.json` via the +helper's `LocaleProvider` — no extra setup required. Add new unit tests next to +the code under test using this pattern; there's no separate mocking layer to +configure beyond what `vitest.setup.ts` already provides. + ### End-to-end tests (Playwright) [Playwright](https://playwright.dev) drives a real Chromium browser against the -running Next.js dev server. +running Next.js dev server (config: `playwright.config.ts` — single Chromium +project, dev server started automatically unless one is already running). ```bash bun run test:e2e # headless Chromium (starts dev server automatically) ``` -E2E tests live in `e2e/`. The deposit smoke test seeds a demo wallet via -`localStorage` so no real Stellar wallet extension is required. +**Structure.** E2E specs live in `e2e/` as `.spec.ts` (e.g. +`e2e/deposit.spec.ts`), one file per user-facing flow, grouped with +`test.describe`. There's no page-object layer yet — specs query the DOM +directly via Testing-Library-style locators (`page.getByRole(...)`, +`page.getByText(...)`). + +**Helpers.** Because the wallet integration needs a real browser extension, +specs seed a demo session via `page.addInitScript` before navigating, so the +flow under test never depends on an actual Stellar wallet: + +```ts +async function seedDemoWallet(page: Page) { + await page.addInitScript( + ({ address }) => { + localStorage.setItem('hb-address', address) + localStorage.setItem('hb-wallet', 'demo') + }, + { address: DEMO_ADDRESS }, + ) +} +``` + +Follow `e2e/deposit.spec.ts` as the template for a new flow: seed whatever +session state the flow needs, `page.goto()` the route, then assert each step +of the flow in order with `expect(locator).toBeVisible()` / +`toBeDisabled()`. ## Development workflow 1. Branch off `main`: `git checkout -b /` (e.g. `feat/withdraw-max-chip`, `fix/helio-glow`, `i18n/creator-screens`). 2. Make focused changes — one issue per PR. 3. Run the checks locally: **`bun run build`** (must pass), **`bun run typecheck`**, **`bun run lint`**, **`bun run format:check`**, and **`bun run test`**. -4. Open a PR using the template; link the issue with `Closes #123`. -5. CI runs build, typecheck, lint, and format check on every PR; **`main` is protected** and requires green CI plus a maintainer review before merge. +4. If your change is user-facing or otherwise notable (a feature, a fix, a + breaking change), add an entry under `[Unreleased]` in + [`CHANGELOG.md`](./CHANGELOG.md) — see that file's "How entries are added" + section for the format. Purely internal changes (refactors, tooling, + formatting) don't need one. +5. Open a PR using the template; link the issue with `Closes #123`. +6. CI runs build, typecheck, lint, and format check on every PR; **`main` is protected** and requires green CI plus a maintainer review before merge. `CODEOWNERS` requires maintainer review for sensitive areas — the wallet integration, design tokens, i18n catalogs, and CI. diff --git a/src/brand/Helio.tsx b/src/brand/Helio.tsx index 8f728b56..038d2572 100644 --- a/src/brand/Helio.tsx +++ b/src/brand/Helio.tsx @@ -2,16 +2,23 @@ import { useId } from 'react' /** * Helio — the platform's one spectacle, here in its static, accessible fallback - * form. The live build is WebGL/R3F (a separate engineering deliverable); this - * is the no-WebGL / reduced-motion representation: a soft luminous solar orb - * with a faint corona of project motes (one mote per funded project). + * form. The live build is WebGL/R3F (`HelioWebGL`, wired in via `LiveHelio`); + * this is the no-WebGL / reduced-motion representation: a soft luminous solar + * orb with a faint corona of project motes (one mote per funded project). * * aria-hidden decoration — every datum it encodes is present as text elsewhere. */ export interface HelioProps { + /** Rendered size in px (square). Defaults to 360. */ size?: number - /** Number of corona motes — one per funded project. */ + /** Number of corona motes — one per funded project. Defaults to 14. */ motes?: number + /** + * Whether the orb plays its slow ~6s breathing animation (CSS `hb-breath` on + * the `.hb-orb` group). Defaults to true. Independent of `prefers-reduced-motion` + * support — that's handled globally in `app.css`, which forces `.hb-orb`'s + * animation off regardless of this prop. + */ breathe?: boolean } diff --git a/src/brand/HelioWebGL.tsx b/src/brand/HelioWebGL.tsx index f79c1f42..dcfb76a8 100644 --- a/src/brand/HelioWebGL.tsx +++ b/src/brand/HelioWebGL.tsx @@ -26,11 +26,20 @@ import { MeshDistortMaterial } from '@react-three/drei' import * as THREE from 'three' export interface HelioWebGLProps { - /** Rendered size in px (square). */ + /** Rendered size in px (square). Defaults to 360. */ size?: number - /** Number of corona motes — one per funded project. */ + /** Number of corona motes — one per funded project. Defaults to 14. */ motes?: number - /** Vault fullness, 0..1. Subtly scales the orb's luminance + size. */ + /** + * Vault fullness, expressed 0..1 (values outside that range are clamped — + * see `clamp01`). Drives three things together so the orb "fills up" as the + * pool does: + * · the sun's resting scale (0.9 at 0 → 1.02 at 1, `baseScale`) + * · the sun's emissive brightness (0.5 at 0 → 0.9 at 1, `baseEmissive`) + * · the glow halo's opacity (0.18 at 0 → 0.34 at 1, `baseOpacity` in `Halo`) + * Defaults to 1 (fully lit). The breathing animation oscillates on top of + * whatever base these produce, so intensity sets the floor, not a fixed value. + */ intensity?: number /** Fired once the canvas has painted its first frame (used to cross-fade from the static fallback). */ onReady?: () => void diff --git a/src/brand/LiveHelio.tsx b/src/brand/LiveHelio.tsx index 9011b8a0..4f807a6d 100644 --- a/src/brand/LiveHelio.tsx +++ b/src/brand/LiveHelio.tsx @@ -15,6 +15,26 @@ const HelioWebGL = dynamic(() => import('./HelioWebGL'), { ssr: false, loading: * Live WebGL Helio with the static accessible SVG orb as its always-present * fallback. Used at the landing hero (the one place the brief grants the full * spectacle); the smaller portfolio / success orbs stay static by design. + * + * Props: same as `HelioProps` (`size`, `motes`, `breathe`), plus `intensity` + * (vault fullness 0..1 — see `HelioWebGLProps.intensity`), forwarded straight + * through to both the static `` and the live ``. + * + * Static-to-live fallback: the static `` renders immediately (and is + * what SSR produces) and stays visible — at full opacity, `live` starts + * `false` — until `HelioWebGL` reports its first painted frame via `onReady`, + * at which point the live canvas fades in over 600ms while the static orb is + * unmounted underneath. `ErrorBoundary` falls back to `` if the WebGL + * canvas throws. + * + * Reduced-motion / no-WebGL: `HelioWebGL` is loaded with `ssr: false` and, + * once mounted, probes for both WebGL support and `prefers-reduced-motion`. + * With no WebGL it renders `null` outright; with reduced motion it still + * renders the canvas but disables `useFrame` animation (see `HelioWebGL`'s own + * "Robustness contract"). In the no-WebGL case `onReady` is never called, so + * `live` never flips to `true` and the static `` simply remains the + * permanent, non-animated (per `app.css`'s `prefers-reduced-motion` rule) + * display — no broken cross-fade, no blank frame. */ export function LiveHelio(props: HelioProps & { intensity?: number }) { const [live, setLive] = useState(false) diff --git a/src/types.ts b/src/types.ts index f12ef103..33e131cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,13 @@ -// Screens in the Heliobond click-through. 'how' and 'learn' currently route to -// Explore (the public, gate-free surfaces) — mirrors the source prototype. +// Steps of the investor click-through (see README.md's "landing → connect → +// explore → project detail → deposit → portfolio → withdraw"), one per +// top-level route under src/app. Navigation itself is handled by the Next.js +// App Router — / useRouter().push('/…') — not by this union or an +// onNav(screen) callback, which the route-based navigation has superseded. export type Screen = - 'landing' | 'connect' | 'explore' | 'how' | 'learn' | 'deposit' | 'portfolio' | 'withdraw' + | 'landing' // / + | 'connect' // /connect + | 'explore' // /explore + | 'project' // /project/[id] + | 'deposit' // /deposit + | 'portfolio' // /portfolio + | 'withdraw' // /withdraw