From 36573f4fd1858f771ac76f06bb556e36adcdac2d Mon Sep 17 00:00:00 2001 From: devsimze <238806700+devsimze@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:19:05 +0100 Subject: [PATCH 1/4] fix(nav): prune vestigial Screen union to match real routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Screen union and its onNav(screen) pattern predate the App Router migration and had no consumers left in the codebase — 'how' and 'learn' were never real routes, and 'project' (the click-through's actual detail step) was missing entirely. Rewrite it to mirror the investor click-through's real top-level routes under src/app, with each member commented against its route. Closes #374 --- src/types.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/types.ts b/src/types.ts index f12ef10..33e131c 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 From 978ce376ca85271f64ab39044de3f4969b368423 Mon Sep 17 00:00:00 2001 From: devsimze <238806700+devsimze@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:19:13 +0100 Subject: [PATCH 2/4] docs(brand): document Helio props and fallback behaviour Expand the JSDoc on Helio, HelioWebGL, and LiveHelio so the API is documented in one discoverable place instead of scattered/implicit comments: - HelioProps / HelioWebGLProps: document every prop, including exactly what `intensity` drives (resting scale, emissive brightness, halo opacity) and that `breathe` is independent of the CSS-level prefers-reduced-motion override in app.css. - LiveHelio: document the static-to-live cross-fade sequence (static orb visible until HelioWebGL's onReady fires, then 600ms fade) and the reduced-motion / no-WebGL fallback (canvas never paints, so the static orb simply remains). Closes #379 --- src/brand/Helio.tsx | 15 +++++++++++---- src/brand/HelioWebGL.tsx | 15 ++++++++++++--- src/brand/LiveHelio.tsx | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/brand/Helio.tsx b/src/brand/Helio.tsx index 8f728b5..038d257 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 f79c1f4..dcfb76a 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 9011b8a..4f807a6 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) From 61feecf9e4a48a9d4913f74b9c6ce1d10364a3e3 Mon Sep 17 00:00:00 2001 From: devsimze <238806700+devsimze@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:20:03 +0100 Subject: [PATCH 3/4] docs: document how to write and run tests in CONTRIBUTING Expand the "Running tests" section into "Testing", adding the structure (co-located *.test.ts(x) next to source, src/__tests__/ for cross-cutting cases, e2e/ as one *.spec.ts per flow) and helper conventions (src/test/render.tsx for Vitest, the demo-wallet seeding pattern for Playwright) so new tests follow the same pattern the existing suite already uses, on top of the run instructions that were already documented. Closes #377 --- CONTRIBUTING.md | 63 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e30e1f1..6d4e374 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,38 +54,85 @@ 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 From c8dae46fdf8df22dec41310f4b6120b4dab6b08b Mon Sep 17 00:00:00 2001 From: devsimze <238806700+devsimze@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:20:16 +0100 Subject: [PATCH 4/4] docs: add CHANGELOG and document the update process Add CHANGELOG.md (Keep a Changelog format), seeded with an Unreleased section summarizing the app's recent notable changes, and document in CONTRIBUTING.md's development workflow when and how contributors add an entry for their own PR. Closes #378 --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 9 +++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..953cbb3 --- /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 6d4e374..138f66e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,8 +139,13 @@ of the flow in order with `expect(locator).toBeVisible()` / 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.