Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 62 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name>.test.ts` / `<Name>.test.tsx` next to `<Name>.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(<Button variant="primary">Continue</Button>)
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 `<flow>.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 <type>/<short-description>` (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.

Expand Down
15 changes: 11 additions & 4 deletions src/brand/Helio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
15 changes: 12 additions & 3 deletions src/brand/HelioWebGL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/brand/LiveHelio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Helio>` and the live `<HelioWebGL>`.
*
* Static-to-live fallback: the static `<Helio>` 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 `<Helio>` 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 `<Helio>` 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)
Expand Down
15 changes: 12 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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 — <Link> / 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
Loading