Compare commits

...

32 Commits

Author SHA1 Message Date
Yana Matrosova ec51855500 NYM-1199: fix @nymproject/types generation gap (export NodeAnnotation deps)
The generated NodeAnnotationV1/V2.ts imported DetailedNodePerformanceV1/V2,
DisplayRole, RoutingScore, ConfigScore and StressTestingScore, but those types
(all of which derive ts_rs::TS with an export_to) were missing from
tools/ts-rs-cli, so the files were never emitted — leaving dangling imports that
broke `tsc` for the whole @nymproject/types package.

Add the 6 types to ts-rs-cli (use + do_export!) and regenerate. The package now
builds with 0 tsc errors, and the full nym-wallet Jest run goes from "84 pass +
4 suites unable to run" to 101 pass / 0 fail — recovering delegationIdentity and
unbondedDelegation.acceptance. The 3 touched FamilyInvitation*/PastFamilyInvitation
files are doc-comment syncs from the regeneration (the `at: number` overrides and
enum variants are unchanged).

Out of scope / still failing for a separate reason: api/nodeStatus.test.ts and
api/networkOverview.test.ts reference the network value 'QA', which the Network
enum (nym-wallet-types → SANDBOX | MAINNET) no longer includes — an app-code
drift unrelated to families or type generation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:57:13 +03:00
Yana Matrosova fcc4cbceea NYM-1199: full member/operator families journey green on sandbox (all 9 cmds)
Add a two-account member/operator journey (`--member`) to the sandbox smoke and
verify the remaining 6 execute commands end-to-end on chain. owner=FAMILY_OWNER
(controls family 1), operator=ACCOUNT_WITH_BONDED_NODE (controls node_id 31):
kick → invite/reject → invite/revoke → invite/accept/leave, each with per-step
state assertions, then restores node 31's membership (verified via --accounts:
family 1 back to 1 member). A real pre-existing family is left exactly as found.

Also adds read-only `--accounts` (prints owner/operator on-chain state) and
generalizes the .env loader to multiple keys (TAURI-WALLET / FAMILY_OWNER /
ACCOUNT_WITH_BONDED_NODE mnemonics) — all read at runtime, never printed.

With the earlier `--write` owner subset (create/update/disband), all 9 families
execute commands + the queries are now confirmed against the deployed sandbox
contract. Tasks 5.2/5.3/5.4 marked done; e2e/README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:34:40 +03:00
Yana Matrosova 56cafe2370 NYM-1199: add read-only --bond-check to sandbox families smoke
Adds `--bond-check <address>` to report which node (nym-node / mixnode /
gateway, with node_id) an arbitrary account controls on the sandbox mixnet
contract — read-only, no state change. Used to confirm whether a candidate
account can act as the node operator for the invite/accept/kick flow.

Finding on sandbox: the family-owner accounts (n13nrrvw…, n18cuqlr…) control
no bonded node, so exercising the 6 member commands still needs an account
that controls a bonded node + its mnemonic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:08:13 +03:00
Yana Matrosova fcf24f0c33 NYM-1199: extend sandbox smoke to the full 9-command families lifecycle
Expand sandbox_families_smoke.rs from create→rename→disband to the complete
owner+operator journey, exercising all 9 execute commands against sandbox: the
account acts as both family owner and node operator, so one funded account
drives create → invite → revoke / reject / accept → leave / kick → disband,
each step gated on a state-poll assertion (pending present/absent, membership
joined/left).

The numeric node_id is resolved from the mixnet contract (owned nym-node, else
legacy mixnode). When the account controls no node, the harness runs the
owner-only subset (create/update/disband) and skips the 6 member-management
commands with a notice instead of aborting — so it stays useful either way.

Verified on sandbox: read smoke + owner subset green (real tx hashes, state
cleaned up). The member commands are implemented and auto-run once a node is
bonded to the account — currently the funded account `n13jtj2…` is an address,
not a node, and a bond lookup (nym-node/mixnode/gateway) finds nothing for it,
so there is no node_id to invite/accept/kick yet. Tasks 5.2-5.4 updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:45:41 +03:00
Yana Matrosova 645b8c7abf NYM-1199: sync Cargo.lock to workspace 1.21.1 bump 2026-06-10 17:33:02 +03:00
Yana Matrosova 3a77eff16b NYM-1199: sandbox families read smoke + guarded write journey (real IPC verified)
Add a headless sandbox smoke (src-tauri/examples/sandbox_families_smoke.rs) that
exercises the node-families real-IPC layer against the contract deployed to
sandbox, via the same validator-client calls the Tauri commands wrap. GUI
automation of the real wallet can't run on macOS (tauri-driver is Linux/Windows
only; Playwright can't drive the native webview), so this is the runnable
equivalent of the manual Tier-3 smoke.

- Read smoke (task 4.1): resolves the bundled sandbox families contract address,
  reads Config from raw state, lists live families/members/pending invites via
  real IPC. Passes against sandbox (2 families, fee 50 NYM, limits 30/50).
- Guarded write journey (task 5.2, `-- --write`): create -> rename -> disband on
  the funded test account, with on-chain-latency polling and cleanup; refuses to
  run if the account already owns a family. All three txs confirmed on sandbox.
  invite/accept/reject/kick/revoke/leave are wired but need a controllable,
  unaffiliated sandbox node id (5.3 / rest of 5.2 still pending that fixture).

The funded account mnemonic is read from .env (TAURI-WALLET-MNEMONIC, gitignored)
at runtime and never printed or committed. Run instructions added to e2e/README.md;
tasks 4.1/4.2 marked done, 5.2/5.4 partial, 5.3 still blocked on a node fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:25:19 +03:00
Yana Matrosova b0dbb174cb NYM-1199: mark node-families-real-ipc docs tasks (6.3, 6.4) complete
The e2e/README.md real-IPC + sandbox-smoke docs and the parent change §9.4
cross-ref already landed in ee1e6c4ac; reconcile the task checkboxes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:51:47 +03:00
Yana Matrosova 50382553c2 NYM-1199: implement real Node Families IPC (Rust command layer + provider wiring)
Add the missing Tauri command layer for the node-families contract and switch
the wallet's real provider onto it (node-families-real-ipc groups 1-3, docs).

Rust command layer (src-tauri/src/operations/families/):
- execute.rs: 9 state-changing commands over NodeFamiliesSigningClient, auto/
  simulated gas; create_family attaches create_family_fee as base-denom funds.
  Returns TransactionExecuteResult (family_events omitted per D2 - the provider
  refreshAll()s reads after every execute).
- query.rs: 9 reads over NodeFamiliesQueryClient, normalised at the IPC boundary
  to the wallet shapes in src/types/families.ts: base Coin -> display DecCoin
  (paid_fee, create_family_fee), per-page contract envelopes -> { items,
  start_next_after }, cw_serde tagged FamilyInvitationStatus -> { kind, at }.
  get_family_config reads raw contract state at the "config" key (no smart query).
- Registered all 18 commands in main.rs; added the contract-common path dep.
- cargo build + cargo clippy clean.

Provider wiring:
- Replace the controlledNodeIds = [] stub in FamiliesContextProvider with a
  derivation from useBondingContext().bondedNode ([nodeId]/[mixId]/[]); wrap the
  /family route in BondingContextProvider (it is per-route, not global). D3.
- Fix the requests/families.ts execute bindings to pass camelCase arg keys
  (Tauri maps JS camelCase -> Rust snake_case params).

Types reconciliation + drift guard:
- Fix the generated FamilyInvitationStatus `at` field (bigint -> number) and
  export the 18 generated family types from the @nymproject/types barrel.
- Add src/types/families.contract-guard.ts: tsc-checked assertions locking the
  wallet families types against the ts-rs-generated contract types; IPC-normalised
  fields excluded with documented reasons.

Docs: real-IPC path + guarded sandbox write-flow procedure in e2e/README.md;
design D3 one-bonded-node-per-account note; parent change 9.4/9.5 cross-ref;
update_family arg shape confirmed against the contract (6.2).

Families Jest suites green; contract guard passes. Remaining sandbox read/write
smokes (3.3, groups 4-5) need a wired native build + sandbox + the funded test
account, tracked in tasks.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:51:46 +03:00
Yana Matrosova 1bb7b1f68b NYM-1199: generate TypeScript types for node-families contract via ts-rs 2026-06-10 16:49:44 +03:00
Yana Matrosova 44a87e79de NYM-1199: resolve open questions for node-families-real-ipc 2026-06-10 16:49:44 +03:00
Yana Matrosova 2baf9aca95 NYM-1199: Propose implementation of real Node Families IPC in wallet
This change introduces the design, requirements, and tasks for connecting the
Node Families UI to the on-chain contract via the wallet's Tauri IPC layer.
The frontend currently calls 18 commands that are not implemented on the
Rust side, leaving a gap between the mock UI and the deployed contract.
This proposal details how to bridge this gap, replace the mock provider
with real data, and verify journeys against the sandbox environment.
2026-06-10 16:49:39 +03:00
Yana Matrosova c9e2d18ef3 NYM-1199: Add visual flow report for Node Families E2E tests
Enhances Playwright E2E tests by capturing full-page screenshots at each
journey step, which are then stitched into a static `e2e-report/index.html`
filmstrip.

This report is uploaded as a CI artifact (even on failure) to provide a
visual overview of test execution, aiding in the detection of UI regressions
and expediting debugging of test failures.
2026-06-10 16:49:39 +03:00
Yana Matrosova 2007894ee6 NYM-1199: wire + verify mock Tauri binary for WebdriverIO) 2026-06-10 16:49:39 +03:00
Yana Matrosova f25bc99e5c NYM-1199: Finalize native webview E2E wiring / unblock mock binary build
This commit completes the setup for `tauri-driver` E2E tests by correctly wiring the mock binary and resolving a blocking build issue.

Key changes include:
- Implementing the `tauri.mock.conf.json` to boot the mock binary into `main.mock.html` and configuring persona-specific in-webview navigation within the E2E spec.
- Resolving a long-standing `webpack:prod` failure by adjusting `tsconfig.json` with `outDir: ./.tsbuild` and excluding test files from the main program, alongside adding a `declare module '*.css'` type declaration.
- Updating `wdio.conf.ts` and documentation to reflect the completed native leg wiring.
2026-06-10 16:49:39 +03:00
Yana Matrosova 0e386b3b92 NYM-1199: Node Families E2E with mock app shell & native webview tests
Implements the new Node Families E2E testing strategy, shifting the primary Playwright suite from Storybook iframes to a dedicated mock-wired app shell (`main.mock.html`). This provides higher-fidelity testing against the actual wallet UI and router without a Tauri runtime or login.

Also introduces an optional WebdriverIO + `tauri-driver` suite for native WebKitGTK webview validation, configured as a non-blocking CI job for Linux. A critical fix for `NymCard`'s `data-testid` prop is included to enable robust UI selectors across all E2E tiers.

Updates CI workflows, adds new scripts for mock builds and native tests, and provides comprehensive documentation in `e2e/README.md` for the tiered testing approach.
2026-06-10 16:49:39 +03:00
Yana Matrosova 7b061caf46 NYM-1199: Finalize Node Families E2E design for mock and native tests
Resolves open questions, detailing runtime persona selection for the mock server (D2), consolidating the Playwright suite (D10), scoping the native leg to Linux-only (D8), and introducing a manual sandbox smoke test (D9). Updates related tasks accordingly.
2026-06-10 16:49:34 +03:00
Yana Matrosova 919ea1e981 NYM-1199: Realign Node Families E2E strategy with Playwright as primary
Revises the end-to-end testing approach for Node Families. The primary
E2E suite now uses Playwright against the mock-wired dev server, ensuring
cross-platform coverage including macOS and reusing existing tooling. The
WebdriverIO + tauri-driver native-webview suite is re-scoped as an
optional, CI-only validation leg. Additionally, an optional read-only
sandbox IPC smoke test is introduced for higher fidelity.
2026-06-10 16:49:33 +03:00
Yana Matrosova cdb88cc08f NYM-1199: Update Node Families E2E openspec for CI and recent merges
This commit refines the design, proposal, and tasks for Node Families native webview E2E testing.

It reconciles the plan with recent merges, including the `ci-nym-wallet-frontend.yml` workflow (detailing the separate E2E job), Figma Code Connect, and the Nym 2.0 theme swap. The updates provide clearer guidance on CI integration and potential feature interactions.
2026-06-10 16:49:33 +03:00
huximaxi 1d9215a0c0 [DEV] add Code Connect scaffold for FamilyPage (NYM-1199)
Installs @figma/code-connect 1.4.7 as dev dependency. Adds figma.config.json
at nym-wallet root (include: src/**/*.figma.tsx). Creates FamilyPage.figma.tsx
mapping FamilyPage to Figma node 1861:1889 (Node Families composite,
moIK1E6AaXhFz8lI1pZVrI). Publish is Tier-1 gated — run separately with PAT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:49:33 +03:00
huximaxi eb774da916 [DEV] apply Nym 2.0 token swap to wallet MUI theme (NYM-1199)
Seven palette values updated from Nym 2.0 design system (nym-color-tokens_fin.html):
highlight #5BF0A0, info #485ECA, error #E73E14, linkHover #5BF0A0,
bg.main #0A0A0A, bg.paper #1A1A1C, text.subdued #AEACB1.

Adds nym2-tokens.ts (canonical Nym 2.0 values, dark + light) and
nym2-map.ts (documents confirmed vs deferred changes). Light mode
and success semantic split deferred to follow-up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:49:29 +03:00
Mark Sinclair 84fd7fe57f GitHub Actions: try another way 2026-06-10 16:49:28 +03:00
Mark Sinclair 5723a828fa GitHub Actions: rsync args 2026-06-10 16:49:28 +03:00
Mark Sinclair e63ed3fb99 GitHub Actions: path typo 2026-06-10 16:49:28 +03:00
Mark Sinclair da69934be1 GitHub Actions: typo 2026-06-10 16:49:28 +03:00
Mark Sinclair ce1739fe5a GitHub Actions: add storybook upload 2026-06-10 16:49:28 +03:00
Yana Matrosova 7d72526405 NYM-1199: Add openspec for Node Families native webview E2E testing
This openspec outlines the design, proposal, requirements, and tasks to implement end-to-end tests for the Node Families feature within the actual Tauri desktop shell. It aims to verify the UI and user journeys against the native webview, complementing existing Storybook-based E2E tests.

The plan involves using WebdriverIO and `tauri-driver` to drive a mock-wired Tauri build, ensuring mock code is tree-shaken from production. These tests will run in Linux CI, providing confidence that the feature behaves as expected in its native environment.
```
2026-06-10 16:49:28 +03:00
Yana Matrosova 5650bfbd4e NYM-1199: Add manual Storybook variants for Node Families flows
Introduces interactive versions of the owner and operator lifecycle stories.
These allow developers to manually trigger actions (create, invite, accept,
kick, disband, leave) and observe UI state changes, complementing the
existing automated 'play' function stories.
2026-06-10 16:49:27 +03:00
Yana Matrosova 5baccc4a97 NYM-1199: Add Node Families wallet feature with comprehensive UI and E2E tests
This commit introduces the complete frontend implementation for the Node Families feature. It includes:
- All owner-side UI (create, edit, invite, manage members, disband) and operator-side UI (view/accept/reject invites, leave family).
- Client-side state management, query hooks, and Tauri IPC bindings.
- A full in-memory mock contract for isolated testing.
- Extensive Storybook component, page, and flow stories.
- Playwright end-to-end tests running against Storybook.
- Updates spec documentation with Figma design sources and resolves open questions.
2026-06-10 16:49:27 +03:00
Jędrzej Stuczyński c0a1c77709 giving the AI friends a bit of reality check 2026-06-10 16:49:19 +03:00
Yana Matrosova 4f51e50fb1 Refine specs with open questions 2026-06-10 16:49:19 +03:00
Yana Matrosova 41fadee392 Update README 2026-06-10 16:49:19 +03:00
Yana Matrosova 87b5dcfd05 NYM-1199: integrate OpenSpec AI for Node Families UI
This commit introduces the foundational setup for developing the Node Families wallet frontend. It integrates the OpenSpec AI development workflow, including prompts and skills, to enable a structured, AI-assisted approach. Additionally, Storybook is configured for comprehensive UI component visualization, mocking, and testing for the new features.

Refers to NYM-1199.
2026-06-10 16:49:19 +03:00
249 changed files with 19124 additions and 983 deletions
+108 -1
View File
@@ -8,7 +8,7 @@ on:
- '.github/workflows/ci-nym-wallet-frontend.yml'
jobs:
types-lint:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
@@ -44,3 +44,110 @@ jobs:
- name: Unit tests (nym-wallet)
run: pnpm --filter @nymproject/nym-wallet-app test
- name: Install Playwright browser (chromium)
run: pnpm --filter @nymproject/nym-wallet-app exec playwright install --with-deps chromium
- name: e2e — Playwright vs mock-wired dev server (primary, design D1)
# Launches the mock-wired dev server (webpack:dev:mock, WALLET_MOCK_FAMILIES=on)
# via Playwright's webServer and replays the owner/operator journeys. Fails the job.
run: pnpm --filter @nymproject/nym-wallet-app test:e2e
- name: Prepare build output directory
shell: bash
env:
OUTPUT_DIR: ci-builds/${{ github.ref_name }}/nym-wallet/storybook/
run: |
rm -rf ci-builds || true
mkdir -p "$OUTPUT_DIR"
echo "$OUTPUT_DIR"
- name: Build storybook
run: pnpm --filter @nymproject/nym-wallet-app build-storybook
- name: Copy storybook outputs
shell: bash
env:
OUTPUT_DIR: ci-builds/${{ github.ref_name }}/nym-wallet/storybook/
run: |
cp -R nym-wallet/storybook-static "$OUTPUT_DIR"
- name: Stage e2e visual flow report
# Generated by the Playwright run (globalTeardown) regardless of pass/fail; `always()`
# so a failing run still uploads the filmstrip for inspection. mkdir covers the
# e2e-failure path where "Prepare build output directory" was skipped.
if: always()
shell: bash
env:
REPORT_DIR: ci-builds/${{ github.ref_name }}/nym-wallet/e2e-report/
run: |
if [ -d nym-wallet/e2e-report ]; then
mkdir -p "$REPORT_DIR"
cp -R nym-wallet/e2e-report/. "$REPORT_DIR"
else
echo "no e2e-report produced"
fi
- name: Upload CI artifacts (storybook + e2e visual report)
if: always()
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-avz"
SOURCE: "ci-builds/"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/builds/
EXCLUDE: "/dist/, /node_modules/"
# Optional native-webview validation (design D4/D8): drives the packaged Tauri binary
# through tauri-driver + WebdriverIO under xvfb. Linux-only; non-blocking until stabilised.
# NOTE: the final wiring (Tauri window → main.mock.html in the mock build) is a documented
# TODO in wdio.conf.ts; this job is the scaffold that runs once that lands.
e2e-tauri:
runs-on: ubuntu-22.04
continue-on-error: true
steps:
- uses: actions/checkout@v6
- name: Install Tauri + WebDriver system deps
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev webkit2gtk-driver xvfb \
libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev \
build-essential curl wget file libssl-dev libsoup-3.0-dev
- name: Setup pnpm
uses: pnpm/action-setup@v5.0.0
with:
version: 11.1.2
- uses: actions/setup-node@v4
with:
node-version-file: nym-wallet/.nvmrc
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: nym-wallet/src-tauri
- name: Install dependencies
run: pnpm install
- name: Build TypeScript packages
run: pnpm build:types && pnpm build:packages
- name: Install tauri-driver
run: cargo install tauri-driver --locked
- name: Build mock-wired Tauri binary
# TODO (native-leg wiring): build the Tauri app from the WALLET_MOCK_FAMILIES=on
# frontend with the window pointed at main.mock.html. See wdio.conf.ts.
run: pnpm --filter @nymproject/nym-wallet-app tauri:build:mock
- name: Run WebdriverIO native-webview suite (under xvfb)
run: xvfb-run -a pnpm --filter @nymproject/nym-wallet-app test:e2e:tauri
+5
View File
@@ -83,6 +83,11 @@ test-tutorials/
# pnpm
.pnpm-store/
<<<<<<< HEAD
tmp/
# operator tools
scripts/nym-node-setup/auto-bond/nodes.csv
=======
*storybook.log
>>>>>>> dea01ef63 (NYM-1199: integrate OpenSpec AI for Node Families UI)
Generated
+3 -1
View File
@@ -7709,6 +7709,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"thiserror 2.0.18",
"ts-rs",
]
[[package]]
@@ -11482,7 +11483,7 @@ dependencies = [
"nym-sdk",
"reqwest 0.13.4",
"rustls 0.23.40",
"smoltcp",
"smoltcp 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)",
"thiserror 2.0.18",
"tokio",
"tokio-rustls 0.26.4",
@@ -13090,6 +13091,7 @@ dependencies = [
"anyhow",
"nym-api-requests",
"nym-mixnet-contract-common",
"nym-node-families-contract-common",
"nym-types",
"nym-validator-client",
"nym-vesting-contract-common",
+2
View File
@@ -73,6 +73,8 @@ Nym Node Operators and Validators Terms and Conditions can be found [here](https
## Getting Started
Requires [Node.js](https://nodejs.org) version 24.
```bash
pnpm install
```
@@ -24,9 +24,11 @@ cw-utils = { workspace = true }
nym-contracts-common = { workspace = true }
nym-mixnet-contract-common = { workspace = true }
ts-rs = { workspace = true, optional = true }
[features]
schema = []
generate-ts = ['ts-rs']
[lints]
workspace = true
@@ -13,8 +13,21 @@ pub type NodeFamilyId = u32;
/// Runtime configuration of the node families contract.
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/FamilyConfig.ts",
rename = "FamilyConfig"
)
)]
pub struct Config {
/// Fee charged on each successful `create_family` execution.
#[cfg_attr(
feature = "generate-ts",
ts(type = "{ denom: string, amount: string }")
)]
pub create_family_fee: Coin,
/// Maximum allowed length, in characters, of a family name.
@@ -26,11 +39,17 @@ pub struct Config {
/// Default lifetime, in seconds, used by `invite_to_family` when the
/// sender doesn't supply an explicit value. Senders may override this
/// per-invitation via the optional `validity_secs` argument.
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
pub default_invitation_validity_secs: u64,
}
/// On-chain representation of a node family.
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(export, export_to = "ts-packages/types/src/types/rust/NodeFamily.ts")
)]
pub struct NodeFamily {
/// The id of the node family
pub id: NodeFamilyId,
@@ -45,17 +64,24 @@ pub struct NodeFamily {
pub description: String,
/// The owner of the node family
#[cfg_attr(feature = "generate-ts", ts(type = "string"))]
pub owner: Addr,
/// Records the fee paid when the family was created,
/// so that the appropriate amount could be returned upon it getting disbanded.
#[cfg_attr(
feature = "generate-ts",
ts(type = "{ denom: string, amount: string }")
)]
pub paid_fee: Coin,
/// Memoized value of the current number of members in the node family
/// Used to detect if the family is empty
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
pub members: u64,
/// Timestamp of the creation of the node family
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
pub created_at: u64,
}
@@ -68,6 +94,14 @@ pub struct NodeFamily {
/// issues a fresh invitation for the same node, which archives the stale one as
/// `Expired` and supersedes it.
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/FamilyInvitation.ts"
)
)]
pub struct FamilyInvitation {
/// The family that issued the invitation.
pub family_id: NodeFamilyId,
@@ -76,6 +110,7 @@ pub struct FamilyInvitation {
pub node_id: NodeId,
/// Block timestamp (unix seconds) after which the invitation is no longer valid.
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
pub expires_at: u64,
}
@@ -85,18 +120,35 @@ pub struct FamilyInvitation {
/// `NodeId` alone — `family_id` is carried in the value to support reverse
/// lookups (all nodes in a given family) via a secondary index.
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/FamilyMembership.ts"
)
)]
pub struct FamilyMembership {
/// The family the node is currently a member of.
pub family_id: NodeFamilyId,
/// Block timestamp (unix seconds) at which the node accepted its
/// invitation and joined the family.
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
pub joined_at: u64,
}
/// Historical record of a node that used to be part of a family but has since been
/// removed (kicked, left voluntarily, or because the family was disbanded).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PastFamilyMember.ts"
)
)]
pub struct PastFamilyMember {
/// The family the node used to belong to.
pub family_id: NodeFamilyId,
@@ -105,6 +157,7 @@ pub struct PastFamilyMember {
pub node_id: NodeId,
/// Block timestamp (unix seconds) at which the membership was terminated.
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
pub removed_at: u64,
}
@@ -115,21 +168,37 @@ pub struct PastFamilyMember {
/// reaches `Expired` if the family issues a fresh invitation for the same node, which
/// supersedes and archives the stale one.
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/FamilyInvitationStatus.ts"
)
)]
pub enum FamilyInvitationStatus {
/// Still awaiting a response. Recorded with a timestamp for completeness even
/// though pending invitations live in a separate map.
Pending { at: u64 },
Pending {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
/// The invitee accepted and joined the family at the given timestamp.
Accepted { at: u64 },
Accepted {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
/// The invitee explicitly rejected the invitation at the given timestamp.
Rejected { at: u64 },
Rejected {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
/// The family revoked the invitation at the given timestamp before it could
/// be accepted or rejected.
Revoked { at: u64 },
/// The invitation had already expired and was superseded by a fresh invitation
/// for the same node from the same family, issued at the given timestamp. This is
/// the only path that archives a timed-out invitation.
Expired { at: u64 },
Revoked {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
}
/// Historical record of an invitation that has reached a terminal state
@@ -137,6 +206,14 @@ pub enum FamilyInvitationStatus {
/// archived here only when a fresh invitation for the same node supersedes it
/// (status `Expired`); otherwise it stays in the pending map until explicitly cleared.
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PastFamilyInvitation.ts"
)
)]
pub struct PastFamilyInvitation {
/// The original invitation as it was issued.
pub invitation: FamilyInvitation,
@@ -147,6 +224,14 @@ pub struct PastFamilyInvitation {
/// Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/NodeFamilyResponse.ts"
)
)]
pub struct NodeFamilyResponse {
/// The id that was queried, echoed back so paginated callers can correlate.
pub family_id: NodeFamilyId,
@@ -157,9 +242,18 @@ pub struct NodeFamilyResponse {
/// Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/NodeFamilyByOwnerResponse.ts"
)
)]
pub struct NodeFamilyByOwnerResponse {
/// The (validated) owner address that was queried, echoed back so callers
/// can correlate.
#[cfg_attr(feature = "generate-ts", ts(type = "string"))]
pub owner: Addr,
/// The matching family, or `None` if `owner` does not currently own one.
@@ -178,6 +272,14 @@ pub struct NodeFamilyByNameResponse {
/// Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/NodeFamilyMembershipResponse.ts"
)
)]
pub struct NodeFamilyMembershipResponse {
/// The node that was queried.
pub node_id: NodeId,
@@ -190,6 +292,14 @@ pub struct NodeFamilyMembershipResponse {
/// A pending [`FamilyInvitation`] paired with whether it has already timed
/// out at the time the query was served.
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PendingFamilyInvitationDetails.ts"
)
)]
pub struct PendingFamilyInvitationDetails {
/// The stored invitation as it was issued.
pub invitation: FamilyInvitation,
@@ -201,6 +311,14 @@ pub struct PendingFamilyInvitationDetails {
/// Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PendingFamilyInvitationResponse.ts"
)
)]
pub struct PendingFamilyInvitationResponse {
/// The family component of the queried `(family_id, node_id)` key.
pub family_id: NodeFamilyId,
@@ -216,6 +334,14 @@ pub struct PendingFamilyInvitationResponse {
/// One entry in a [`FamilyMembersPagedResponse`] page — pairs a node id with
/// its [`FamilyMembership`] record (notably its `joined_at` timestamp).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/FamilyMemberRecord.ts"
)
)]
pub struct FamilyMemberRecord {
/// The node currently in the family.
pub node_id: NodeId,
@@ -226,6 +352,14 @@ pub struct FamilyMemberRecord {
/// Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/FamilyMembersPagedResponse.ts"
)
)]
pub struct FamilyMembersPagedResponse {
/// The family whose members were queried, echoed back so paginated
/// callers can correlate.
@@ -253,6 +387,14 @@ pub struct AllFamilyMembersPagedResponse {
/// Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PendingFamilyInvitationsPagedResponse.ts"
)
)]
pub struct PendingFamilyInvitationsPagedResponse {
/// The family whose pending invitations were queried, echoed back so
/// paginated callers can correlate.
@@ -270,6 +412,14 @@ pub struct PendingFamilyInvitationsPagedResponse {
/// Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PendingInvitationsForNodePagedResponse.ts"
)
)]
pub struct PendingInvitationsForNodePagedResponse {
/// The node whose pending invitations were queried, echoed back so
/// paginated callers can correlate.
@@ -316,6 +466,14 @@ pub type GlobalPastFamilyInvitationCursor = ((NodeFamilyId, NodeId), u64);
/// Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PastFamilyInvitationsPagedResponse.ts"
)
)]
pub struct PastFamilyInvitationsPagedResponse {
/// The family whose archived invitations were queried, echoed back so
/// paginated callers can correlate.
@@ -327,6 +485,7 @@ pub struct PastFamilyInvitationsPagedResponse {
/// Cursor to pass as `start_after` on the next call, or `None` if this
/// page is empty (treat as end-of-list).
#[cfg_attr(feature = "generate-ts", ts(type = "[number, number] | null"))]
pub start_next_after: Option<PastFamilyInvitationCursor>,
}
@@ -371,6 +530,14 @@ pub type PastFamilyMemberForNodeCursor = (NodeFamilyId, u64);
/// Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged).
#[cw_serde]
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
#[cfg_attr(
feature = "generate-ts",
ts(
export,
export_to = "ts-packages/types/src/types/rust/PastFamilyMembersPagedResponse.ts"
)
)]
pub struct PastFamilyMembersPagedResponse {
/// The family whose archived memberships were queried, echoed back so
/// paginated callers can correlate.
@@ -382,6 +549,7 @@ pub struct PastFamilyMembersPagedResponse {
/// Cursor to pass as `start_after` on the next call, or `None` if this
/// page is empty (treat as end-of-list).
#[cfg_attr(feature = "generate-ts", ts(type = "[number, number] | null"))]
pub start_next_after: Option<PastFamilyMemberCursor>,
}
+149
View File
@@ -0,0 +1,149 @@
---
description: Implement tasks from an OpenSpec change (Experimental)
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+154
View File
@@ -0,0 +1,154 @@
---
description: Archive a completed change in the experimental workflow
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+170
View File
@@ -0,0 +1,170 @@
---
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+103
View File
@@ -0,0 +1,103 @@
---
description: Propose a new change - create it and generate all artifacts in one step
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+10 -4
View File
@@ -1,14 +1,20 @@
{
"extends": [
"@nymproject/eslint-config-react-typescript"
"@nymproject/eslint-config-react-typescript",
"plugin:storybook/recommended"
],
"parserOptions": {
"project": "./tsconfig.eslint.json"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"env": { "jest": true }
"files": [
"**/*.test.ts",
"**/*.test.tsx"
],
"env": {
"jest": true
}
}
],
"rules": {
@@ -19,4 +25,4 @@
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/quotes": "off"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
test-results/
.tsbuild/
e2e-report/
playwright-report/
+27
View File
@@ -0,0 +1,27 @@
import path from 'path';
import type { StorybookConfig } from '@storybook/react-webpack5';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-webpack5-compiler-swc',
'@storybook/addon-a11y',
'@storybook/addon-docs',
'@storybook/addon-mcp',
],
framework: '@storybook/react-webpack5',
// The app resolves bare `src/...` imports and the `@assets` alias via the shared
// `@nymproject/webpack` base config, which Storybook does not inherit — replicate it here.
webpackFinal: async (cfg) => {
// `main.ts` loads as an ESM module (no __dirname); Storybook runs from the wallet dir.
const walletRoot = process.cwd();
cfg.resolve = cfg.resolve ?? {};
cfg.resolve.modules = [walletRoot, 'node_modules', ...(cfg.resolve.modules ?? [])];
cfg.resolve.alias = {
...(cfg.resolve.alias ?? {}),
'@assets': path.resolve(walletRoot, '../assets'),
};
return cfg;
},
};
export default config;
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import type { Preview } from '@storybook/react-webpack5';
import { CacheProvider } from '@emotion/react';
import { CssBaseline } from '@mui/material';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { SnackbarProvider } from 'notistack';
import { getDesignTokens } from '../src/theme/theme';
import { muiEmotionCache } from '../src/theme/emotionCache';
// Build the theme directly from design tokens + emotion cache. We deliberately
// avoid `NymWalletTheme`, which imports `@assets/...fonts.css` (a tsconfig path
// alias Storybook's webpack can't resolve). Fonts fall back to system defaults
// in Storybook, which is fine for component/flow rendering.
const storybookTheme = createTheme(getDesignTokens('dark'));
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
decorators: [
(Story) => (
<CacheProvider value={muiEmotionCache}>
<ThemeProvider theme={storybookTheme}>
<CssBaseline />
<SnackbarProvider anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
<Story />
</SnackbarProvider>
</ThemeProvider>
</CacheProvider>
),
],
};
export default preview;
+35 -34
View File
@@ -25,6 +25,7 @@ dependencies = [
"nym-contracts-common",
"nym-crypto",
"nym-mixnet-contract-common",
"nym-node-families-contract-common",
"nym-node-requests",
"nym-store-cipher",
"nym-types",
@@ -4927,7 +4928,7 @@ dependencies = [
[[package]]
name = "nym-api-requests"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"bs58",
"celes",
@@ -4967,7 +4968,7 @@ dependencies = [
[[package]]
name = "nym-bin-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"const-str",
"log",
@@ -4998,7 +4999,7 @@ dependencies = [
[[package]]
name = "nym-coconut-dkg-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cosmwasm-schema",
"cosmwasm-std",
@@ -5011,7 +5012,7 @@ dependencies = [
[[package]]
name = "nym-compact-ecash"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"bincode",
"bs58",
@@ -5033,7 +5034,7 @@ dependencies = [
[[package]]
name = "nym-config"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"dirs 6.0.0",
"handlebars",
@@ -5047,7 +5048,7 @@ dependencies = [
[[package]]
name = "nym-contracts-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"bs58",
"cosmwasm-schema",
@@ -5061,7 +5062,7 @@ dependencies = [
[[package]]
name = "nym-credentials-interface"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"nym-bls12_381-fork",
"nym-compact-ecash",
@@ -5079,7 +5080,7 @@ dependencies = [
[[package]]
name = "nym-crypto"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -5101,7 +5102,7 @@ dependencies = [
[[package]]
name = "nym-ecash-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"bs58",
"cosmwasm-schema",
@@ -5114,7 +5115,7 @@ dependencies = [
[[package]]
name = "nym-ecash-signer-check-types"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"nym-coconut-dkg-common",
"nym-crypto",
@@ -5129,14 +5130,14 @@ dependencies = [
[[package]]
name = "nym-ecash-time"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"time",
]
[[package]]
name = "nym-exit-policy"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"serde",
"serde_json",
@@ -5147,7 +5148,7 @@ dependencies = [
[[package]]
name = "nym-group-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cosmwasm-schema",
"cw-controllers",
@@ -5158,7 +5159,7 @@ dependencies = [
[[package]]
name = "nym-http-api-client"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"async-trait",
"bincode",
@@ -5190,7 +5191,7 @@ dependencies = [
[[package]]
name = "nym-http-api-client-macro"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"proc-macro-crate 3.3.0",
"proc-macro2",
@@ -5201,7 +5202,7 @@ dependencies = [
[[package]]
name = "nym-http-api-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"bincode",
"serde",
@@ -5211,7 +5212,7 @@ dependencies = [
[[package]]
name = "nym-kkt-ciphersuite"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"num_enum",
"semver",
@@ -5222,7 +5223,7 @@ dependencies = [
[[package]]
name = "nym-mixnet-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"bs58",
"cosmwasm-schema",
@@ -5243,7 +5244,7 @@ dependencies = [
[[package]]
name = "nym-multisig-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cosmwasm-schema",
"cosmwasm-std",
@@ -5258,7 +5259,7 @@ dependencies = [
[[package]]
name = "nym-network-defaults"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cargo_metadata 0.19.2",
"dotenvy",
@@ -5273,7 +5274,7 @@ dependencies = [
[[package]]
name = "nym-network-monitors-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cosmwasm-schema",
"cosmwasm-std",
@@ -5285,7 +5286,7 @@ dependencies = [
[[package]]
name = "nym-node-families-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cosmwasm-schema",
"cosmwasm-std",
@@ -5300,7 +5301,7 @@ dependencies = [
[[package]]
name = "nym-node-requests"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"async-trait",
"celes",
@@ -5327,7 +5328,7 @@ dependencies = [
[[package]]
name = "nym-noise-keys"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"nym-crypto",
"schemars",
@@ -5337,7 +5338,7 @@ dependencies = [
[[package]]
name = "nym-pemstore"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"pem",
"tracing",
@@ -5346,7 +5347,7 @@ dependencies = [
[[package]]
name = "nym-performance-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cosmwasm-schema",
"cosmwasm-std",
@@ -5359,7 +5360,7 @@ dependencies = [
[[package]]
name = "nym-serde-helpers"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -5370,7 +5371,7 @@ dependencies = [
[[package]]
name = "nym-store-cipher"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"aes-gcm",
"argon2",
@@ -5385,7 +5386,7 @@ dependencies = [
[[package]]
name = "nym-ticketbooks-merkle"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"nym-credentials-interface",
"nym-serde-helpers",
@@ -5399,7 +5400,7 @@ dependencies = [
[[package]]
name = "nym-types"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"base64 0.22.1",
"cosmrs",
@@ -5429,7 +5430,7 @@ dependencies = [
[[package]]
name = "nym-upgrade-mode-check"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"jwt-simple",
"nym-crypto",
@@ -5445,7 +5446,7 @@ dependencies = [
[[package]]
name = "nym-validator-client"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5496,7 +5497,7 @@ dependencies = [
[[package]]
name = "nym-vesting-contract-common"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"cosmwasm-schema",
"cosmwasm-std",
@@ -5542,7 +5543,7 @@ dependencies = [
[[package]]
name = "nym-wireguard-types"
version = "1.21.0"
version = "1.21.1"
dependencies = [
"base64 0.22.1",
"nym-crypto",
+3 -3
View File
@@ -68,11 +68,11 @@ It is intended to be used during development and for troubleshooting.
You can compile the wallet in development mode by running the following command inside the `nym-wallet` directory:
```
yarn dev
pnpm run dev
```
This will produce a binary in - `nym-wallet/target/debug/` named `nym-wallet`
This will produce a binary in - `nym-wallet/target/debug/` named `NymWallet`
To launch the wallet, navigate to the directory and run the following command: `./nym-wallet`
To launch the wallet, navigate to the directory and run the following command: `./NymWallet`
## Production mode
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Node Families e2e — visual flow report</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; background: #0a0a0a; color: #eee; }
h1 { font-size: 20px; }
section { margin: 32px 0; }
h2 { font-size: 16px; color: #5bf0a0; text-transform: capitalize; border-bottom: 1px solid #333; padding-bottom: 6px; }
.strip { display: flex; gap: 16px; overflow-x: auto; padding: 12px 0; }
figure { margin: 0; flex: 0 0 auto; width: 320px; }
img { width: 320px; border: 1px solid #333; border-radius: 6px; background: #1a1a1c; cursor: zoom-in; }
img:target, img:active { transform: scale(2.4); transform-origin: top left; position: relative; z-index: 10; }
figcaption { font-size: 12px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
</style>
</head>
<body>
<h1>Node Families e2e — visual flow report</h1>
<p style="color:#888">Per-step screenshots from the mock-wired Playwright journeys (design D1/D2). Hover/scroll each filmstrip; click an image to zoom.</p>
<section><h2>operator lifecycle accept leave then reject</h2><div class="strip"><figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/01-pending-node-invites.png" alt="pending node invites"><figcaption>pending node invites</figcaption></figure>
<figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/02-invite-accepted.png" alt="invite accepted"><figcaption>invite accepted</figcaption></figure>
<figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/03-family-left.png" alt="family left"><figcaption>family left</figcaption></figure>
<figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/04-invite-rejected.png" alt="invite rejected"><figcaption>invite rejected</figcaption></figure></div></section>
<section><h2>operator page shows multi node invite states</h2><div class="strip"><figure><img loading="lazy" src="screenshots/operator-page-shows-multi-node-invite-states/01-multi-node-invite-states.png" alt="multi node invite states"><figcaption>multi node invite states</figcaption></figure></div></section>
<section><h2>owner lifecycle create invite accept kick disband</h2><div class="strip"><figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/01-create-family-entry.png" alt="create family entry"><figcaption>create family entry</figcaption></figure>
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/02-family-created.png" alt="family created"><figcaption>family created</figcaption></figure>
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/03-node-invited-pending.png" alt="node invited pending"><figcaption>node invited pending</figcaption></figure>
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/04-member-joined.png" alt="member joined"><figcaption>member joined</figcaption></figure>
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/05-member-kicked.png" alt="member kicked"><figcaption>member kicked</figcaption></figure>
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/06-family-disbanded.png" alt="family disbanded"><figcaption>family disbanded</figcaption></figure></div></section>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+67
View File
@@ -0,0 +1,67 @@
import { FAMILY_IDS, FAMILY_NODES, TID } from '../e2e/shared/families';
/**
* Native-webview replay of the owner + operator journeys (design D4), sharing selectors +
* fixtures with the primary Playwright suite via `e2e/shared/families` (parity requirement).
*
* The mock binary boots into `main.mock.html` (owner persona) — so the owner journey runs on
* launch with no navigation. The operator journey navigates the webview to the operator persona
* first. On Linux/WebKitGTK the Tauri asset scheme is `tauri://localhost/` (the mock config sets
* `useHttpsScheme: false`); adjust `appUrl` if a platform serves a different scheme.
*/
const appUrl = (persona: 'owner' | 'operator' | 'operator-seeded') =>
`tauri://localhost/main.mock.html?persona=${persona}`;
const byId = (id: string) => $(`[data-testid="${id}"]`);
const inSection = (node: number, id: string) => byId(TID.operatorNodeSection(node)).$(`[data-testid="${id}"]`);
const { ownerFlow, operatorAccept, operatorReject } = FAMILY_NODES;
describe('Families flows — native webview', () => {
it('owner lifecycle: create → invite → accept → kick → disband', async () => {
const fid = FAMILY_IDS.ownerFlow;
await byId(TID.createFamilyName).waitForDisplayed({ timeout: 30_000 });
await byId(TID.createFamilyName).setValue('Flow Family');
await byId(TID.createFamilyDescription).setValue('A family created in a flow test.');
await byId(TID.createFamilySubmit).click();
await byId(TID.ownerManagementPage).waitForDisplayed();
await byId(TID.inviteNodeId).setValue(String(ownerFlow));
await byId(TID.inviteNodeSubmit).click();
await byId(TID.inviteNodeConfirm).click();
await byId(TID.pendingInvite(ownerFlow)).waitForDisplayed();
await byId(TID.tabOperator).click();
await inSection(ownerFlow, TID.acceptCard(fid)).click();
await byId(TID.acceptConfirm(fid)).click();
await byId(TID.tabOwner).click();
await byId(TID.memberJoined(ownerFlow)).waitForDisplayed();
await byId(TID.memberJoinedKick(ownerFlow)).click();
await byId(TID.memberJoinedKickConfirm(ownerFlow)).click();
await byId(TID.memberJoined(ownerFlow)).waitForExist({ reverse: true });
await byId(TID.deleteButton).click();
await byId(TID.deleteConfirm).click();
await byId(TID.createFamilyName).waitForDisplayed();
});
it('operator lifecycle: accept → leave, then reject', async () => {
const fid = FAMILY_IDS.operatorFlow;
await browser.url(appUrl('operator'));
await byId(TID.tabOperator).click();
await inSection(operatorAccept, TID.acceptCard(fid)).click();
await byId(TID.acceptConfirm(fid)).click();
await inSection(operatorAccept, TID.leaveButton).waitForDisplayed();
await inSection(operatorAccept, TID.leaveButton).click();
await byId(TID.leaveConfirm).click();
await inSection(operatorReject, TID.rejectCard(fid)).click();
await byId(TID.rejectConfirm(fid)).click();
await byId(TID.inviteGroupEmpty(operatorReject)).waitForDisplayed();
});
});
+26
View File
@@ -0,0 +1,26 @@
// Skip-not-fail launcher for the optional native-webview leg (design D5).
// tauri-driver only works on Linux/Windows; on macOS (no WKWebView driver) or when the
// required drivers are missing, this exits 0 with a clear message instead of failing.
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
const skip = (why) => {
console.log(`[e2e:tauri] skipped — ${why}`);
process.exit(0);
};
const has = (bin) => {
try {
execSync(`command -v ${bin}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
if (platform() === 'darwin') skip('macOS has no WKWebView driver (tauri-driver unsupported) — use the Playwright suite locally.');
if (platform() === 'win32' ? !has('msedgedriver') : !(has('WebKitWebDriver') || has('webkit2gtk-driver')))
skip('platform webdriver not found (install webkit2gtk-driver on Linux / msedgedriver on Windows).');
if (!has('tauri-driver')) skip('tauri-driver not found — run `cargo install tauri-driver --locked`.');
execSync('wdio run ./wdio.conf.ts', { stdio: 'inherit' });
+150
View File
@@ -0,0 +1,150 @@
# Node Families e2e
Three tiers validate the Family page journeys. They share selectors + fixtures via
[`e2e/shared/families.ts`](./shared/families.ts) so they cannot drift apart.
## Mock-wired build (`WALLET_MOCK_FAMILIES`)
The real wallet bootstrap (`AppProvider`) is Tauri-coupled and login-gated, so it can't run
in a plain browser. A dedicated, flag-gated mock entry solves this (design D2):
- `WALLET_MOCK_FAMILIES=on` adds a `mainMock` webpack entry + `main.mock.html` that mounts
the real router + `ApplicationLayout` + Family page, but with the Storybook mocks
(`MockMainContextProvider` + `MockFamiliesContextProvider`) — no Tauri, no login.
- Off (default, and always in production) the mock entry/HTML are never built.
- Persona is chosen at runtime: `main.mock.html?persona=owner|operator|operator-seeded`.
Run it: `pnpm webpack:dev:mock` → http://localhost:9000/main.mock.html?persona=owner#/family
## Tier 1 — Playwright vs dev server (primary, cross-platform)
The main suite. Runs everywhere incl. macOS; this is what gates CI.
```
pnpm --dir .. run build # once: build workspace packages (@nymproject/types, etc.)
npx playwright install chromium # once
pnpm test:e2e # launches webpack:dev:mock + replays the journeys
```
Config: [`playwright.config.ts`](../playwright.config.ts). Specs: [`families.spec.ts`](./families.spec.ts).
**Visual flow report:** each journey step captures a captioned screenshot (`shot()` in
[`shared/report.ts`](./shared/report.ts)), stitched into a static `e2e-report/index.html`
filmstrip (via global setup/teardown) for smoke inspection. The `build` CI job stages + uploads
`e2e-report/` alongside Storybook (`if: always()`, so a failing run still publishes it).
## Tier 2 — WebdriverIO + tauri-driver (optional, Linux CI only)
Drives the **packaged Tauri binary** in the native WebKitGTK webview. macOS is unsupported
(no WKWebView driver), so `pnpm test:e2e:tauri` **skips** there (design D5). Runs in the
non-blocking `e2e-tauri` CI job under `xvfb`.
- Config: [`../wdio.conf.ts`](../wdio.conf.ts) · Spec: [`../e2e-tauri/families.tauri.ts`](../e2e-tauri/families.tauri.ts)
- Prereqs (Linux): `webkit2gtk-driver`, `cargo install tauri-driver --locked`.
- Binary wiring: `pnpm tauri:build:mock` builds the frontend with `WALLET_MOCK_FAMILIES=on`
and a Tauri binary whose window boots `main.mock.html` (owner persona) via
[`../src-tauri/tauri.mock.conf.json`](../src-tauri/tauri.mock.conf.json); the spec navigates to
other personas in-webview. `wdio.conf.ts` points at `src-tauri/target/release/NymWallet`.
- The run itself is Linux-CI-only (`tauri-driver`; macOS skips via `e2e-tauri/run.mjs`).
Note: `tauri:build:mock` runs `webpack:prod`, which was previously broken repo-wide — the
shared `ForkTsCheckerWebpackPlugin` (`write-references` emit mode) + `allowJs` emitted `.js`
into `src` (polluting it / breaking Jest) and type-checked test files. Fixed in the wallet via
`outDir: ./.tsbuild` (redirects the emit), `declare module '*.css'` (`src/typings/css.d.ts`),
and excluding `**/*.test.*` from the type-check program.
## Tier 3 — Sandbox real-IPC read smoke (optional, manual)
The node-families contract is deployed to **sandbox** (currently one family, one member).
This smoke validates the *real* `FamiliesContextProvider` + `requests/families.ts` wiring
that the mock stands in for — separate from, and non-blocking relative to, the mock tiers.
Manual procedure (design D9):
1. Launch the real wallet (`pnpm dev`) and sign into a **sandbox** account (network `SANDBOX`).
2. Open the Family page and confirm it renders the known sandbox family/member via real IPC.
3. **Read-only** — do not create/invite/kick/disband against the shared sandbox.
4. Assert render/shape (or pin the known family id), not exact contents, so a contract
redeploy doesn't hard-fail.
Promote to a non-blocking CI job only once a sandbox test account can be provisioned
headlessly (mnemonic in CI secrets) — a follow-up, not a blocker.
### Headless equivalent (`sandbox_families_smoke` example)
GUI automation of the real wallet can't run on macOS (tauri-driver is Linux/Windows-only;
Playwright can't drive the native webview), so the read smoke above also exists as a
headless Rust harness that exercises the exact `validator-client` calls the Tauri commands
in `src-tauri/src/operations/families/` wrap. It reads the funded sandbox account mnemonic
from `.env` (`TAURI-WALLET-MNEMONIC`, gitignored — never printed or committed). Run from
the `nym-wallet/` directory:
```sh
# read-only smoke (no state change) — lists the live families/members/config via real IPC
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke
# owner subset (create → rename → disband, with cleanup) on the funded account
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --write
# full member/operator journey against FAMILY_OWNER's existing family, using
# ACCOUNT_WITH_BONDED_NODE as the node operator (kick/invite/reject/revoke/accept/leave),
# restoring the node's membership at the end
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --member
# read-only diagnostics
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --accounts
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --bond-check <n1-address>
```
`--write` uses `TAURI-WALLET-MNEMONIC` (refuses to start if that account already owns a
family; disbands its throwaway family at the end). `--member` uses `FAMILY_OWNER_MNEMONIC`
(owner) + `ACCOUNT_WITH_BONDED_NODE_MNEMONIC` (operator) to drive all 6 member commands
against the owner's existing family and restore the node's original membership, so a real
family is left unchanged. All mnemonics are read from `.env` (gitignored) and never printed.
Together the two runs cover all 9 execute commands end-to-end on sandbox.
## Real IPC layer (what the mock stands in for)
The 18 commands `requests/families.ts` invokes are implemented in
[`../src-tauri/src/operations/families/`](../src-tauri/src/operations/families/) and registered
in [`../src-tauri/src/main.rs`](../src-tauri/src/main.rs):
- `execute.rs` — 9 state-changing txs over `NodeFamiliesSigningClient` (auto/simulated gas;
`create_family` attaches the `create_family_fee` as base-denom funds). Returns
`TransactionExecuteResult` (the optional `family_events` is omitted — the provider
`refreshAll()`s queries after every execute, so the UI re-derives state from reads).
- `query.rs` — 9 reads over `NodeFamiliesQueryClient`, **normalised at the IPC boundary** to the
wallet shapes in `src/types/families.ts`: base-denom `Coin` → display `DecCoin`
(`paid_fee`, `create_family_fee`), per-page contract envelopes → `{ items, start_next_after }`,
the cw_serde tagged `FamilyInvitationStatus``{ kind, at }`. `get_family_config` has no smart
query, so it reads raw contract state at the `"config"` key.
**Tauri arg casing:** JS passes camelCase keys, Tauri maps them to the commands' snake_case
params — so the `requests/families.ts` execute bindings send `nodeId`/`familyId`/`validitySecs`/
`updatedName`/`updatedDescription`.
**Mock → real switch.** `FamiliesContext.defaultQueries` points at the real `requests/families.ts`;
`MockFamiliesContextProvider` swaps in an in-memory engine. Production `/family`
([`../src/pages/families/FamilyPageRoute.tsx`](../src/pages/families/FamilyPageRoute.tsx)) mounts
`BondingContextProvider` → real `FamiliesContextProvider``FamilyPage`; the mock entry
(`main.mock.html`) is offline/e2e-only. `controlledNodeIds` derives from the account's bonded node
(≤1 on chain).
**Contract drift guard.** [`../src/types/families.contract-guard.ts`](../src/types/families.contract-guard.ts)
holds `tsc`-checked assertions between `src/types/families.ts` and the ts-rs-generated contract
types; regenerate (`tools/ts-rs-cli`) on a contract change and the guard flags any divergence.
## Sandbox write flows (guarded, manual)
Mutating journeys (create → invite → accept → kick → disband; accept/leave/reject) need a
**dedicated funded sandbox account** — never a shared/real one:
- Account `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv` (sandbox, ~101k NYM). Mnemonic lives **only**
in vault secret `TAURI-WALLET-MNEMONIC` — inject via CI secret, never commit it.
- Check state with `nym-cli -c sandbox.env account balance n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv`.
- Tolerate on-chain latency (poll/refresh after each execute). Target **only** this account's
family/nodes and **clean up at the end** (disband / leave) so it stays reusable.
- An account bonds ≤1 node, so the multi-node operator persona can't be reproduced here — that
journey stays mock/Storybook-only.
Keep this tier manual/non-blocking until headless account provisioning exists.
+99
View File
@@ -0,0 +1,99 @@
import { test, expect, Page } from '@playwright/test';
import { FAMILY_IDS, FAMILY_NODES, familyMockUrl, TID } from './shared/families';
import { shot } from './shared/report';
/**
* Primary e2e of the owner + operator Node Families journeys (design D1), driven against
* the mock-wired app shell (`main.mock.html`, design D2) on the dev server. Selectors are
* shared with the optional WebdriverIO leg via `./shared/families` (parity requirement)
* and target the ids that actually render (see the note in that file).
*
* Each step also captures a screenshot via `shot()` → assembled into a static visual flow
* report (`e2e-report/index.html`) for smoke inspection, uploaded by the CI build job.
*
* Confirmation dialogs portal to the document body; in a single browser DOM they're reached
* at page scope. Per-node invite content is scoped via the `operator-node-<n>` wrapper, and
* invite buttons are keyed by family id.
*/
const { ownerFlow, operatorAccept, operatorReject, operatorNone } = FAMILY_NODES;
const openOperatorTab = (page: Page) => page.getByTestId(TID.tabOperator).click();
test.describe('Families flows (mock-wired app shell)', () => {
test('owner lifecycle: create → invite → accept → kick → disband', async ({ page }, testInfo) => {
const fid = FAMILY_IDS.ownerFlow;
await page.goto(familyMockUrl('owner'));
// create
await expect(page.getByTestId(TID.createFamilyName)).toBeVisible({ timeout: 30_000 });
await shot(page, testInfo, 'create family entry');
await page.getByTestId(TID.createFamilyName).fill('Flow Family');
await page.getByTestId(TID.createFamilyDescription).fill('A family created in a flow test.');
await page.getByTestId(TID.createFamilySubmit).click();
await expect(page.getByTestId(TID.ownerManagementPage)).toBeVisible();
await shot(page, testInfo, 'family created');
// invite the self-controlled node
await page.getByTestId(TID.inviteNodeId).fill(String(ownerFlow));
await page.getByTestId(TID.inviteNodeSubmit).click();
await page.getByTestId(TID.inviteNodeConfirm).click();
await expect(page.getByTestId(TID.pendingInvite(ownerFlow))).toBeVisible();
await shot(page, testInfo, 'node invited (pending)');
// accept it from the operator tab (same account controls the node)
await openOperatorTab(page);
await page.getByTestId(TID.operatorNodeSection(ownerFlow)).getByTestId(TID.acceptCard(fid)).click();
await page.getByTestId(TID.acceptConfirm(fid)).click();
// kick it from the owner tab — the joined member appears, then is removed
await page.getByTestId(TID.tabOwner).click();
await expect(page.getByTestId(TID.memberJoined(ownerFlow))).toBeVisible();
await shot(page, testInfo, 'member joined');
await page.getByTestId(TID.memberJoinedKick(ownerFlow)).click();
await page.getByTestId(TID.memberJoinedKickConfirm(ownerFlow)).click();
await expect(page.getByTestId(TID.memberJoined(ownerFlow))).toHaveCount(0);
await shot(page, testInfo, 'member kicked');
// disband the now-empty family
await page.getByTestId(TID.deleteButton).click();
await page.getByTestId(TID.deleteConfirm).click();
await expect(page.getByTestId(TID.createFamilyName)).toBeVisible();
await shot(page, testInfo, 'family disbanded');
});
test('operator lifecycle: accept → leave, then reject', async ({ page }, testInfo) => {
const fid = FAMILY_IDS.operatorFlow;
await page.goto(familyMockUrl('operator'));
await openOperatorTab(page);
await shot(page, testInfo, 'pending node invites');
// accept the invite on the accept-node (scope by node; invite buttons are keyed by family id)
const acceptSection = page.getByTestId(TID.operatorNodeSection(operatorAccept));
await acceptSection.getByTestId(TID.acceptCard(fid)).click();
await page.getByTestId(TID.acceptConfirm(fid)).click();
// joined → a Leave action appears for that node
await expect(acceptSection.getByTestId(TID.leaveButton)).toBeVisible();
await shot(page, testInfo, 'invite accepted');
// leave the family
await acceptSection.getByTestId(TID.leaveButton).click();
await page.getByTestId(TID.leaveConfirm).click();
await shot(page, testInfo, 'family left');
// reject the invite on the reject-node → its group ends empty
await page.getByTestId(TID.operatorNodeSection(operatorReject)).getByTestId(TID.rejectCard(fid)).click();
await page.getByTestId(TID.rejectConfirm(fid)).click();
await expect(page.getByTestId(TID.inviteGroupEmpty(operatorReject))).toBeVisible();
await shot(page, testInfo, 'invite rejected');
});
test('operator page shows multi-node invite states', async ({ page }, testInfo) => {
await page.goto(familyMockUrl('operator-seeded'));
await openOperatorTab(page);
// node with an active invite renders its section; node with none shows the empty state
await expect(page.getByTestId(TID.operatorNodeSection(operatorAccept))).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(TID.inviteGroupEmpty(operatorNone))).toBeVisible();
await shot(page, testInfo, 'multi-node invite states');
});
});
+6
View File
@@ -0,0 +1,6 @@
import { resetReport } from './shared/report';
// Clears the previous visual report before a run so the uploaded artifact is current.
export default async function globalSetup(): Promise<void> {
resetReport();
}
+6
View File
@@ -0,0 +1,6 @@
import { buildGallery } from './shared/report';
// Stitches the per-step screenshots into e2e-report/index.html after the run.
export default async function globalTeardown(): Promise<void> {
buildGallery();
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Shared journey constants for the Node Families e2e suites (parity requirement,
* design D3). Both the primary Playwright suite (`e2e/families.spec.ts`) and the
* optional WebdriverIO native leg (`e2e-tauri/families.tauri.ts`) import these.
*
* These are the test ids that render reliably in the DOM. We scope by the `operator-node-<n>`
* Stack wrapper / table rows and key invite buttons by family id (the ids emitted by
* `ConfirmActionButton`, e.g. `invite-card-<familyId>-accept`). Historically `NymCard` dropped
* `data-testid` (its prop was `dataTestid`), so `node-invite-group-<n>` / `invite-card-<fid>`
* didn't render and the original Storybook play-function selectors were invalid; that was fixed
* in the `fix-nymcard-data-testid` change. These selectors remain valid and are kept as-is.
*/
export type FamilyPersona = 'owner' | 'operator' | 'operator-seeded';
/** Fixture node ids (mirror src/context/mocks/families.fixtures.ts). */
export const FAMILY_NODES = {
ownerFlow: 301, // MOCK_OWNER_FLOW_NODE
operatorAccept: 201, // MOCK_OPERATOR_FLOW_ACCEPT_NODE / MOCK_OPERATOR_NODE_ACTIVE
operatorReject: 204, // MOCK_OPERATOR_FLOW_REJECT_NODE
operatorNone: 203, // MOCK_OPERATOR_NODE_NONE
} as const;
/**
* The family_id that issued each persona's invites — invite-card test ids are keyed by
* family_id, not node id. Owner-flow + operator-flow each have a single family (id 1);
* the seeded store's operator invites come from the second family (id 2).
*/
export const FAMILY_IDS = {
ownerFlow: 1,
operatorFlow: 1,
seeded: 2,
} as const;
/** Route into the mock-wired app shell for a given persona (see PERSONAS in src/main.mock.tsx). */
export const familyMockUrl = (persona: FamilyPersona) => `/main.mock.html?persona=${persona}#/family`;
/** Test ids that actually render (parameterised by node id or family id where noted). */
export const TID = {
// owner: create + manage
createFamilyName: 'create-family-name',
createFamilyDescription: 'create-family-description',
createFamilySubmit: 'create-family-submit',
ownerManagementPage: 'owner-management-page',
inviteNodeId: 'invite-node-id',
inviteNodeSubmit: 'invite-node-submit',
inviteNodeConfirm: 'invite-node-confirm',
deleteButton: 'delete-family-button',
deleteConfirm: 'delete-family-button-confirm',
pendingInvite: (node: number) => `pending-invite-${node}`,
memberJoined: (node: number) => `member-joined-${node}`,
memberJoinedKick: (node: number) => `member-joined-${node}-kick`,
memberJoinedKickConfirm: (node: number) => `member-joined-${node}-kick-confirm`,
// tabs
tabOwner: 'family-tab-owner',
tabOperator: 'family-tab-operator',
// operator: per-node section wrapper (Stack — renders) used for scoping
operatorNodeSection: (node: number) => `operator-node-${node}`,
inviteGroupEmpty: (node: number) => `node-invite-group-${node}-empty`,
leaveButton: 'leave-family-button',
leaveConfirm: 'leave-family-button-confirm',
// invite cards: keyed by FAMILY id (ConfirmActionButton dataTestid — renders)
acceptCard: (familyId: number) => `invite-card-${familyId}-accept`,
acceptConfirm: (familyId: number) => `invite-card-${familyId}-accept-confirm`,
rejectCard: (familyId: number) => `invite-card-${familyId}-reject`,
rejectConfirm: (familyId: number) => `invite-card-${familyId}-reject-confirm`,
} as const;
+94
View File
@@ -0,0 +1,94 @@
import fs from 'node:fs';
import path from 'node:path';
import type { Page, TestInfo } from '@playwright/test';
/**
* Visual flow report for the Node Families e2e journeys. Each `shot()` writes a numbered,
* captioned PNG into `e2e-report/screenshots/<test>/` (and attaches it to the Playwright HTML
* report). `buildGallery()` (run from globalTeardown) stitches them into a static
* `e2e-report/index.html` filmstrip for visual inspection / smoke checks — uploaded by CI.
*/
export const REPORT_DIR = path.resolve(__dirname, '..', '..', 'e2e-report');
const SCREENSHOTS_DIR = path.join(REPORT_DIR, 'screenshots');
const slug = (s: string) =>
s
.toLowerCase()
.replace(/[^\w]+/g, '-')
.replace(/^-+|-+$/g, '');
// Per-test step counters (workers run one test each, so this stays consistent per test).
const counters = new Map<string, number>();
/** Clear any previous report so the upload reflects only the latest run. */
export function resetReport(): void {
fs.rmSync(REPORT_DIR, { recursive: true, force: true });
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
counters.clear();
}
/** Capture a full-page, captioned step screenshot into the report (and the Playwright report). */
export async function shot(page: Page, testInfo: TestInfo, label: string): Promise<void> {
const testSlug = slug(testInfo.title);
const dir = path.join(SCREENSHOTS_DIR, testSlug);
fs.mkdirSync(dir, { recursive: true });
const n = (counters.get(testSlug) ?? 0) + 1;
counters.set(testSlug, n);
const file = `${String(n).padStart(2, '0')}-${slug(label)}.png`;
const body = await page.screenshot({ fullPage: true });
fs.writeFileSync(path.join(dir, file), body);
await testInfo.attach(label, { body, contentType: 'image/png' });
}
/** Stitch the captured screenshots into a static index.html filmstrip. */
export function buildGallery(): void {
if (!fs.existsSync(SCREENSHOTS_DIR)) return;
const tests = fs
.readdirSync(SCREENSHOTS_DIR)
.filter((d) => fs.statSync(path.join(SCREENSHOTS_DIR, d)).isDirectory())
.sort();
const sections = tests
.map((t) => {
const imgs = fs
.readdirSync(path.join(SCREENSHOTS_DIR, t))
.filter((f) => f.endsWith('.png'))
.sort();
const frames = imgs
.map((f) => {
const caption = f.replace(/^\d+-/, '').replace(/\.png$/, '').replace(/-/g, ' ');
return `<figure><img loading="lazy" src="screenshots/${t}/${f}" alt="${caption}"><figcaption>${caption}</figcaption></figure>`;
})
.join('\n');
return `<section><h2>${t.replace(/-/g, ' ')}</h2><div class="strip">${frames}</div></section>`;
})
.join('\n');
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Node Families e2e — visual flow report</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; background: #0a0a0a; color: #eee; }
h1 { font-size: 20px; }
section { margin: 32px 0; }
h2 { font-size: 16px; color: #5bf0a0; text-transform: capitalize; border-bottom: 1px solid #333; padding-bottom: 6px; }
.strip { display: flex; gap: 16px; overflow-x: auto; padding: 12px 0; }
figure { margin: 0; flex: 0 0 auto; width: 320px; }
img { width: 320px; border: 1px solid #333; border-radius: 6px; background: #1a1a1c; cursor: zoom-in; }
img:target, img:active { transform: scale(2.4); transform-origin: top left; position: relative; z-index: 10; }
figcaption { font-size: 12px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
</style>
</head>
<body>
<h1>Node Families e2e — visual flow report</h1>
<p style="color:#888">Per-step screenshots from the mock-wired Playwright journeys (design D1/D2). Hover/scroll each filmstrip; click an image to zoom.</p>
${sections}
</body>
</html>`;
fs.writeFileSync(path.join(REPORT_DIR, 'index.html'), html);
}
+6
View File
@@ -0,0 +1,6 @@
{
"codeConnect": {
"include": ["src/**/*.figma.tsx"],
"exclude": ["**/*.test.*", "**/*.stories.*"]
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-08
@@ -0,0 +1,43 @@
## Context
`src/components/NymCard.tsx` currently:
```tsx
<Card data-testid={hideHeader ? dataTestid : undefined} ...>
{!hideHeader && title !== undefined && (
<CardHeader data-testid={dataTestid || (typeof title === 'string' ? title : 'nym-card')} ... />
)}
...children...
</Card>
```
Problems: (1) only reads `dataTestid` (camelCase) — the 17 call sites passing `data-testid` (kebab) are ignored; (2) when a header is shown the id lands on the `CardHeader`, not the content-wrapping root, so it can't scope children; (3) the `|| title` fallback emits misleading ids like `Node 201` / `Current family`.
Discovered while building `node-families-tauri-webdriver-e2e`: the e2e had to scope around this (using `operator-node-<n>` wrappers and family-id-keyed button ids) because `node-invite-group-<n>` / `invite-card-<n>` never rendered.
## Goals / Non-Goals
**Goals:** accept `data-testid`; put the resolved id on the card root (content-wrapping, scope-able); single element only; drop title-derived ids; keep `dataTestid` working.
**Non-Goals:** changing call sites from `dataTestid` to `data-testid` en masse; any visual/behavioural change; touching `ConfirmActionButton` (its `dataTestid` already renders correctly).
## Decisions
**D1 — Resolve `data-testid ?? dataTestid` and place it on the `<Card>` root, always.**
Destructure both props (`'data-testid': dataTestidAttr`, `dataTestid`) and compute `const testId = dataTestidAttr ?? dataTestid`. Apply `data-testid={testId}` to the outer `<Card>` unconditionally (header or not), so it wraps content and scoping works. *Alternative considered:* keep it on the header when a header exists — rejected (can't scope children, and is the current broken behaviour).
**D2 — Remove the `|| title` / `'nym-card'` fallback; render no attribute when unset.**
Emit `data-testid` only when `testId` is defined. Verified no test queries by a title-derived id, so this only removes noise. Avoids duplicate ids (root + header) that would break Playwright strict locators.
**D3 — Revert the e2e scope-arounds where the intended ids now render.**
With the fix, `node-invite-group-<n>` and `invite-card-<familyId>` render on real elements. Optionally simplify `e2e/shared/families.ts` back toward the intended ids. Keep the suite green either way — this is cleanup, not required for correctness.
## Risks / Trade-offs
- **17 previously-absent `data-testid`s start appearing** → could affect DOM snapshot tests. There are none today (Jest is node-env); mitigation: run the wallet build + the Node Families Playwright suite + `tsc`/eslint after the change.
- **A consumer relied on the title-derived id** → verified none do; if one surfaces, it can pass an explicit `data-testid`.
- **Type prop name** → React/TS allows `'data-testid'` as a prop key; type it explicitly in `NymCard`'s props so call sites keep type-checking.
## Migration Plan
Single-component change; additive (new ids appear, none removed except the unused title fallback). No rollback concerns. Verify by: build wallet, run `pnpm test`, `pnpm tsc`, and the Node Families Playwright suite (`pnpm test:e2e`).
@@ -0,0 +1,27 @@
## Why
`NymCard`'s test-id prop is `dataTestid` (camelCase), but **17** call sites across the wallet pass `data-testid` (kebab-case), which `NymCard` silently ignores. Those intended test ids (`member-list`, `create-family-form`, `invite-card-<id>`, `operator-node-<id>-family`, `dissolve-family-card`, `family-summary`, `balance-usd-approx`, error/gateway cards, …) never reach the DOM. Worse, when a `data-testid` is dropped `NymCard` stamps the header with the **title text** as a test id (e.g. `Node 201`, `Current family`), producing misleading ids.
This silently breaks UI test selectors: the Node Families Storybook `play` functions and the first Playwright pass both targeted ids that don't render (discovered during the `node-families-tauri-webdriver-e2e` work, which had to scope around it). Fixing the component restores the intended, consistent test-id contract for the whole app.
## What Changes
- `NymCard` SHALL accept a standard `data-testid` prop (in addition to the existing `dataTestid`) and apply the resolved id to the card **root** element, so it wraps the card's content and is usable as a scope container.
- Remove the title-as-test-id fallback (`data-testid={dataTestid || title}`) so cards no longer emit misleading ids derived from their title; emit a test id only when one is explicitly provided.
- The resolved id MUST appear on exactly one element (no duplicate root/header ids that would break strict locators).
- Follow-up cleanup: revert the scope-around workarounds in the Node Families e2e selectors (`e2e/shared/families.ts`) back to the now-rendering intended ids where it simplifies them.
## Capabilities
### New Capabilities
- `nymcard-testid-contract`: The `NymCard` component's contract for exposing a DOM test id (accept `data-testid`, single deterministic element, no title-derived ids).
### Modified Capabilities
<!-- None — no existing capability spec covers NymCard. -->
## Impact
- **Component**: `src/components/NymCard.tsx` (the fix). ~27 `NymCard` call sites; **17** currently-dropped `data-testid`s will start rendering (families, balance, gateway, error cards). No call-site edits required.
- **Tests**: `nym-wallet` Jest is node-env (no DOM render), so unit tests are unaffected; verify the wallet builds, `tsc`/eslint stay clean, the Node Families Playwright suite stays green, and (bonus) the Storybook `play` functions now resolve their original ids.
- **Risk**: low — no test currently queries by the title-fallback id (verified); the change only adds/relocates ids. Snapshot-style DOM tests (none today) would see new `data-testid` attributes appear.
- **Out of scope**: broad re-write of call sites to switch `dataTestid``data-testid` (both remain supported); behavioural/visual changes to `NymCard`.
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: NymCard exposes a DOM test id from a standard prop
`NymCard` SHALL apply a caller-provided test id to its root DOM element. It MUST accept the standard `data-testid` prop, and SHALL continue to accept the legacy `dataTestid` prop; when both are provided, one resolved value is used. The resolved test id MUST appear on exactly one element (the card root), never duplicated across the root and the header.
#### Scenario: data-testid reaches the DOM
- **WHEN** a `NymCard` is rendered with `data-testid="member-list"`
- **THEN** the rendered card root element has `data-testid="member-list"`
- **AND** a scoped query within that element can find the card's content
#### Scenario: legacy dataTestid still works
- **WHEN** a `NymCard` is rendered with `dataTestid="foo"` and no `data-testid`
- **THEN** the rendered card root element has `data-testid="foo"`
#### Scenario: no duplicate ids
- **WHEN** a `NymCard` with a header and a provided test id is rendered
- **THEN** exactly one element carries that test id (strict locators match a single node)
### Requirement: No title-derived test ids
`NymCard` SHALL NOT derive a test id from its `title` (or emit a `nym-card` fallback). When no test id is provided, the card SHALL render without a `data-testid` attribute.
#### Scenario: untagged card emits no test id
- **WHEN** a `NymCard` is rendered with a `title` but no `data-testid`/`dataTestid`
- **THEN** neither the card root nor its header carries a `data-testid` derived from the title
@@ -0,0 +1,17 @@
## 1. Fix NymCard
- [x] 1.1 In `src/components/NymCard.tsx`, accept a `'data-testid'?: string` prop alongside `dataTestid`; resolve `dataTestidAttr ?? dataTestid`.
- [x] 1.2 Apply `data-testid` to the root `<Card>` element unconditionally (header or not); rendered only when defined.
- [x] 1.3 Remove the `dataTestid || title`/`'nym-card'` fallback on `CardHeader` so no title-derived id is emitted and the id is not duplicated.
## 2. Verify
- [x] 2.1 `pnpm tsc` clean ("No errors found"); eslint clean on `NymCard.tsx`; mock dev build compiles.
- [x] 2.2 `pnpm test` (Jest) green — **85/85** (after removing 328 stray compiled `.js` artifacts that were shadowing the `.ts` sources and breaking Jest; unrelated to this fix).
- [x] 2.3 Node Families Playwright suite green (3/3). Spot-checked in the mock app: `node-invite-group-201` and `invite-card-2` now render (were dropped before), and the misleading title ids (`Node 201`, `Current family`) are gone.
- [ ] 2.4 (Bonus) Confirm the Storybook Node Families `play` functions resolve their original ids — not run (would need a Storybook run); the ids they target now render in the DOM.
## 3. Cleanup (optional)
- [ ] 3.1 Simplify `e2e/shared/families.ts`**skipped intentionally**: the current selectors work and remain correct (invite buttons are family-id keyed via `ConfirmActionButton` regardless of this fix; `operator-node-<n>` is a valid scope). No change needed.
- [x] 3.2 Updated the `NymCard` caveat note in `e2e/README.md` to record that the prop is fixed.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-09
@@ -0,0 +1,57 @@
## Context
Three layers already exist; only the middle one is missing:
- **Frontend** — `src/requests/families.ts` defines all 18 IPC bindings (9 execute + 9 query) with command names + arg shapes; the real `FamiliesContextProvider` already calls them (and `refreshAll()`s queries after each execute). The only stub is `controlledNodeIds = []`.
- **On-chain client** — `validator-client` exposes `NodeFamiliesSigningClient` (create/update/disband/invite/revoke/accept/reject/leave/kick) and `NodeFamiliesQueryClient` (by-id/by-owner/membership/members-paged/pending/past/…). The `node-families-contract` is **deployed to sandbox** (one family, one member).
- **Wallet Tauri layer (MISSING)** — `src-tauri/src/operations/` has `mixnet/`, `vesting/`, etc. but **no `families/`**, so the 18 invoked commands have no handler. This is the entire gap.
## Goals / Non-Goals
**Goals:** implement the 18 Tauri commands over the existing validator-client traits; return frontend-typed results; switch the running app to real data (drop the `controlledNodeIds` stub); verify journeys against sandbox (read smoke + guarded writes).
**Non-Goals:** contract changes; mainnet; UI/`data-testid` changes; replacing the mock entry (it stays for offline e2e).
## Decisions
**D1 — Mirror `operations/mixnet/` for the families command module.**
Add `src-tauri/src/operations/families/{mod,query,execute}.rs`. Each `#[tauri::command]` acquires the account's client from the wallet `State` (as mixnet ops do), calls the corresponding `NodeFamilies{Signing,Query}Client` method, and returns the mapped type. Register all 18 in `main.rs` `invoke_handler`. *Alternative:* one mega-file — rejected for clarity; split execute vs query.
**D2 — `family_events`: rely on post-execute query refresh, parse events best-effort.**
The mock fabricates `FamilyTxResult.family_events`; on chain they'd come from parsing the tx's wasm events. The provider already `refreshAll()`s all family queries after every execute, so the UI re-derives state from queries, not from `family_events`. Decision: return the real `TransactionExecuteResult` fields and populate `family_events` best-effort (parse wasm events if cheap; otherwise empty) — the UI does not depend on it for correctness. Revisit if a view reads `family_events` directly. *Alternative:* full event parsing up front — deferred as unnecessary for the journeys.
**D3 — Derive `controlledNodeIds` from the account's bonded nodes.**
Reuse the existing bonding/account node info the wallet already fetches (the operator persona needs "nodes I control"). Replace the `useMemo(() => [], [])` stub in `FamiliesContextProvider` with that derivation. Keep it resilient when the account controls no nodes.
*Implemented:* consume `useBondingContext().bondedNode` (`[nodeId]` nym-node / `[mixId]` legacy mixnode / `[]` gateway-or-none); the `/family` route is now wrapped in `BondingContextProvider` (it is mounted per-route, not globally). **Reality check:** an account bonds **at most one** node on chain, so `controlledNodeIds` is 01 long. The mock's 3-node operator persona is therefore **not reproducible** from a single sandbox account — the multi-node operator journey (§5.3) remains a mock/Storybook-only scenario, and the sandbox operator check is limited to that one node's invites.
**D4 — Generate contract types via ts-rs (`nym-wallet-types`) and reconcile with `src/types/families.ts`.**
`src/types/families.ts` was hand-written for the mock. Prefer generating the canonical shapes from the contract Rust types (ts-rs, as other wallet types are) and reconciling field-by-field (cursors, paged envelope, membership, `FamilyTxResult`). Where the hand-written type and generated type diverge, the generated (contract-truth) shape wins; update the mock/types accordingly so mock and real stay parity.
**D5 — `get_family_config`: no smart query exists → read raw contract state.**
*Confirmed from the contract:* `QueryMsg` has **no** `GetConfig` variant (only `UpdateConfig { config }` execute + `config` in instantiate), and the validator-client query trait has no config getter. So `get_family_config` can't be a normal smart query. Options: (a) **raw contract-state read** of the `Config` `Item` via `query_contract_raw` at its storage key (no contract change; preferred); (b) hardcode/derive client-side (brittle); (c) add a `GetConfig` query to the contract (out of scope here). Plan: (a). The UI only uses `FamilyConfig` for cosmetic fee display, so if (a) proves awkward, `get_family_config` can degrade gracefully without blocking the journeys.
**D6 — Sandbox e2e in two stages; writes are guarded and may stay partly manual.**
(1) **Read smoke** (non-mutating): point a build at the real provider + sandbox, assert the Family page renders the known family/member — automatable and safe. (2) **Write flows**: require a *dedicated funded sandbox test account* (mnemonic via CI secret) and tolerate on-chain latency/fees; they mutate real state, so they must target only that account's family/nodes and clean up (disband/leave) at the end. Iterate until the owner/operator journeys pass. If headless account provisioning isn't available, the write tier stays a documented manual run while the read smoke gates CI. *This is the riskiest part and the one most likely to need iteration.*
**D7 — Fees: follow the wallet's existing execute convention.**
Reuse however `operations/mixnet/` supplies `Option<Fee>` (auto/simulated default) so the families execute commands behave consistently with the rest of the wallet.
## Risks / Trade-offs
- **Type drift mock↔chain** → D4 reconciliation against ts-rs-generated types; a contract-shape test guards it.
- **`family_events` shape mismatch** → D2 leans on query refresh; UI doesn't depend on the fabricated events.
- **Shared sandbox mutation / flakiness / fees** → D6 dedicated funded test account, target-only-self, cleanup; read smoke gates CI, writes non-blocking/manual until provisioning exists.
- **`UpdateFamily` contract shape** (parent §9.5) → verify `update_family` args (`updated_name`/`updated_description: Option`) against the deployed contract on rebase.
- **Sandbox availability/endpoint** → the read smoke must fail soft (skip/non-blocking) if sandbox is down, to avoid false CI reds.
## Migration Plan
Additive: the commands don't exist today, so adding them only enables the already-present provider. Roll out: (1) Rust commands + registration (compiles, app boots), (2) type reconciliation, (3) `controlledNodeIds` derivation, (4) read smoke vs sandbox, (5) guarded write flows + iterate. Rollback = the mock entry still runs the UI offline regardless.
## Open Questions
- ~~Is there a dedicated funded sandbox test account we can use headlessly?~~ **RESOLVED:** yes — a dedicated **sandbox** account is provisioned. Address `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv`, funded with ~101,000 NYM; mnemonic stored in vault as secret **`TAURI-WALLET-MNEMONIC`** (vault.nymte.ch item `95d3d842-90ad-4b6f-8b0c-10f5febce1c3`) — inject via CI secret, never commit it. Inspect with `nym-cli -c sandbox.env account balance <addr>`. Write flows (§5) target only this account; clean up (disband/leave) so it stays reusable.
- ~~Does any view read `FamilyTxResult.family_events`?~~ **RESOLVED:** no — only the type def + the mock/provider *produce* it; nothing reads it, so query-refresh is sufficient (confirms D2, no event parsing needed).
- ~~`get_family_config` mapping?~~ **RESOLVED:** the contract has no config query → read raw contract state (design D5); degrades gracefully (fee display is cosmetic).
- ~~`update_family` arg shape?~~ **RESOLVED:** contract is `UpdateFamily { updated_name, updated_description: Option<String> }` — matches the frontend args + parent §9.5.
- **Still a decision (not a blocker):** replace `src/types/families.ts` wholesale with ts-rs output vs reconcile selectively. Default: reconcile selectively, contract-truth shape wins (design D4).
@@ -0,0 +1,28 @@
## Why
The Node Families UI is complete and proven against the in-memory mock, but it has never run on real chain data: the frontend invokes 18 Tauri commands (`src/requests/families.ts`) that **don't exist on the Rust side yet**, so the real `FamiliesContextProvider` can't resolve anything. The on-chain pieces are already in place — the `node-families-contract` is deployed to **sandbox**, and `validator-client` already exposes full `NodeFamiliesSigningClient` + `NodeFamiliesQueryClient` traits. The only missing link is the wallet's Tauri command layer that bridges the two. This change adds it and switches the running app from mock to real IPC.
## What Changes
- Add a wallet Tauri command module (`src-tauri/src/operations/families/`) implementing the **18 commands** the frontend already calls (9 execute + 9 query), each delegating to the existing `validator-client` node-families traits via the wallet's signing/query client (mirroring `operations/mixnet/`). Register them in the `invoke_handler` (`main.rs`).
- Map chain results to the frontend's TS contract: execute commands return `FamilyTxResult` (incl. `family_events` parsed from the tx, which the mock currently fabricates); query commands return the existing `NodeFamily` / paged / membership shapes. Confirm `nym-wallet-types` (ts-rs) generation matches `src/types/families.ts`, or reconcile.
- Finish the **real provider**: `FamiliesContextProvider` already wires the 18 requests; remove its `controlledNodeIds` stub (currently `[]`) by deriving controlled node ids from the connected account's bonded nodes, so owner/operator personas work on real data. No change to `FamilyPage`.
- The real provider is **already** the default `/family` route; the mock entry (`main.mock.tsx`) stays for the offline Tier-1 suite. This change makes the *production* app show live family data.
- **Iterate to green against sandbox**: a real-IPC tier — first a read-only smoke (queries render the sandbox family/member), then guarded execute flows against a funded sandbox test account — run until the journeys pass. Replaces the parent change's manual task 9.4 with an automated path where feasible.
## Capabilities
### New Capabilities
- `families-real-ipc`: The wallet's Tauri command layer for the node-families contract (queries + execute via the nyxd/validator client) and the real-data provider wiring that replaces the mock in the running app.
### Modified Capabilities
<!-- None: families-app-mock-build / families-app-e2e (from node-families-tauri-webdriver-e2e) are reused, not changed in requirement. -->
## Impact
- **Rust (`nym-wallet/src-tauri`)**: new `operations/families/{mod.rs,queries.rs,execute.rs}` (or similar); `main.rs` `invoke_handler` additions; uses `nym_validator_client` node-families traits + wallet `State`/signing client; tx-event parsing for `family_events`. Possibly new error variants.
- **Types**: `nym-wallet-types` ts-rs exports for the contract types; reconcile with `src/types/families.ts` (esp. `FamilyTxResult.family_events`, cursors, paged shapes).
- **Frontend (`src`)**: `FamiliesContextProvider` `controlledNodeIds` derivation (drop the `[]` stub); no UI/`data-testid` changes.
- **e2e**: a real-IPC tier (sandbox) — read smoke + guarded write flows; needs a sandbox test account (mnemonic via secrets) and a network/account that tolerates lifecycle mutations.
- **Dependency / sequencing**: builds on `node-families-tauri-webdriver-e2e` (mock app, Tier-1/2 harness) and parent `node-families-wallet` (§9.4/9.5 — real IPC + `UpdateFamily` contract shape). The sandbox contract being live satisfies the external prerequisite.
- **Out of scope**: contract changes (the contract is deployed as-is); mainnet wiring; UI redesign.
@@ -0,0 +1,51 @@
## ADDED Requirements
### Requirement: Tauri command layer for the node-families contract
The wallet SHALL implement the Tauri commands the frontend invokes for node families — the 9 execute commands (`create_family`, `update_family`, `disband_family`, `invite_to_family`, `revoke_family_invitation`, `kick_from_family`, `accept_family_invitation`, `reject_family_invitation`, `leave_family`) and the query commands (`get_family_by_id`, `get_family_by_owner`, `get_family_membership`, `get_family_config`, `get_family_members_paged`, `get_pending_invitations_for_family_paged`, `get_pending_invitations_for_node_paged`, `get_past_invitations_for_family_paged`, `get_past_members_for_family_paged`). Each command MUST delegate to the existing `validator-client` node-families traits using the connected account's client, and MUST be registered in the Tauri `invoke_handler`. Command names and argument shapes MUST match `src/requests/families.ts`.
#### Scenario: Every frontend command resolves on the Rust side
- **WHEN** the frontend invokes any of the 18 node-families commands
- **THEN** a registered Rust handler executes it against the node-families contract via the validator client
- **AND** no command falls through to "command not found"
#### Scenario: Execute returns a parsed family transaction result
- **WHEN** an execute command (e.g. `create_family`) succeeds on chain
- **THEN** the command returns a `FamilyTxResult` whose shape matches the frontend type, including `family_events` derived from the transaction (not fabricated)
#### Scenario: Queries return the frontend-typed shapes
- **WHEN** a query command runs against the contract
- **THEN** it returns data matching the corresponding `src/types/families.ts` shape (`NodeFamily`, membership, paged response with cursor)
### Requirement: Real provider replaces the mock in the running app
The production `/family` route SHALL render `FamilyPage` backed by the real `FamiliesContextProvider` (Tauri IPC), not the mock. The provider SHALL derive `controlledNodeIds` from the connected account's bonded nodes (removing the current empty-stub), so the operator view reflects nodes the account actually controls. The mock entry SHALL remain available for the offline e2e suite but MUST NOT back the production route.
#### Scenario: Production app shows live family data
- **WHEN** a signed-in account opens the Family page in the production app connected to a network with the contract
- **THEN** the page renders that account's family / invites from on-chain queries via the real provider
- **AND** the operator tab lists the account's controlled bonded nodes
#### Scenario: Mock stays isolated to e2e
- **WHEN** the production build is produced
- **THEN** the `/family` route uses `FamiliesContextProvider` and the mock provider/entry is excluded (per the mock-build gate)
### Requirement: Journeys pass against the sandbox contract
The owner and operator journeys SHALL be verifiable against the node-families contract on **sandbox** through the real provider. A read-only smoke MUST confirm queries render the known sandbox family/member via real IPC. Execute flows, when exercised, MUST run against a dedicated funded sandbox test account and MUST NOT depend on or corrupt unrelated shared state; the suite SHALL be iterated until the targeted journeys pass.
#### Scenario: Sandbox read smoke
- **WHEN** the app is connected to sandbox and the Family page loads
- **THEN** the real queries return and render the sandbox family/member without any state-changing transaction
#### Scenario: Sandbox execute flow (guarded)
- **WHEN** an execute journey runs against a funded sandbox test account
- **THEN** the on-chain state change is reflected back through the real queries in the UI
- **AND** the flow targets only that test account's family/nodes
@@ -0,0 +1,41 @@
## 1. Rust Tauri command layer (operations/families)
- [x] 1.1 Scaffold `src-tauri/src/operations/families/{mod.rs,query.rs,execute.rs}` mirroring `operations/mixnet/` (state access via `WalletState`, `current_client()?.nyxd`, `BackendError`, auto/simulated gas per D7). Added `nym-node-families-contract-common` path dep to `src-tauri/Cargo.toml`.
- [x] 1.2 Implemented the 9 **execute** commands over `NodeFamiliesSigningClient`; each returns `TransactionExecuteResult` (a subset of the frontend `FamilyTxResult``family_events` omitted per D2, UI re-derives via `refreshAll()`). `create_family` attaches the `create_family_fee` as base-denom funds; all use auto gas (`None`).
- [x] 1.3 Implemented the **query** commands over `NodeFamiliesQueryClient`. Each paged command flattens the contract envelope into the frontend's `{ items, start_next_after }` (`FamilyPagedResponse<T,C>`); members → `{ node_id, joined_at }`; past-invitation status normalised from the cw_serde tagged enum to `{ kind, at }`.
- [x] 1.4 Implemented `get_family_config` via raw contract-state read (design D5 — no `GetConfig` smart query): `query_contract_raw(addr, b"config")` → deserialize `Config``create_family_fee` converted base→display `DecCoin` so the UI can round-trip it into `create_family`.
- [x] 1.5 Registered all 18 commands in `main.rs` `invoke_handler`. Confirmed Tauri maps JS camelCase → Rust snake_case (verified against `update_mixnode_cost_params`/`new_costs`); fixed `src/requests/families.ts` execute bindings to pass camelCase keys (`nodeId`/`familyId`/`validitySecs`/`updatedName`/`updatedDescription`) — query bindings already correct.
- [x] 1.6 `cargo build` + `cargo clippy` clean (only pre-existing `delegate.rs` warning remains; none from `families/`).
## 2. Types reconciliation
- [x] 2.1 **Generated ts-rs exports** for the node-families contract types. Added a `generate-ts` feature + optional `ts-rs` dep to `nym-node-families-contract-common`, annotated 18 types in `types.rs` (`derive(ts_rs::TS)` + `export_to` under `ts-packages/types/src/types/rust/`, with overrides: `Coin → { denom, amount }`, `Addr → string`, `u64 → number`, tuple cursors `→ [number, number] | null`, `Config` renamed to `FamilyConfig`), wired them into `tools/ts-rs-cli` (dep + `use` + `do_export!`), and ran it → 18 `*.ts` files emitted (FamilyConfig, NodeFamily, NodeFamilyResponse, paged/membership/pending/past responses, etc.). Verified shapes (e.g. `NodeFamily.members: number`, `paid_fee: { denom, amount }`).
- [x] 2.2 Reconciled field-by-field. Outcome: `src/types/families.ts` is the wallet-facing (IPC-boundary) shape and the Rust command layer translates contract-truth into exactly it — no `families.ts`/mock field changes were needed. Divergences are all handled in `operations/families/query.rs`: base-denom `Coin` → display `DecCoin` (`paid_fee`, `create_family_fee`), per-page contract envelopes → uniform `{ items, start_next_after }`, cw_serde tagged `FamilyInvitationStatus``{ kind, at }`. Also fixed the generated `FamilyInvitationStatus` `at` field (`bigint``number`, `ts(type="number")` override) and exported all 18 generated family types from the `@nymproject/types` package barrel (`ts-packages/types/src/types/rust/index.ts`).
- [x] 2.3 Added a compile-time contract-shape guard `src/types/families.contract-guard.ts`: `tsc`/ForkTsChecker-checked `Equal<>` assertions between the wallet types and the committed ts-rs-generated contract types (imported by relative path). 1:1 types asserted whole; the IPC-normalised fields (`paid_fee`/`create_family_fee`, paged envelopes, status union) excluded with a documented reason. Drift on any other field breaks the build. Guard passes; families Jest suite green (43).
## 3. Real provider wiring (replace mock in the running app)
- [x] 3.1 Replaced the `controlledNodeIds = []` stub in `FamiliesContextProvider` with a derivation from `useBondingContext().bondedNode`: `[nodeId]` for a nym-node, `[mixId]` for a legacy mixnode, `[]` for a gateway / no bond (an account bonds ≤1 node — design D3, see §note below). Wrapped the `/family` route (`FamilyPageRoute`) in `BondingContextProvider` so the data is available (it's per-route, not global).
- [x] 3.2 Verified structurally: `/family``FamilyPageWithProvider` now mounts `BondingContextProvider``FamiliesContextProvider``FamilyPage`; queries point at `defaultQueries` (real Tauri requests); no `data-testid`/UI change; the mock entry (`main.mock.tsx``MockFamiliesContextProvider`) is untouched and e2e-only. tsc shows no families/bonding errors; full render-vs-chain confirmation is part of 3.3.
- [ ] 3.3 **(manual — needs running wallet + dev/sandbox network)** Sign in, open Family, confirm queries resolve and an execute reflects back after refresh. Overlaps the §4/§5 sandbox runs.
> **Note (D3 / §5 reality):** on the real chain an account controls **at most one** bonded node, so `controlledNodeIds` is 01 long. The mock's multi-node operator persona (3 nodes) cannot be reproduced from a single sandbox account; the multi-node operator journey stays a mock/Storybook-only scenario.
## 4. Sandbox read smoke (safe, automatable)
- [x] 4.1 **Read smoke passing against live sandbox.** Implemented as a headless Rust harness (`src-tauri/examples/sandbox_families_smoke.rs`) that connects via the same `validator-client` calls the Tauri commands wrap (GUI automation can't run on macOS — tauri-driver is Linux/Windows-only). Run output: resolved the bundled sandbox families contract address, read `Config` from raw state (`create_family_fee=50 NYM`, limits 30/50, 600s), and listed the 2 live families with members/pending invites via real IPC — no state change. Validates the query envelope deserialization + raw-config read (1.4/D5) end-to-end.
- [x] 4.2 Documented run rather than a CI gate (the harness needs the funded-account mnemonic via `.env`, not yet a CI secret): invocation documented in the example's header + `e2e/README.md`. Promote to a non-blocking CI job once the mnemonic is wired as a CI secret.
## 5. Sandbox execute flows (guarded) + iterate to green
- [x] 5.1 **Sandbox test account provisioned + funded:** `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv` (~101,000 NYM — ample for fees/repeat runs); mnemonic in vault secret `TAURI-WALLET-MNEMONIC` (item `95d3d842-90ad-4b6f-8b0c-10f5febce1c3`). Inject via CI secret only — never commit. Verify/inspect with `nym-cli -c sandbox.env account balance <addr>` (also handy for pre/post-run state checks + cleanup). Tests target only this account and clean up after.
- [x] 5.2 **Owner journey green on sandbox** (`sandbox_families_smoke.rs --write`): `create_family` (50 NYM funds attached + converted), `update_family` (rename), `disband_family` — all executed + confirmed on chain (real tx hashes), on-chain-latency polling, full cleanup. The owner-side member ops (`invite`/`revoke`/`kick`) are covered by the §5.3 run below.
- [x] 5.3 **Operator journey green on sandbox** (`--member`, two-account flow): owner=`FAMILY_OWNER` (`n18cuqlr…`, owns family 1), operator=`ACCOUNT_WITH_BONDED_NODE` (`n1nu7zg8…`, controls node_id 31). Exercised all 6 member commands against family 1 with per-step state assertions — `kick`, `invite`(×4), `reject`, `revoke`, `accept`(×2), `leave` — then **restored** node 31's membership (verified via `--accounts`: family 1 back to 1 member). A real pre-existing family was left exactly as found.
- [x] 5.4 **Both journeys pass against sandbox.** Across the two runs all 9 execute commands are confirmed end-to-end (funds, base-coin conversion, signing, two-account owner/operator separation, on-chain latency). Write tier stays manual (not a CI gate) until the mnemonics are wired as CI secrets; `.env` (gitignored) holds them locally and they are never printed/committed.
## 6. Verify & docs
- [~] 6.1 **Families work is regression-free; types-package generation gap fixed.** `cargo build` + `cargo clippy` clean; families code has no tsc errors; the contract-shape guard passes. Fixed the `@nymproject/types` build: `NodeAnnotationV1/V2` generated imports for `DetailedNodePerformanceV1/V2`, `DisplayRole`, `RoutingScore`, `ConfigScore`, `StressTestingScore` dangled because those (TS-deriving) types weren't in `tools/ts-rs-cli`; added them + regenerated → `tsc` builds the package with **0 errors**. Full wallet Jest now **101 pass, 0 fail** (was 84 + 4 suites unable to run); the two recovered suites are `delegationIdentity` + `unbondedDelegation.acceptance`. **Remaining (separate, out-of-scope, pre-existing):** `api/nodeStatus.test.ts` + `api/networkOverview.test.ts` fail to compile because `src/api/{nodeStatus,networkOverview}.ts` + their tests reference `'QA'`, which the source-of-truth `Network` enum (`nym-wallet-types`, → `SANDBOX | MAINNET`) doesn't include — an app-code drift unrelated to families or type generation; needs a product call (drop `QA` from the app code vs re-add it to `Network`). Mock Tier-1 Playwright unaffected (mock path untouched).
- [x] 6.2 Confirmed against the deployed contract source: `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` (`node-families-contract/src/msg.rs`) — matches the `update_family` command + frontend `UpdateFamilyArgs` + parent §9.5.
- [x] 6.3 Documented the real-IPC path in `e2e/README.md`: a "Real IPC layer (what the mock stands in for)" section (the 18 commands, the contract→wallet IPC-boundary translations, the mock→real provider switch) plus "Tier 3 — Sandbox real-IPC read smoke" and the guarded write-flow procedure (dedicated funded sandbox account, mnemonic via secret, target-only-self + cleanup).
- [x] 6.4 Updated parent change `node-families-wallet` §9.4 to `[~]` "Realised by the `node-families-real-ipc` change" with a pointer to this change's Rust command layer + provider wiring.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-05
@@ -0,0 +1,87 @@
## Context
The Node Families feature (parent change `node-families-wallet`) is complete and verified in Storybook. The page component is already cleanly decoupled from Tauri:
- `src/pages/families/FamilyPage.tsx` — pure page, consumes `useFamiliesContext()`, no Tauri imports.
- `src/context/FamiliesContextProvider.tsx` — the **real** provider; the only families file importing `./main` (Tauri runtime).
- `src/context/mocks/families.tsx` (`MockFamiliesContextProvider`) — drives the page from an in-memory contract engine (`familiesMockState.ts`) seeded by `families.fixtures.ts`.
- Storybook flow stories (`FamilyFlows.stories.tsx`) and `e2e/families.spec.ts` (Playwright → Storybook on :6006) already encode the journeys via `data-testid`s.
Environment facts that shape the design:
- The wallet dev server is **webpack-dev-server on `http://localhost:9000`** (`historyApiFallback: true`, `hot: true`) — the same `devUrl` Tauri loads. Pointing a browser-based runner here exercises the real app shell + router.
- **Playwright cannot drive Tauri's WebDriver.** `tauri-driver` exposes the classic W3C WebDriver protocol; Playwright's only WebDriver story is experimental **BiDi**, a different protocol. The Tauri-documented native clients are WebdriverIO and Selenium.
- **`tauri-driver` has no macOS support** (no WKWebView driver). Native-webview e2e runs only on Linux/Windows — in CI.
- The node-families **contract is now deployed to sandbox** (one family, one member), enabling an optional real-IPC read smoke.
This design supersedes an earlier framing that made WebdriverIO + `tauri-driver` the primary suite. Per the latest direction, the primary suite is **Playwright against the mock-wired dev server** (cross-platform, runs on macOS), and the native-webview run is an **optional CI validation leg**.
## Goals / Non-Goals
**Goals:**
- Render the existing Family page inside the real wallet app shell, backed by the Storybook mock providers, with zero chain/IPC dependency.
- A primary e2e suite that runs **everywhere including the developer's Mac**, reusing the existing Playwright dependency, selectors, and journeys.
- Keep all mock code out of the production bundle (compile-time elimination).
- Provide an optional native-binary validation leg (WebdriverIO + `tauri-driver`) in Linux CI for higher fidelity.
- Make use of the sandbox contract deployment via an optional real-IPC read smoke.
**Non-Goals:**
- No real IPC/chain *write* wiring (remains parent-change tasks 9.4/9.5); the sandbox tier is read-only.
- No macOS native-webview e2e (unsupported by Tauri).
- No destructive lifecycle mutations against the shared sandbox.
- No change to the Family feature's behavior or its specs.
## Decisions
**D1 — Playwright against the mock-wired dev server is the PRIMARY suite.**
Point Playwright at `http://localhost:9000` with the mock flag on, and replay the journeys against the real app shell + router. Rationale: runs on every OS (incl. the developer's Mac), reuses the already-present `@playwright/test` dep and the existing selectors/specs, and tests the actual app chrome rather than Storybook iframes. *Alternative considered:* keep driving Storybook stories — lower fidelity (no router/app shell) and no closer to the real app. *Alternative considered:* Cypress — no advantage over the Playwright suite already in the repo.
**D2 — Dedicated mock ENTRY (gated by the build flag); persona chosen at runtime within it.**
*Revised during apply:* the original "swap the families provider inside the prod `main` entry" cannot work in a browser — `main.tsx``AppCommon``AppProvider` is Tauri-coupled at bootstrap (imports `tauri-forage`, `@tauri-apps/api/app`, ~9 `requests/*`, fires `invoke` effects on mount) and the main routes are login-gated, so a plain browser never reaches `/family`. Instead, add a **dedicated mock entry** (`src/main.mock.tsx` + generated `main.mock.html`) that mounts the real `HashRouter` + real `ApplicationLayout` + the Family page, but with the existing Storybook mocks (`MockMainContextProvider` for the app bootstrap + `MockFamiliesContextProvider` for families) — no Tauri, no login gate, browser-safe. The webpack flag (`WALLET_MOCK_FAMILIES=on|off`, default `off`, also added to `EnvironmentPlugin`) **gates whether the mock entry + its HTML are built at all**, so production builds never include it (cleaner than tree-shaking a swapped import — the mock code is simply never an entry). The **persona** (`buildOwnerFlowStore` / `buildOperatorFlowStore` + sender) is read at *runtime* from `?persona=owner|operator` (default `owner`) on `window.location.search`, so a **single** dev server serves both personas and Playwright just navigates to different URLs. The real `/family` route (`FamilyPageRoute.tsx``FamiliesContextProvider`) and `FamilyPage.tsx` are left untouched, keeping the merged Code Connect mapping valid. *Alternatives considered:* in-place provider swap (rejected — bootstrap can't boot in a browser); thin shell without `ApplicationLayout` (rejected — little fidelity over the Storybook suite being replaced); native-only via Tier 2 (rejected — never exercises the shell on macOS).
**D3 — Reuse the existing fixtures and selectors verbatim.**
The mock-wired dev server seeds the same fixtures as the Storybook flows, and the page already exposes the journey `data-testid`s, so the Playwright journeys mirror `FamilyFlows.stories.tsx` step-for-step. The same selectors then carry over to the optional WebdriverIO leg, keeping all suites observably equivalent.
**D4 — Optional native-webview validation: WebdriverIO + `tauri-driver` in CI (not Playwright).**
For "how far can we get against the actual binary," follow the Tauri WebDriver-in-CI flow: Ubuntu runner, `xvfb-run` for a headless display, `libwebkit2gtk-4.1-dev` + `webkit2gtk-driver` + `tauri-driver` (cargo, `--locked`), WebdriverIO driving the packaged app's native WebKitGTK webview. Playwright is not an option here (protocol mismatch, D-context). This leg is optional and starts non-blocking.
**D5 — Skip-not-fail on unsupported platforms / missing tools.**
The WebdriverIO leg detects macOS (or a missing `tauri-driver`/`webkit2gtk-driver`) and skips with a clear message, so invoking it locally on a Mac is a no-op rather than a red failure. The Playwright suite is the local source of truth; the native leg is CI-only.
**D6 — CI: primary step in `build`, native leg as a separate job.**
The merged `ci-nym-wallet-frontend.yml` has one `build` job (ubuntu-22.04: install → tsc → lint → unit tests → build-storybook → upload). Add the **primary Playwright e2e as a step in `build`** (the Playwright suite is not yet in CI — only unit tests + Storybook build are). Add the **native-webview run as a separate job** (`needs: build` or independent) because it adds a Rust/Tauri compile + system webdriver deps that would slow and couple the main job; start it `continue-on-error`.
**D7 — Sandbox real-IPC smoke is a separate, optional, READ-only tier.**
The sandbox contract (one family/one member) lets us smoke the real `FamiliesContextProvider` + `src/requests/families.ts` against a live chain — validating the IPC wiring that the mock deliberately stands in for (parent-change 9.4). Keep it read-only and separate from the deterministic mock e2e: a shared sandbox is a poor place to run create/kick/disband lifecycles, and live-network reads are inherently flakier. *Alternative considered:* fold sandbox into the main e2e — rejected (non-determinism + shared-state mutation).
**D8 — Native leg is Linux-only initially (no Windows leg yet).**
Tier 2 ships as a single Ubuntu job. Adding the Windows/WebView2 leg (also supported by `tauri-driver`) doubles CI cost and maintenance for a tier that is already optional/`continue-on-error`; WebKitGTK on Linux is the higher-value target since it is closest to the Linux desktop builds. Revisit Windows only if a WebView2-specific regression surfaces.
**D9 — Sandbox smoke ships as a documented MANUAL step first, pinning the known family id.**
A live sandbox read needs a connected, funded wallet account, which is not provisionable non-interactively in CI today (mnemonic in secrets + network + chain availability). So D7's smoke starts as a documented manual procedure that pins the known sandbox family id and asserts render/shape, not exact contents. It graduates to a CI job only once a sandbox test account can be provisioned headlessly — tracked as a follow-up, not a blocker.
**D10 — One Playwright suite: repoint to the dev server, retire the Storybook-iframe specs.**
Rather than maintain two Playwright suites, repoint the single `e2e/families.spec.ts` at the mock-wired dev server (`:9000`). The Storybook `play` functions remain as Storybook-level interaction coverage (runnable via the test-runner), but we do not keep a parallel Playwright-against-Storybook suite — it would be lower fidelity and a drift source for no added coverage.
## Risks / Trade-offs
- **Browser fidelity ≠ packaged app** → The primary Playwright suite tests the web frontend, not the native binary or real `invoke`; the optional WebdriverIO leg covers the binary, and D7 covers real IPC. The three tiers together close the gap the mock leaves.
- **WebKitGTK rendering/timing differs from Chromium (native leg)** → Journeys wait on `data-testid`s (as the Storybook flows do) with low mock latency and generous CI timeouts.
- **Build-flag branch could ship mock code** → Default off; bundle/guard check + the spec scenario "Production build excludes mock code" lock it in.
- **Provider seam breaks the merged Figma Code Connect mapping** (`FamilyPage.figma.tsx``example: () => <FamilyPage />`) → Keep mock/real selection in a *separate* module (D2); never make `FamilyPage`'s module depend on the flag or on Tauri.
- **Theme swap (Nym 2.0) under test** → Confirmed color-only (families components untouched by the merge); journeys assert visibility/test ids, not pixels.
- **`tauri-driver`/`webkit2gtk-driver` version drift in CI** → Pin `tauri-driver` (`cargo install --locked`), install a known WebKitGTK driver in the runner, cache cargo bin; the leg is `continue-on-error` until stable.
- **Sandbox state drifts / contract redeploys** → The read smoke asserts shape/render, not exact contents (or pins to a known family id) so a changed fixture doesn't hard-fail; keep it non-blocking.
## Migration Plan
Additive only. Rollout: (1) build-time flag + provider seam; (2) repoint Playwright at the mock-wired dev server and add it to the `build` CI job; (3) add the optional WebdriverIO native job (`continue-on-error`); (4) add the optional sandbox read smoke. Any optional leg can be dropped/gated without affecting the app or the primary suite.
## Open Questions
All four prior open questions are now resolved:
- **Persona handling** → single dev server; persona via runtime `?persona=` URL param inside the build-gated mock branch (**D2**).
- **Windows native leg** → no; Linux-only initially (**D8**).
- **Sandbox smoke placement / family id** → documented manual step first, pinning the known sandbox family id; CI only once a headless test account exists (**D9**).
- **Retire Storybook-iframe Playwright specs?** → yes; one suite, repointed at the dev server (**D10**).
Residual (a follow-up, not a blocker): provisioning a headless sandbox test account would let D9's smoke graduate from manual to CI. **Update:** that account now exists (sandbox `n13jtj2...e0tzv`, mnemonic in vault secret `TAURI-WALLET-MNEMONIC`) — the graduation is now actionable, tracked in the `node-families-real-ipc` change (§45).
@@ -0,0 +1,30 @@
## Why
The Node Families feature is fully built and exercised in Storybook, but its end-to-end journeys have only ever run against Storybook iframes. We want to validate the page and the owner/operator flows inside the **real wallet app shell** (the production router and chrome), backed by the existing Storybook mock providers so no live chain or Rust IPC is required — and we want that primary suite to run **everywhere, including the developer's Mac**, with a heavier native-binary check available in CI.
## What Changes
- Mount the existing Family page via a **dedicated, build-flag-gated mock entry** (`main.mock.tsx` + `main.mock.html`): it renders the real router + layout but with the Storybook mocks (`MockMainContextProvider` + `MockFamiliesContextProvider`), so it runs in a plain browser with no Tauri runtime or login. The real `main` entry and `/family` route are untouched; production builds never include the mock entry. (Revised during apply — the real app bootstrap is Tauri-coupled and can't boot in a browser, so an in-place provider swap was not viable.)
- **Primary e2e — Playwright against the dev server (mock-wired):** point Playwright at the running webpack dev server (`http://localhost:9000`, the same `devUrl` Tauri loads) with the mock flag enabled, and replay the same journeys currently covered by the Storybook flow stories. This drives the real app shell + router in a real browser (Chromium/WebKit), is cross-platform (runs locally on macOS), and reuses the existing `@playwright/test` dependency and `data-testid` selectors. It supersedes the current Playwright→Storybook-iframe suite by pointing at the app shell instead.
- **Optional validation — WebdriverIO + `tauri-driver` in CI:** as a "how far can we get against the actual binary" leg, add the Tauri WebDriver CI flow (Ubuntu + `xvfb-run`, `webkit2gtk-driver` + `tauri-driver`, WebdriverIO) to drive the **packaged app in the native WebKitGTK webview**. Linux/Windows only (macOS has no WKWebView driver), so it lives in CI, not local dev, and starts non-blocking.
- **Optional higher-fidelity tier — sandbox real-IPC smoke:** the node-families contract is now deployed to **sandbox** (currently one family, one member). This unlocks a read-only smoke against the real `FamiliesContextProvider` + `src/requests/families.ts` wiring (parent-change task 9.4), separate from the deterministic mock e2e.
- **Not** adopting Playwright for the Tauri runtime: Playwright can't speak the classic W3C WebDriver protocol `tauri-driver` exposes (its experimental WebDriver **BiDi** support is a different protocol); WebdriverIO remains the client for the native leg.
## Capabilities
### New Capabilities
- `families-app-mock-build`: A build-time flag that mounts the Family page inside the wallet app shell with the Storybook mock providers, seeded by deterministic fixtures, served by the dev server, while keeping mock code out of production builds.
- `families-app-e2e`: End-to-end journey coverage of the owner and operator Node Families flows — primarily Playwright against the mock-wired dev server (cross-platform), with an optional WebdriverIO + `tauri-driver` native-webview validation leg in CI.
### Modified Capabilities
<!-- None: the existing node-families-owner / node-families-operator specs describe behavior this change exercises but does not alter. -->
## Impact
- **Frontend (`nym-wallet/src`)**: provider-selection seam (build-time flag) around `FamiliesContextProvider` vs `MockFamiliesContextProvider`; a Family route reachable in the mock-wired dev server; webpack config gains a mock-flag-driven `DefinePlugin` constant + persona seed.
- **Tests (`nym-wallet/e2e`)**: repoint/extend the Playwright config from Storybook (:6006) to the mock-wired dev server (:9000); journeys reuse existing selectors. Add an optional WebdriverIO config + `tauri-driver` (cargo) for the native leg.
- **CI (`.github/workflows/ci-nym-wallet-frontend.yml`)**: the existing single `build` job (ubuntu-22.04: install → tsc → lint → unit tests → build-storybook → upload) gains the **primary Playwright e2e step** (currently the Playwright suite is NOT in CI), and a **separate optional** native-webview job (xvfb-run + `webkit2gtk-driver` + `tauri-driver` + Tauri/Rust build, starts `continue-on-error`).
- **Figma Code Connect (recently merged)**: `src/pages/families/FamilyPage.figma.tsx` maps `FamilyPage` via `example: () => <FamilyPage />`. The provider seam MUST keep `FamilyPage` itself provider-agnostic and importable in isolation so this mapping (and the `src/**/*.figma.tsx` config) keeps resolving.
- **Theme (recently merged Nym 2.0 swap)**: orthogonal — color-only, no DOM/`data-testid` change, so journeys/selectors are unaffected; the mock-wired build simply renders the new dark palette.
- **Dependencies**: no new dep for the primary suite (`@playwright/test` already present); `webdriverio` (+ runner) and `tauri-driver` (cargo) only for the optional native leg.
- **Out of scope**: full destructive lifecycle mutations against shared sandbox (read smoke only); macOS native-webview e2e (unsupported by Tauri); Figma Code Connect publish (Tier-1/Hux-gated); real IPC wiring beyond the read smoke (parent-change 9.4/9.5).
@@ -0,0 +1,71 @@
## ADDED Requirements
### Requirement: Primary e2e against the mock-wired dev server
The project SHALL provide a Playwright suite that drives the wallet app served by the dev server (`http://localhost:9000`) with the families mock flag enabled, exercising the Family page within the real app shell and router (not a Storybook iframe). The suite MUST run cross-platform (including macOS) and MUST NOT require a live chain or Rust IPC. It SHALL reuse the existing journey `data-testid` selectors.
#### Scenario: Suite drives the app shell, not Storybook
- **WHEN** the Playwright suite starts
- **THEN** it launches (or reuses) the mock-wired dev server on `:9000` and navigates to the Family page within the app router
- **AND** the page renders backed by `MockFamiliesContextProvider` with no IPC call
#### Scenario: Runs on the developer's platform
- **WHEN** the suite is run locally on macOS
- **THEN** it executes the journeys in a real browser and reports pass/fail (no platform skip)
### Requirement: Owner lifecycle journey
The primary suite SHALL replay the owner lifecycle end to end against the app shell: create a family, invite the self-controlled node, accept the invite from the operator tab, kick the member, and disband the family, asserting the same post-step DOM transitions as the Storybook owner-lifecycle flow.
#### Scenario: Owner create-to-disband completes
- **WHEN** the suite runs the owner lifecycle against the owner-persona mock build
- **THEN** creating a family reveals the owner management page
- **AND** the invited node appears as a pending invite, then as a joined member after acceptance
- **AND** kicking the member removes it, and disbanding returns the create-family entry point
### Requirement: Operator lifecycle journey
The primary suite SHALL replay the operator lifecycle end to end: accept an invite on one controlled node, leave that family, then reject an invite on another controlled node, asserting the same post-step DOM transitions as the Storybook operator-lifecycle flow, including that the reject-node invite group ends empty.
#### Scenario: Operator accept-leave-reject completes
- **WHEN** the suite runs the operator lifecycle against the operator-persona mock build
- **THEN** accepting the invite shows the current-family card with a leave action
- **AND** after leaving and rejecting the other node's invite, that node's invite group is empty
### Requirement: Selector and journey parity across suites
All e2e suites (primary Playwright, and the optional native-webview leg) SHALL target the same `data-testid` selectors and assert the same observable outcomes as the existing Storybook flow stories, so they verify equivalent behavior across environments. Confirmation dialogs that portal outside the page canvas SHALL be located by their global test ids, mirroring the Storybook `screen`-scoped queries.
#### Scenario: Equivalent assertions across environments
- **WHEN** a journey step is asserted in more than one suite
- **THEN** every suite queries the same `data-testid` and expects the same visible/absent outcome
### Requirement: Optional native-webview validation leg
The project SHALL provide an optional WebdriverIO + `tauri-driver` leg that launches the packaged Tauri binary and replays the owner and operator journeys against the platform native webview, following the Tauri WebDriver-in-CI flow (Ubuntu runner, `xvfb-run` headless display, `webkit2gtk-driver` + `tauri-driver`). The leg MUST run in CI on a supported platform (Linux) and MUST skip — not fail — on unsupported platforms (macOS) or when `tauri-driver`/`webkit2gtk-driver` is absent.
#### Scenario: Native leg validates the binary in CI
- **WHEN** the native leg runs on the Linux CI runner
- **THEN** it installs `webkit2gtk-driver` and `tauri-driver`, builds the mock-wired binary, and replays the owner and operator journeys under `xvfb-run`
- **AND** a journey failure is reported (non-blocking while the leg is stabilizing)
#### Scenario: Skip-not-fail off-platform
- **WHEN** the native leg is invoked on macOS or without the required drivers
- **THEN** it is skipped with a clear message rather than reported as a failure
### Requirement: Optional sandbox real-IPC read smoke
The project MAY provide a read-only smoke that exercises the real `FamiliesContextProvider` + `src/requests/families.ts` against the node-families contract deployed to sandbox, validating the IPC wiring the mock stands in for. The smoke SHALL be read-only (no create/invite/kick/disband against shared sandbox) and SHALL assert rendered shape rather than exact contents (or pin a known family id), and MUST be separate from and non-blocking relative to the deterministic mock suites.
#### Scenario: Sandbox read smoke renders real data
- **WHEN** the read smoke runs against the sandbox-connected app
- **THEN** the Family page renders the sandbox family/member via real IPC without performing any state-changing transaction
- **AND** a failure does not block the primary mock suites
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Dedicated mock entry, gated by a build flag
The wallet app SHALL provide a dedicated mock entry (a separate webpack entry + generated HTML) that mounts the Family page with the mock app bootstrap (`MockMainContextProvider`) and mock families provider (`MockFamiliesContextProvider`), requiring no Tauri runtime and no login. A build flag (`WALLET_MOCK_FAMILIES`, default off) SHALL gate whether the mock entry and its HTML are built at all, so a production build never includes the mock entry or any families mock-engine code. The real `/family` route and `FamilyPage` component SHALL be left unchanged so the page stays backed by `FamiliesContextProvider` in production and remains importable in isolation.
#### Scenario: Mock build wires the mock providers
- **WHEN** the app is built with the families mock flag enabled
- **THEN** the mock entry renders `FamilyPage` wrapped in `MockFamiliesContextProvider` (and the app shell in `MockMainContextProvider`)
- **AND** the page resolves family reads from the in-memory mock engine without any Tauri IPC call or login
#### Scenario: Production build excludes the mock entry
- **WHEN** the app is built with the families mock flag disabled (default)
- **THEN** no mock entry or `main.mock.html` is produced and the bundle contains no families mock-engine code
- **AND** the real `/family` route still renders `FamilyPage` wrapped in `FamiliesContextProvider`
### Requirement: Deterministic seeded fixtures in the mock build
The mock-wired build SHALL seed the families mock store from the same deterministic fixtures used by the Storybook flow stories, so that a given launch presents a reproducible starting state for each persona. The build SHALL expose, without a live chain or Rust handlers, an owner-persona entry state and an operator-persona entry state equivalent to `buildOwnerFlowStore` and `buildOperatorFlowStore`.
#### Scenario: Owner persona entry state
- **WHEN** the mock build launches in the owner-persona configuration
- **THEN** the Family page opens with no existing family and the create-family entry point is reachable
- **AND** the self-controlled flow node is available to invite
#### Scenario: Operator persona entry state
- **WHEN** the mock build launches in the operator-persona configuration
- **THEN** the Node invites tab presents the seeded pending invitations on the operator's controlled nodes
### Requirement: Family page reachable in the Tauri app shell
The mock-wired build SHALL mount the Family page within the production app shell (the real router and chrome), not a Storybook iframe, and the page SHALL be reachable through normal in-app navigation. The page and its interactive elements SHALL expose the same `data-testid` selectors as the Storybook stories so a single set of journey selectors works in both environments.
#### Scenario: Navigating to the Family page in the app
- **WHEN** the mock-wired app is loaded and the user navigates to the Family section
- **THEN** the `family-page` element is rendered inside the app shell
- **AND** the owner and operator tabs (`family-tab-owner`, `family-tab-operator`) are present with the same test ids used in Storybook
@@ -0,0 +1,54 @@
## 1. Dedicated mock entry (D2)
- [x] 1.1 Add `WALLET_MOCK_FAMILIES` (default `off`) to webpack `EnvironmentPlugin`, and conditionally register a `mainMock` entry + a `main.mock.html` HtmlWebpackPlugin output **only when the flag is `on`**, so production builds never include the mock entry.
- [x] 1.2 Create `src/main.mock.tsx`: mount real `HashRouter` + real `ApplicationLayout` + a `/family` route, wrapped in `MockMainContextProvider` (mock app bootstrap) + `MockFamiliesContextProvider` (mock families) + `QueryClientProvider` + `NymWalletTheme` — no Tauri, no login gate. Leave `FamilyPage.tsx` and the real `FamilyPageRoute.tsx` untouched (keeps Code Connect valid).
- [x] 1.3 In `main.mock.tsx`, read the persona at runtime from `?persona=owner|operator` (default `owner`) on `window.location.search` and seed `buildOwnerFlowStore` / `buildOperatorFlowStore` + the matching sender (reuse `families.fixtures.ts`); seed the hash to `#/family`. (Added a third `operator-seeded` persona for the multi-node assertion.)
- [x] 1.4 Confirm the Family page is reachable at `/main.mock.html?persona=...#/family` on the dev server (`:9000`), rendering inside the real layout/router (not a Storybook iframe), keeping the existing `data-testid`s. (Layout chrome `Nav`/`AppBar` verified Tauri-free; full browser render pending a live run — see 6.1.)
- [x] 1.5 Confirm a default (flag `off`) production build is unchanged — no `mainMock` entry, no `main.mock.html`, no mock-engine code; the real `/family` route still wires `FamiliesContextProvider`. (Verified: config builds entry `auth,main,log` with flag off; `+mainMock` only with flag on.)
- [x] 1.6 Add an npm script to launch the single mock-wired dev server (`webpack:dev:mock` = `WALLET_MOCK_FAMILIES=on webpack serve --config webpack.dev.js`); both personas are reached on the one server via `?persona=`.
## 2. Primary e2e — Playwright against the mock-wired dev server
- [x] 2.1 Repoint `playwright.config.ts` from Storybook (:6006) to the mock-wired dev server (`baseURL http://localhost:9000`), with a single `webServer` (`webpack:dev:mock`) and `reuseExistingServer` locally; tests pick persona via the `?persona=` URL.
- [x] 2.2 Port the owner lifecycle journey (create → invite → accept → kick → disband) against `main.mock.html?persona=owner`, reusing the existing `data-testid` selectors and the Storybook flow steps.
- [x] 2.3 Port the operator lifecycle journey against `?persona=operator` (accept → leave, then reject), asserting the reject-node invite group ends empty.
- [x] 2.4 Port the multi-node operator invite-states assertion (`node-invite-group-201` present, `node-invite-group-203-empty`) against `?persona=operator-seeded`.
- [x] 2.5 Handle portalled confirmation dialogs via their global test ids (page-scoped, mirroring the Storybook `screen` queries).
- [x] 2.6 Retire the old Storybook-iframe specs (D10): replaced `e2e/families.spec.ts` with the dev-server journeys and factored shared selectors into `e2e/shared/families.ts` for parity (Storybook `play` functions stay as Storybook-level coverage).
- [x] 2.7 Confirm the suite runs green locally on macOS — **DONE: 3/3 passing** (clean cold run, Chromium). Required selector corrections: the original Storybook-derived ids on `NymCard` don't render (its prop is `dataTestid`, not `data-testid`), so journeys scope by the `operator-node-<n>` wrapper and key invite buttons by family id.
## 3. CI — wire the primary suite in
- [x] 3.1 Add a Playwright e2e step to the existing `build` job in `.github/workflows/ci-nym-wallet-frontend.yml` (install `--with-deps chromium` + run `test:e2e`).
- [x] 3.2 The step launches the mock-wired dev server via Playwright's `webServer` and fails the job on any journey failure (no `continue-on-error`).
## 4. Optional — native-webview validation leg (WebdriverIO + tauri-driver)
- [x] 4.1 Add `webdriverio` + `@wdio/{cli,local-runner,mocha-framework,spec-reporter}` + `tsx` to dev deps; documented `cargo install tauri-driver --locked` (README + CI).
- [x] 4.2 Create `wdio.conf.ts` (starts `tauri-driver`, `tauri:options.application` → release binary, mocha timeouts) + `test:e2e:tauri` script. **Binary wiring DONE:** `src-tauri/tauri.mock.conf.json` overrides the window to boot `main.mock.html` (owner persona); `tauri:build:mock` = `WALLET_MOCK_FAMILIES=on webpack:prod` + `tauri build --no-bundle --config tauri.mock.conf.json`. Operator persona reached via in-webview `browser.url('tauri://localhost/main.mock.html?persona=operator')` in the spec.
- [x] 4.3 Implement the skip-not-fail guard (`e2e-tauri/run.mjs`): macOS / missing `tauri-driver` / missing `webkit2gtk-driver` → exit 0 with a clear message (design D5).
- [x] 4.4 Reuse the journey selectors from §2 via `e2e/shared/families.ts` (`e2e-tauri/families.tauri.ts`), so the native leg asserts identical outcomes.
- [x] 4.5 Add a **separate** `e2e-tauri` CI job (ubuntu-22.04, `continue-on-error`) following the Tauri WebDriver-in-CI flow: `libwebkit2gtk-4.1-dev` + `webkit2gtk-driver` + `xvfb`, Rust + cache, `cargo install tauri-driver --locked`, build mock binary, run under `xvfb-run`.
## 5. Optional — sandbox real-IPC read smoke (manual first, D9)
- [x] 5.1 Documented (e2e/README.md) a manual read-only smoke: connect to a sandbox account, open the Family page, confirm it renders the known sandbox family/member via real `FamiliesContextProvider` + `requests/families.ts` (no state-changing tx).
- [x] 5.2 Documented: pin the known sandbox family id and assert render/shape, not exact contents; kept separate + non-blocking vs the mock suites.
- [x] 5.3 Documented the follow-up: promote to a non-blocking CI job once a sandbox test account can be provisioned headlessly.
## 6. Verification & docs
- [x] 6.1 Run the primary Playwright suite locally (macOS) — **DONE: 3/3 green** against the mock-wired app shell. (CI execution still pending the first push.) Fixes needed to get a clean browser render: skip React Refresh/HMR + the dev-server live-reload client in the mock build (`webpack.dev.js`, avoids missing `core-js-pure`/`ansi-html-community`); add the relative `node_modules` walk for the mock build (pnpm strict + absolute `resolve.modules` dropped `object-assign`); make `src/utils/common.ts` resolve `getCurrentWebviewWindow()` lazily (was crashing at import outside Tauri).
- [x] 6.2a **Fixed the pre-existing `webpack:prod` failure** that blocked the mock binary (and `pnpm build` generally). Root cause: the shared `ForkTsCheckerWebpackPlugin` runs in `mode: 'write-references'` (emit) and, with the wallet's `allowJs: true` + no `outDir`, emitted `.js` next to sources — polluting `src` (broke Jest), erroring "would overwrite input file" on `.test.js`/`.test.ts` pairs, and type-checking test files (jest globals). Contained wallet fix: add `declare module '*.css'` (`src/typings/css.d.ts`), set `outDir: ./.tsbuild` (redirects the emit out of `src`; `tsc --noEmit`/ts-loader/ts-jest ignore it), and exclude `**/*.test.*` from the type-check program (Jest still type-checks tests via ts-jest). Result: `webpack:prod` exits 0, emits `dist/main.mock.html`, no `src` pollution.
- [x] 6.2b Built the mock binary locally (`tauri:build:mock`, ~3m48s) and **visually confirmed** it boots straight into the Family page in the real app shell (owner persona / "Create a family" entry, network "Testnet Sandbox", sidebar "Version mock" — i.e. `MockMainContextProvider` + `MockFamiliesContextProvider`, no Tauri IPC/login). Fixed the binary path in `wdio.conf.ts` (`target/release/NymWallet` — the Cargo workspace target is at the wallet root, not `src-tauri/target`).
- [ ] 6.2c Run the WebdriverIO suite against the binary — **Linux/Windows-only** (`tauri-driver`), so it executes in the `e2e-tauri` CI job (skips on macOS via `run.mjs`). This is the one remaining unrun step.
- [x] 6.3 Confirm `tsc` + eslint stay clean and the production build is unaffected — verified: after `pnpm install`, `tsc` is fully clean (exit 0); `main.mock.tsx` + `utils/common.ts` lint clean; webpack prod-safe (no `mainMock` entry with flag off).
- [x] 6.4 Confirm the provider seam didn't break Code Connect (seam is a separate entry; `FamilyPage.tsx` + `FamilyPageRoute.tsx` untouched; `FamilyPage.figma.tsx` now type-checks once `@figma/code-connect` is installed) and the Nym 2.0 theme swap left journey `data-testid`s intact (color-only).
- [x] 6.5 Document the tiered setup + mock-flag usage (`e2e/README.md`).
## 7. Visual flow report (bonus)
- [x] 7.1 Capture a full-page, captioned screenshot at each journey step (`shot()` in `e2e/shared/report.ts`) — written to `e2e-report/screenshots/<test>/NN-label.png` and attached to the Playwright report.
- [x] 7.2 Assemble a static `e2e-report/index.html` filmstrip (per-test, ordered, captioned) via Playwright `globalSetup`/`globalTeardown` (`report.globalSetup.ts` resets, `report.globalTeardown.ts` builds).
- [x] 7.3 Stage + upload `e2e-report/` in the CI `build` job alongside Storybook, with `if: always()` so a failing run still publishes the filmstrip for inspection; gitignore `e2e-report/` + `playwright-report/`.
- [x] 7.4 Verified locally: `pnpm test:e2e` green (3/3), report renders 11 frames across the owner/operator/multi-node journeys.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-27
@@ -0,0 +1,102 @@
## Context
Node Families is a new on-chain capability defined in the `node-families-contract` spec (root openspec). The Nym Wallet is a Tauri + React app. State for each domain is exposed through a React Context provider that calls Tauri IPC `requests` and is mirrored by a mock provider under `src/context/mocks/` (see `bonding.tsx``mocks/bonding.tsx`, wired in `src/context/index.tsx`). Reads in newer code use TanStack Query (see `delegationQuery.ts`, `delegationQueryKeys.ts`). Storybook (`@storybook/react-webpack5`, addons: a11y, docs, mcp) is configured but currently only has the default example stories — there is no established pattern yet for rendering domain UI against mock providers. Playwright is not yet a dependency.
This design covers the wallet UI, the hooks/mocks layer, Storybook structure, and the test strategy. UI visuals come from Figma via Figma MCP during apply.
## Goals / Non-Goals
**Goals:**
- A `families` context + hooks that expose the full owner and operator contract surface, each with a mock counterpart.
- Mocked smart-contract fixtures sufficient to drive every story and test without a chain.
- Storybook coverage on three levels: component states, composed pages, full user-flow stories with simulated actions.
- Storybook interaction tests, Playwright e2e flows, and hook/integration tests against mocks.
- UI built from Figma designs.
**Non-Goals:**
- Implementing the contract-side `UpdateFamily` handler - it lands in a separate contract change that this branch rebases onto before merge.
- Owner-acts-for-node behaviour (V2 per NYM-1217): the future capability for the family owner to perform actions on member nodes, plus any reward/redemption flows tied to it. V1 acceptance is a pure membership record.
## Decisions
### D1: One `FamiliesContext` covering both personas, reads via TanStack Query
A single `src/context/families.tsx` (exported from `index.tsx`) exposes owner operations (create, edit, disband, invite, revoke, kick) and operator operations (accept, reject, leave), plus loading/error/refresh. Reads (family-by-owner, members, pending/past invitations, per-node invitations, config) are TanStack Query hooks with a `familyQueryKeys` module mirroring the `delegationQueryKeys` pattern. *Alternative considered:* two separate contexts (owner/operator) — rejected because a single connected account can be both, and the member list needs owner + archive reads together; one context avoids cross-provider coordination.
### D2: Tauri requests in `src/requests/families.ts`, types in `src/types`
Add IPC bindings mirroring `requests/bond.ts` (one function per execute msg + per query), typed against new TS types for `NodeFamily`, `FamilyMembership`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation`, `PastFamilyMember`, and `Config`. Execute calls return `TransactionExecuteResult` like the existing bonding calls.
### D3: A faithful `node-families-contract` mock, derived from the root spec
Following the existing `src/context/mocks` convention (module-level fixtures, a provider that mirrors the real context, execute methods that `setIsLoading``mockSleep` → mutate in-memory fixtures → return `TxResultMock`, reads as "fake tauri request" functions), the mock models the **entire** `node-families-contract` surface from `openspec/specs/node-families-contract/spec.md` — not just the happy path. A `withFamiliesMock` Storybook decorator wraps stories in this provider; the same provider backs RTL integration tests. *Alternative considered:* MSW/network mocking — rejected because the data crosses Tauri IPC, not HTTP, so provider-level mocking is the faithful seam.
The mock lives in `src/context/mocks/families.tsx` with fixtures in a co-located `src/context/mocks/families.fixtures.ts`, and reproduces:
- **Config**: `create_family_fee` (DecCoin), `family_name_length_limit`, `family_description_length_limit`, `default_invitation_validity_secs`.
- **Data types**: `NodeFamily { id, name, description, normalised_name, members, created_at, paid_fee, owner }`; `FamilyMembership { family_id, joined_at }`; `FamilyInvitation { family_id, node_id, expires_at }`; `PendingFamilyInvitationDetails { invitation, expired }`; `PastFamilyInvitation { invitation, status: Accepted{at} | Rejected{at} | Revoked{at} }`; `PastFamilyMember { family_id, node_id, removed_at }`.
- **Execute msgs** (mutating fixtures, honoring invariants): `CreateFamily`, `DisbandFamily`, `InviteToFamily`, `RevokeFamilyInvitation`, `KickFromFamily`, `AcceptFamilyInvitation`, `RejectFamilyInvitation`, `LeaveFamily`, plus an `OnNymNodeUnbond` test helper to simulate the mixnet cleanup callback. (`UpdateConfig` is admin-only and out of the wallet's scope.)
- **Queries**: `GetFamilyById`, `GetFamilyByName` (normalised lookup), `GetFamilyByOwner`, `GetFamilyMembership`, and the paginated `GetFamiliesPaged`, `GetFamilyMembersPaged`, `GetAllFamilyMembersPaged`, `GetPendingInvitation(s)ForFamilyPaged`, `GetPendingInvitationsForNodePaged`, `GetAllPendingInvitationsPaged`, `GetPastInvitationsForFamily/NodePaged`, `GetAllPastInvitationsPaged`, `GetPastMembersForFamily/NodePaged` — all with exclusive `start_after`, default limit 50, max 100, and `start_next_after`.
- **Invariants enforced in the mock** so stories/tests exercise real edge cases: one family per owner, one family per node, monotonic never-recycled family ids starting at 1, ASCII normalisation + global uniqueness of names, byte-length limits on name/description, `expired = now >= expires_at` computed live, per-`(family, node)` archive counters starting at 0, and the disband/leave/kick/unbond archival transitions.
- **Errors**: a typed error set mirroring the contract (`InvalidFamilyCreationFee`, `FamilyNameAlreadyTaken`, `FamilyNameTooLong`, `EmptyFamilyName`, `SenderAlreadyOwnsAFamily`, `NodeAlreadyInFamily`, `NodeDoesntExist`, `PendingInvitationAlreadyExists`, `ZeroInvitationValidity`, `InvitationExpired`, `InvitationNotFound`, `FamilyNotEmpty`, `SenderDoesntControlNode`, `NodeNotMemberOfFamily`, etc.) so warning/error states are reachable from mocked calls.
- **Events**: mock execute returns carry the spec's event names/attributes (`family_creation`, `family_disband`, `family_invitation`, `family_invitation_revoked/accepted/rejected`, `family_member_left/kicked`, `family_node_unbond_cleanup`) so any UI/indexer assertions can verify them.
### D4: Member-list sections map 1:1 to contract queries (one row per record)
The four UI sections each correspond to a distinct contract query, paginating independently via its `start_after` cursor. **Pending**: rows from `GetPendingInvitationsForFamilyPaged`, each carrying the `expired` flag. **Joined**: rows from `GetFamilyMembersPaged`. **Rejected**: rows from `GetPastInvitationsForFamilyPaged` filtered to `Rejected` status. **Removed**: rows from `GetPastMembersForFamilyPaged` (covers both left and kicked). `Revoked` past invitations are owner-side actions and are NOT shown in the member list. Because the contract stores per-`(family, node)` archive records that accumulate (a node may be invited, kicked, re-invited, etc. arbitrarily many times), a single node MAY legitimately appear in more than one section - each row represents a record, not a node. The aggregator hook is therefore a thin pass-through (queries → named sections), not a priority-cascade derivation; UI clarity comes from per-section headings + record timestamps, not from collapsing history.
### D5: Family tab is always visible; UI identifies families by name only
The Family tab renders for **every** wallet account (not gated on owning a family or controlling a node), so any account can start a family: it shows the create entry point when the account owns no family and the management surface when it does. `family_id` is internal only - the UI identifies families by name (globally unique among live families after normalisation) and shows the owner address as supplementary trust context wherever invites are displayed. Names are released for reuse when a family is disbanded, so a past archived record's name may not match the family currently holding that name.
### D6: Large lists are paginated via the contract's exclusive `start_after` cursor
Member lists and invitation archives use the contract's cursor pagination: each page passes `start_after` (exclusive) and reads `start_next_after` from the response to fetch the next page, with the contract's default limit of 50 (max 100). The TanStack Query read hooks expose this as incremental/infinite pagination; the mock honours the same cursor semantics so paging is exercised without a chain.
### D7: Storybook three-level structure
- **Components** (`src/components/families/*.stories.tsx`): each component with explicit state args (empty, loading, error, expired, over-limit, success).
- **Pages** (`src/pages/families/*.stories.tsx`): composed surfaces (owner management page, operator invites page) backed by the mock provider.
- **Flows** (`*.flow.stories.tsx`): play functions (`@storybook/test`) that perform the user actions end to end (create → invite → accept → kick → disband; operator: receive → accept/reject → leave).
### D8: Playwright runs against the static Storybook build
Because the production app is Tauri (not a plain web target), Playwright e2e specs run against `build-storybook` served statically, exercising the flow stories as real browser sessions. This gives deterministic, chain-free e2e without packaging Tauri. *Alternative considered:* `tauri-driver`/WebDriver against the native app — heavier, flaky in CI, and unnecessary since the contract layer is mocked anyway.
### D9: Creation fee and limits are read from chain config
The UI reads `create_family_fee`, `family_name_length_limit`, and `family_description_length_limit` from contract `Config` (mocked in fixtures), never hardcoding 100 NYM or character counts. Validation is byte-length based to match the contract.
## Design Source (Figma)
UI is built from the **Nym 2.0** Figma file, board **"Nym_Wallet Node families added"**:
- **File key:** `moIK1E6AaXhFz8lI1pZVrI`
- **Board node:** `1859:981`
- **Board URL:** https://www.figma.com/design/moIK1E6AaXhFz8lI1pZVrI/%F0%9F%94%A5%F0%9F%94%A5Nym.2.0%F0%9F%94%A5%F0%9F%94%A5?node-id=1859-981
- Open any frame below via the same URL with `?node-id=<id>` (dash form, e.g. `2474-1935`). Pull frames during apply with the Figma MCP `get_design_context` tool (`fileKey` + `nodeId`).
The board holds two overlapping mockups. The **newer polished wireframe set (`2474:*`, "nym-wallet-ui-wireframes", 28/05) is the canonical build target.** The **ticket-annotated composite (`1861:*`, "family-wallet-composite", 13/05) is the reference** for component-level detail and per-ticket intent (its sections carry the NYM-12xx numbers). Where the two disagree, the `2474:*` set wins; reconcile any drift at apply time.
**Canonical surfaces (`2474:*`):**
- `2474:1935` — Family · all 4 user states → `2474:1945` No family yet · `2474:1980` Owner · `2474:2063` Member, pending invite · `2474:2134` Member, active
- `2474:1360` — Balance — Overview · `2474:1449` — Balance — Family tab
- `2474:1305` — Dissolve · `2474:1311` — Member (remove/offline states)
**Reference composite sections (`1861:*`), ticket-mapped:**
- `1861:393` SECTION 1 — intro / 4 family states
- `1861:638` SECTION 2 — Create Family · **NYM-1210**
- `1861:794` SECTION 3 — Family Detail (roster + settings) · **NYM-1211** edit · **NYM-1213** view roster · **NYM-1214** remove member · **NYM-1215** dissolve empty family
- `1861:1150` SECTION 4 — Invite Node · **NYM-1212**
- `1861:1349` SECTION 5 — Incoming Invite popups · **NYM-1216 / 1217 / 1218**
- `1861:1711` SECTION 6 — Leave family · **NYM-1219**
Also on the board: a wireframe **Components** column (`2474:863`) and ten full-screen render frames (`2386:2352``2464:3976`) showing each state in the full app shell. Per-requirement frame links are recorded inline in `specs/node-families-owner/spec.md` and `specs/node-families-operator/spec.md`.
## Risks / Trade-offs
- **[`UpdateFamily` lands in a separate contract change]** → Build the edit UI + mock against the decided shape (see Resolved); verify on rebase per task 9.5 and reconcile any drift in the request binding, mock execute, and TS types. No feature flag needed since the wallet branch only merges after the contract change lands.
- **[Same node may appear in multiple sections]** (e.g., currently Joined and previously Removed) → record timestamps and clear section headings must make the overlap read as history rather than as a duplicate row; aggregator hook is a pass-through, so the risk is purely UX, not data correctness.
- **[Playwright-vs-Tauri divergence]** → Storybook flows test UI logic against mocks, not the real IPC bridge; a thin set of manual/native smoke checks should still cover Tauri wiring before release.
- **[Fresh Storybook conventions]** → Establish the `withFamiliesMock` decorator and naming once, up front, to avoid per-story drift.
## Migration Plan
Additive only — new tab, context, requests, types, stories, tests. No existing wallet behavior changes. This branch rebases onto the `UpdateFamily` contract change before merging, so the edit path is real (not feature-flagged) at ship time. Rollback is removal of the tab entry point.
## Open Questions
_(none open)_
_Resolved:_ **Figma file/frame URLs** for each component and page are now captured — see "Design Source (Figma)" above (file `moIK1E6AaXhFz8lI1pZVrI`, board `1859:981`); the `2474:*` polished set is canonical and the `1861:*` ticket-annotated composite is the per-component reference, with per-requirement node IDs recorded inline in the two spec files. No family key concept in V1 (acceptance is a pure membership record; owner-acts-for-node is V2 per NYM-1217); `family_id` is **internal**, the UI identifies families by **name**; names are unique among **live** families only (released for reuse on disband); the Family tab is **always visible**; large lists are **paginated via the contract's `start_after` cursor** (default 50, max 100); the **`UpdateFamily` message shape** is `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` with `None` meaning "field unchanged" and `Some(_)` meaning "set to this value", sender must be the family owner; this lands in a separate contract change and is verified on rebase per task 9.5.
@@ -0,0 +1,50 @@
## Why
Node Families is a new on-chain capability (see the `node-families-contract` spec) that lets a family owner group nodes under a single family and lets node operators join, leave, and respond to invites. The Nym Wallet currently has no surface for any of this. This change introduces a **Family Tab** in the wallet so the two personas — family owners and node operators — can drive the full family lifecycle from the UI, end to end. It delivers tickets NYM-1210 through NYM-1219.
## What Changes
**Family owner flows (NYM-12101215)**
- New **Family Tab**, always visible to any wallet account: shows a create-family entry point when the account owns no family, and the family management surface when it does.
- Create a family: attach the configured creation fee (`Config::create_family_fee`), set name + description, surface insufficient-balance and fee errors.
- Add/edit family **name** and **description**, with byte-length limits and input sanitisation, inline over-limit errors.
- Invite a node by **node ID**: triggers the contract invite (nonce/TTL via `validity_secs`), with confirmation; warns and does not send if the node is already in a family, does not exist, or already has a pending invite from this family.
- Manage **pending invites**: withdraw an active invite (confirmation prompt), dismiss/clear expired invites (confirmation), with list + contract state kept in sync.
- View the **member list** grouped by status: Pending / Joined / Rejected / Removed, with per-status empty states and refresh.
- **Kick** a member (confirmation prompt → contract `KickFromFamily`), moving the node to Removed.
- **Delete** an empty family (`DisbandFamily`); blocked with a clear error when members remain.
**Node operator flows (NYM-12161219)**
- View incoming **invites per node** (multi-node aware): family name, inviting owner, expiry/TTL; expired invites shown as non-actionable.
- **Accept** an invite (`AcceptFamilyInvitation`) → node moves to Joined. V1 acceptance is a pure membership record; owner-acts-for-node behaviour (where the family owner could perform actions on member nodes) is V2 per NYM-1217 and out of scope here.
- **Reject** an invite (`RejectFamilyInvitation`, confirmation) → no longer shown, node reflected as Rejected.
- **Leave** a family (`LeaveFamily`, confirmation) → removed from member list; can subsequently receive/accept new invites.
**Engineering scope**
- React **hooks** wrapping the contract surface (queries + execute msgs), each with a **mock** counterpart following the existing `src/context/<x>.tsx``src/context/mocks/<x>.tsx` pattern.
- **Storybook** coverage on three levels: component states, composed pages, and full user-flow stories with simulated actions, all driven by the mocked hooks/contract data.
- **Tests**: Storybook interaction tests, Playwright end-to-end flows, and hook/integration tests against the mocks.
- UI implemented from **Figma** (designs supplied via Figma MCP during apply).
**Contract dependencies** (landing in a separate contract change; this branch rebases onto it before merge):
- **Edit name/description after creation** (NYM-1211): the contract spec has `CreateFamily` (carries name/description) and `UpdateConfig` (admin) but no `UpdateFamily` edit handler yet. The contract team will add it; the wallet builds against an assumed message shape and verifies it on rebase (see design.md Open Questions).
## Capabilities
### New Capabilities
- `node-families-owner`: Wallet behavior for the family-owner persona — Family Tab visibility, create/edit/delete family, invite/withdraw/clear invitations, and the status-grouped member list.
- `node-families-operator`: Wallet behavior for the node-operator persona — per-node invite viewing (with TTL/expiry), accept, reject, and leave.
### Modified Capabilities
<!-- None in the wallet's own spec set. The two contract gaps above belong to the
`node-families-contract` capability in the root openspec project and must be
resolved there (separate change); they are tracked here as dependencies, not deltas. -->
## Impact
- **Code**: new Family Tab pages/components under `src/pages` + `src/components`; new context/hooks in `src/context` (`families.tsx` owner, `familyInvites.tsx` operator or a combined `families.tsx`) with mocks in `src/context/mocks`; new types in `src/types`; new request/IPC bindings (Tauri) for the contract execute/query methods.
- **Mocks**: a faithful `node-families-contract` mock under `src/context/mocks` (provider + `families.fixtures.ts`) derived from `openspec/specs/node-families-contract/spec.md`, covering its full surface — Config, all data types, every execute msg and query (with pagination), enforced invariants, the typed error set, and emitted events. Follows the existing `mocks/bonding.tsx` convention.
- **Storybook**: new stories tree for components → pages → flows.
- **Tests**: Playwright e2e specs; Jest/RTL hook + integration tests against mocks; Storybook interaction tests.
- **Dependencies / blockers**: `UpdateFamily` edit lands in a separate contract change; this wallet branch will rebase onto that change before merge, and the edit path swaps from the mock to the real IPC binding at rebase time (verified per task 9.5). Creation fee is configurable on-chain (not a hardcoded 100 NYM); UI must read it from config.
- **External**: Figma designs (via Figma MCP) required during implementation — Nym 2.0 file `moIK1E6AaXhFz8lI1pZVrI`, board "Nym_Wallet Node families added" (`1859:981`); per-frame mapping in design.md "Design Source (Figma)".
@@ -0,0 +1,63 @@
## ADDED Requirements
<!-- Design source: Figma file `moIK1E6AaXhFz8lI1pZVrI`, board `1859:981`. Canonical = `2474:*` set; reference = `1861:*` ticket composite. See design.md "Design Source (Figma)". Per-requirement frame IDs are noted as _Design:_ lines below; open via ?node-id=<id> (dash form). -->
### Requirement: Node operator can view family invites per node
_Design: canonical `2474:2063` (Member, pending invite); ref `1861:1349` (SECTION 5 · Incoming Invite popups · NYM-1216)._
The wallet SHALL display, in the Family tab, the pending family invitations addressed to each node the operator controls. When the operator controls multiple nodes, invitations SHALL be shown separately per node. Each invitation SHALL show the family name, the inviting family owner, and the expiry (TTL). Invitations whose contract `expired` flag is true SHALL be shown as **expired** and SHALL NOT be actionable (no accept/reject). Invitations are sourced from `GetPendingInvitationsForNodePaged` per controlled node.
#### Scenario: Active invite is shown with details
- **WHEN** a controlled node has a pending, not-yet-expired invitation
- **THEN** the wallet shows the family name, inviting owner, and expiry, and offers accept/reject actions
#### Scenario: Expired invite is shown as non-actionable
- **WHEN** a pending invitation's `expired` flag is true
- **THEN** the wallet shows it as expired and offers no accept/reject actions
#### Scenario: Multiple nodes show their invites separately
- **WHEN** the operator controls more than one node, each with different invitations
- **THEN** the wallet groups invitations under their respective node and shows each node's distinct invite state
### Requirement: Node operator can accept an invite
_Design: ref `1861:1349` (SECTION 5 · accept · NYM-1218), incl. on-chain-consequences confirm; canonical `2474:2063` (Member, pending invite)._
The wallet SHALL let the operator accept a pending, not-yet-expired invitation from the invite view, triggering `AcceptFamilyInvitation { family_id, node_id }`. On success the wallet MUST show a confirmation and the node MUST appear as **Joined** in the family member list. In V1 acceptance records membership only; the family owner gains no control over the node itself (owner-acts-for-node is V2 per NYM-1217). Accepting an expired invitation MUST be prevented (`InvitationExpired`).
#### Scenario: Successful acceptance
- **WHEN** the operator accepts a not-yet-expired invitation for a node they control
- **THEN** `AcceptFamilyInvitation` is triggered, a confirmation is shown, and the node is reflected as Joined
#### Scenario: Expired invitation cannot be accepted
- **WHEN** the operator attempts to accept an invitation whose `expired` flag is true
- **THEN** the wallet prevents acceptance and surfaces an expired error
### Requirement: Node operator can reject an invite
_Design: ref `1861:1349` (SECTION 5 · reject · NYM-1217/1218)._
The wallet SHALL let the operator reject a pending invitation from the invite view, behind a confirmation prompt, triggering `RejectFamilyInvitation { family_id, node_id }`. After rejection the invitation MUST no longer appear in the operator's pending list, and the node MUST appear under **Rejected** in the family member list.
#### Scenario: Successful rejection
- **WHEN** the operator rejects a pending invitation and confirms the prompt
- **THEN** `RejectFamilyInvitation` is triggered and the invitation is removed from the pending list
#### Scenario: Rejected invite is no longer shown
- **WHEN** an invitation has been rejected
- **THEN** it is not shown again in the operator's pending invite list and the node shows as Rejected in the family member list
### Requirement: Node operator can leave a family
_Design: ref `1861:1711` (SECTION 6 · Leave family · NYM-1219); canonical `2474:2134` (Member, active)._
The wallet SHALL let an operator whose node is a member of a family leave it voluntarily from the Family tab, behind a confirmation prompt, triggering `LeaveFamily { node_id }`. After leaving, the node MUST be removed from the family member list (shown as Removed) and the operator MUST subsequently be able to receive and accept invitations from other families.
#### Scenario: Successful leave
- **WHEN** the operator leaves a family and confirms the prompt
- **THEN** `LeaveFamily` is triggered and the node is removed from the family member list
#### Scenario: Node can join another family after leaving
- **WHEN** a node has left a family
- **THEN** the operator can receive and accept invitations from other families for that node
@@ -0,0 +1,155 @@
## ADDED Requirements
<!-- Design source: Figma file `moIK1E6AaXhFz8lI1pZVrI`, board `1859:981`. Canonical = `2474:*` set; reference = `1861:*` ticket composite. See design.md "Design Source (Figma)". Per-requirement frame IDs are noted as _Design:_ lines below; open via ?node-id=<id> (dash form). -->
### Requirement: Family Tab is always visible and exposes create or management based on ownership
_Design: `2474:1935` (4 user states) → `2474:1945` No family yet, `2474:1980` Owner; `2474:1449` Balance — Family tab; ref `1861:393` (SECTION 1)._
The wallet SHALL display the **Family** tab for every connected wallet account, regardless of whether the account owns a family or controls a bonded node, so that any account can start a new family. When the connected address does not own a family, the tab SHALL present a create-family entry point. When the address already owns a family, the tab SHALL show the family management surface instead of the create entry point.
#### Scenario: Account without a family sees the create entry point
- **WHEN** any connected account opens the Family tab and its address owns no family
- **THEN** the tab is shown and renders a "Create family" entry point
#### Scenario: Owner sees management surface instead of create
- **WHEN** the connected address already owns a family
- **THEN** the Family tab renders the family management surface (member list, invite, edit, delete) and not the create entry point
### Requirement: Family owner can create a family with the creation fee
_Design: ref `1861:638` (SECTION 2 · Create Family · NYM-1210); canonical entry point `2474:1945` (No family yet)._
The wallet SHALL allow an eligible user to create a family by submitting a name and description and attaching the contract's configured creation fee (`Config::create_family_fee`, read from chain — NOT a hardcoded amount). The wallet MUST display the required fee before submission, deduct it on success, and show a success confirmation that surfaces the new family. The wallet MUST surface an insufficient-balance error before submitting when the balance is below fee + estimated gas, and MUST surface contract fee errors (`InvalidFamilyCreationFee`, `InvalidDeposit`) clearly.
#### Scenario: Successful creation
- **WHEN** a user with sufficient balance submits a valid name and description with the correct fee attached
- **THEN** the family is created, the fee is deducted, and a success confirmation referencing the new family is shown
#### Scenario: Insufficient balance is surfaced before submission
- **WHEN** the connected account balance is below the creation fee plus estimated gas
- **THEN** the wallet shows a clear insufficient-balance error and does not submit the transaction
#### Scenario: Contract fee error is surfaced
- **WHEN** creation fails with `InvalidFamilyCreationFee` or `InvalidDeposit`
- **THEN** the wallet shows a clear fee error and the family is not created
### Requirement: Family owner can add and edit the family name and description
_Design: ref `1861:794` (SECTION 3 · NYM-1211 edit); canonical `2474:1980` (Owner state)._
The wallet SHALL let the owner set a name and description on creation and edit either after creation. Inputs MUST be validated against the contract byte-length limits (`Config::family_name_length_limit`, `Config::family_description_length_limit`) measured in bytes, and MUST be sanitised so that scripts, control characters, and injection attempts are neutralised before submission. Over-limit input MUST be surfaced with an inline error and MUST NOT be submitted. Editing uses the contract's `UpdateFamily` handler.
#### Scenario: Valid name and description are accepted
- **WHEN** the owner enters a name and description within the byte limits
- **THEN** the input passes validation and is submitted
#### Scenario: Over-limit input is blocked with an inline error
- **WHEN** the owner enters a name or description whose byte length exceeds its configured limit (e.g. a multi-byte emoji pushing the byte count over)
- **THEN** the wallet shows an inline over-limit error and does not submit
#### Scenario: Special characters and scripts are sanitised
- **WHEN** the owner enters input containing HTML/script tags or injection attempts
- **THEN** the wallet neutralises the input and never renders it as executable markup
#### Scenario: Owner edits name and description after creation
- **WHEN** the owner edits the name and/or description of an existing family with valid input
- **THEN** the updated values are persisted and reflected in the family management surface
### Requirement: Family owner can invite a node by node ID
_Design: ref `1861:1150` (SECTION 4 · Invite Node · NYM-1212), incl. the three warning states; canonical `2474:1980` (Owner state)._
The wallet SHALL let the owner invite a node by entering its node ID, triggering `InviteToFamily` (with optional `validity_secs` for the TTL/nonce). On success the wallet MUST show a confirmation. The wallet MUST NOT send the invite and MUST warn the owner when: the node is already in a family (`NodeAlreadyInFamily`), the node does not exist or is unbonding (`NodeDoesntExist`), or a pending invite from this family already exists (`PendingInvitationAlreadyExists`). Malformed node IDs MUST be surfaced with a clear validation error.
#### Scenario: Successful invite
- **WHEN** the owner enters a valid node ID for an existing, family-free node
- **THEN** the invite is sent and a confirmation is shown
#### Scenario: Node already in a family is warned, not invited
- **WHEN** the entered node is already a member of a family
- **THEN** the wallet shows an "already in family" warning and does not send the invite
#### Scenario: Non-existent node is warned, not invited
- **WHEN** the entered node does not exist or is unbonding
- **THEN** the wallet shows a "node does not exist" warning and does not send the invite
#### Scenario: Invalid node ID is rejected
- **WHEN** the owner enters a malformed node ID
- **THEN** the wallet shows a clear validation error and does not submit
### Requirement: Family owner can withdraw pending invites and clear expired ones
_Design: ref `1861:794` (SECTION 3 · roster/invite management) and `1861:1150` (SECTION 4 · pending invite + expired states)._
The wallet SHALL list the family's pending invitations with their expiry state (using the contract `expired` flag). For an active (not-yet-expired) invite the owner SHALL be able to withdraw it via `RevokeFamilyInvitation` behind a confirmation prompt. Expired invites SHALL be shown as expired with a dismiss/clear option, also behind a confirmation prompt. After either action the invite MUST be removed from the pending list and the displayed contract state refreshed.
#### Scenario: Withdraw an active invite
- **WHEN** the owner withdraws a pending, not-yet-expired invite and confirms the prompt
- **THEN** the invite is revoked on-chain, removed from the pending list, and the state refreshes
#### Scenario: Expired invite shows as expired with a clear option
- **WHEN** a pending invite's `expired` flag is true
- **THEN** the wallet displays it as expired and offers a dismiss/clear action
#### Scenario: Clearing an expired invite requires confirmation
- **WHEN** the owner clears an expired invite
- **THEN** a confirmation prompt is shown, and on confirm the invite is removed from the pending list and the state refreshes
### Requirement: Family owner can view the member list grouped by status
_Design: ref `1861:794` (SECTION 3 · view roster · NYM-1213); canonical `2474:1980` (Owner state)._
The wallet SHALL display the family's records grouped into four sections: **Pending** (active pending invitations), **Joined** (current members), **Rejected** (invitations the node declined), and **Removed** (members that left or were kicked). Each section is sourced from a distinct contract query and paginates independently using the contract's exclusive `start_after` cursor (default page size 50, max 100), fetching subsequent pages via the returned `start_next_after`. Because the contract stores per-`(family, node)` archive records that accumulate over time, a single node MAY appear in more than one section when its history justifies it (e.g., currently Joined and previously Removed); each row represents a record, not a node. `Revoked` past invitations are owner-side actions and SHALL NOT be shown in the member list. The list SHALL refresh to reflect current contract state and SHALL render an empty state for any section with no entries.
#### Scenario: Large section is paginated by cursor
- **WHEN** a section has more entries than one page
- **THEN** the wallet fetches additional pages using `start_after`/`start_next_after` rather than loading the whole section at once
#### Scenario: Records are grouped into sections
- **WHEN** the owner opens the member list
- **THEN** records appear under Pending, Joined, Rejected, and Removed according to which contract query produced them
#### Scenario: Node appears in multiple sections when history justifies it
- **WHEN** a node is currently a member of the family AND has been kicked or has left at some earlier point
- **THEN** it appears as a row in Joined for the current membership AND as a separate row in Removed for the past kick/leave
#### Scenario: Revoked invitations are not shown in the member list
- **WHEN** a node has only past `Revoked` invitations from this family (no current membership, no pending invite, no past membership, no past Rejected invitation)
- **THEN** the node does not appear in the member list
#### Scenario: Empty section shows an empty state
- **WHEN** a section has no entries (e.g. no pending invites)
- **THEN** the wallet renders an empty state for that section
#### Scenario: List reflects state after an action
- **WHEN** the underlying contract state changes (invite accepted, member kicked, etc.) and the list refreshes
- **THEN** the new record appears in its corresponding section, while any pre-existing records for the same node remain in their own sections
### Requirement: Family owner can remove a node from the family
_Design: ref `1861:794` (SECTION 3 · remove member · NYM-1214); canonical `2474:1311` (Member remove state)._
The wallet SHALL let the owner remove (kick) a Joined member via `KickFromFamily`, behind a confirmation prompt. On confirmation the kick is submitted and the node MUST move to **Removed** in the member list. Cancelling the prompt MUST make no contract call and leave state unchanged.
#### Scenario: Successful removal
- **WHEN** the owner kicks a member and confirms the prompt
- **THEN** `KickFromFamily` is triggered and the node moves to Removed in the member list
#### Scenario: Cancellation makes no change
- **WHEN** the owner opens the removal confirmation prompt and cancels
- **THEN** no contract call is made and the member remains Joined
### Requirement: Family owner can delete an empty family
_Design: ref `1861:794` (SECTION 3 · dissolve empty family · NYM-1215); canonical `2474:1305` (Dissolve)._
The wallet SHALL offer a delete-family option to the owner. Deletion (via `DisbandFamily`) SHALL be permitted only when the family has zero members and SHALL be behind a confirmation prompt. Attempting to delete a non-empty family MUST surface a clear error (`FamilyNotEmpty`) and MUST NOT remove the family.
#### Scenario: Successful deletion of an empty family
- **WHEN** the owner deletes a family with zero members and confirms the prompt
- **THEN** `DisbandFamily` is triggered, the family is removed, and the creation fee refund is reflected
#### Scenario: Deleting a non-empty family is blocked
- **WHEN** the owner attempts to delete a family that still has members
- **THEN** the wallet shows a clear `FamilyNotEmpty` error and the family is not removed
@@ -0,0 +1,72 @@
## 1. Types & request bindings
- [x] 1.1 Add TS types in `src/types` for `NodeFamily`, `FamilyMembership`, `FamilyInvitation`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation` (with status), `PastFamilyMember`, and contract `Config` (fee + limits)
- [x] 1.2 Add `src/requests/families.ts` Tauri IPC bindings for execute msgs: createFamily, updateFamily, disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily
- [x] 1.3 Add query bindings: getFamilyByOwner, getFamilyMembership, family members paged, pending invitations for family/node paged, past invitations for family paged, past members for family paged, config
- [x] 1.4 Export new requests from `src/requests/index.ts`
## 2. Context, hooks & query keys
- [x] 2.1 Create `src/context/families.tsx` (`FamiliesContext` + provider) exposing owner + operator operations, loading/error/refresh; wire into `src/context/index.tsx`
- [x] 2.2 Add `familyQueryKeys` module mirroring `delegationQueryKeys`
- [x] 2.3 Add TanStack Query read hooks: useFamilyByOwner, useFamilyConfig, useFamilyMembers, usePendingInvitationsForFamily, usePastInvitationsForFamily, usePastMembersForFamily, usePendingInvitationsForNode, useFamilyMembership
- [x] 2.4 Add a `useFamilyMemberList` aggregator hook combining the four section queries (Pending, Joined, Rejected, Removed) into one consumable shape for the UI; each section maps 1:1 to its underlying query (no cross-section deduplication, no priority cascade); Revoked past invitations are not surfaced in any section
- [x] 2.5 Add execute hooks/methods with optimistic refresh + error surfacing for all nine execute msgs
## 3. node-families-contract mock & fixtures (derived from `openspec/specs/node-families-contract/spec.md`)
- [x] 3.1 Create `src/context/mocks/families.fixtures.ts` with a `Config` (create_family_fee, name/description byte limits, default_invitation_validity_secs) and typed fixtures for `NodeFamily`, `FamilyMembership`, `FamilyInvitation`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation` (Accepted/Rejected/Revoked), `PastFamilyMember`
- [x] 3.2 Seed fixtures: a sample owned family; members across Joined and Removed (left + kicked); past invitations as Rejected and Revoked; pending invitations including at least one expired and one active
- [x] 3.3 Add a multi-node operator fixture: two controlled nodes with different invite states (active, expired, none)
- [x] 3.4 Create `src/context/mocks/families.tsx` mirroring the context with `mockSleep` latency and mutable in-memory state (follow the `mocks/bonding.tsx` convention; return `TxResultMock` from execute methods)
- [x] 3.5 Implement mock execute methods that mutate fixtures and honor contract invariants: createFamily, updateFamily (`updated_name`/`updated_description` `Option<String>`; None = unchanged), disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily, plus an `onNymNodeUnbond` test helper
- [x] 3.6 Implement mock query functions for every contract query: getFamilyById, getFamilyByName (normalised), getFamilyByOwner, getFamilyMembership, and all paginated queries with exclusive `start_after`, default limit 50, max 100, and `start_next_after`
- [x] 3.7 Enforce contract invariants in the mock: one family per owner, one family per node, monotonic non-recycled ids starting at 1, ASCII name normalisation + global uniqueness, byte-length limits, live `expired = now >= expires_at`, per-`(family, node)` archive counters from 0
- [x] 3.8 Model the contract error set as typed mock errors (InvalidFamilyCreationFee, FamilyNameAlreadyTaken/TooLong, EmptyFamilyName, SenderAlreadyOwnsAFamily, NodeAlreadyInFamily, NodeDoesntExist, PendingInvitationAlreadyExists, ZeroInvitationValidity, InvitationExpired/NotFound, FamilyNotEmpty, SenderDoesntControlNode, NodeNotMemberOfFamily) so warning/error UI states are reachable
- [x] 3.9 Have mock execute returns carry the spec's event names/attributes (family_creation, family_disband, family_invitation, family_invitation_revoked/accepted/rejected, family_member_left/kicked, family_node_unbond_cleanup)
## 4. Owner UI components (from Figma)
- [x] 4.1 Pull owner-side designs via Figma MCP and implement: CreateFamily form (name, description, fee display, balance/fee errors), with byte-limit validation + input sanitisation
- [x] 4.2 EditFamily form (name/description, byte limits, inline over-limit error): send only changed fields as `Some(value)` and unchanged ones as `None`; if nothing changed, do not submit
- [x] 4.3 InviteNode form (node ID input, validation) with confirmation and the three warning states (already-in-family, non-existent, duplicate pending)
- [x] 4.4 PendingInvites list with withdraw (active, confirmation) and clear-expired (confirmation) actions and `expired` badges
- [x] 4.5 MemberList grouped by Pending/Joined/Rejected/Removed with per-status empty states and refresh
- [x] 4.6 Kick action with confirmation prompt; DeleteFamily action (empty-only, confirmation, `FamilyNotEmpty` error)
## 5. Operator UI components (from Figma)
- [x] 5.1 Pull operator-side designs via Figma MCP and implement: per-node InviteCard (family name, inviting owner, expiry/TTL) with expired = non-actionable
- [x] 5.2 Multi-node grouping of invites
- [x] 5.3 Accept action (confirmation) and Reject action (confirmation)
- [x] 5.4 LeaveFamily action with confirmation
## 6. Family Tab & pages
- [x] 6.1 Add the Family tab, always visible for every wallet account (no eligibility gating)
- [x] 6.2 Owner management page composing components from section 4
- [x] 6.3 Operator invites page composing components from section 5
- [x] 6.4 Route between create entry point and management surface based on ownership
## 7. Storybook (three levels)
- [x] 7.1 Add a `withFamiliesMock` decorator backed by the mock provider
- [x] 7.2 Component stories with explicit state args (empty, loading, error, expired, over-limit, success) for every component in sections 4 & 5
- [x] 7.3 Page stories for the owner management page and operator invites page
- [x] 7.4 Flow stories with `@storybook/test` play functions: owner flow (create → invite → accept → kick → disband) and operator flow (receive → accept/reject → leave)
## 8. Tests
- [x] 8.1 Hook/integration tests (Jest) for every execute method and the status-derivation selector against the mock engine. NOTE: jest is `node` env, `*.test.ts` only (no jsdom/RTL render) — coverage is at the mock-engine + extracted pure `deriveMemberSections` selector level, which is the mock provider's logic. Files: `familiesMockState.test.ts`, `familyMemberSections.test.ts`, `Families/helpers.test.ts` (47 tests).
- [x] 8.2 Storybook interaction tests: assertive `play` functions on the flow stories (run by `@storybook/test-runner` via `pnpm test:storybook`). Requires `pnpm install` + browsers to execute.
- [x] 8.3 Added `@playwright/test` devDep + `playwright.config.ts` (serves Storybook on :6006 via `webServer`). Requires `pnpm install` + `npx playwright install chromium` to run.
- [x] 8.4 Playwright e2e specs `e2e/families.spec.ts` covering owner + operator flows (+ multi-node states) against the flow stories.
- [x] 8.5 Spec scenarios covered in `familiesMockState.test.ts` (create success/fee errors, over-limit + special-char normalisation, invite warnings, revoke active, expired-invite flag, kick, disband empty vs blocked, accept→Joined, reject→Rejected, leave→Removed + rejoin, multi-node) and `helpers.test.ts` (insufficient-balance pre-check, sanitisation). "kick + cancel" cancellation path is UI-only (no contract call) — exercised by the flow stories, not the engine.
## 9. Wiring & verification
- [x] 9.1 App route `/family` uses the real `FamiliesContextProvider` (via `pages/families/FamilyPageRoute.tsx``context/FamiliesContextProvider.tsx`); the mock provider is confined to Storybook/tests. The real provider is the ONLY families module importing `./main` (keeps Storybook free of Tauri-runtime code).
- [x] 9.2 Fee + limits are read from contract `Config` via `useFamilyConfig` (`create_family_fee`, `family_name_length_limit`, `family_description_length_limit`); no hardcoded 100 NYM / char counts. (The `?? 30/120` fallbacks are load-time display defaults only; the submitted fee is always `config.data.create_family_fee`.)
- [x] 9.3 `pnpm test` (85/85 green incl. 47 family) and `build-storybook` (succeeds) run clean; tsc + eslint clean. Playwright (`test:e2e`) and `test:storybook` are NOT run here — blocked on `pnpm install` of the new devDeps + `npx playwright install chromium` (no network/browsers in this env).
- [~] 9.4 **Realised by the `node-families-real-ipc` change.** The Rust Tauri command handlers (the missing layer this task waited on) are now implemented in `src-tauri/src/operations/families/` and registered in `main.rs`; the real `FamiliesContextProvider` is wired to them and `controlledNodeIds` derives from the bonded node. The remaining manual IPC smoke on a wired build (create + invite + accept against sandbox) is tracked there as §3.3 / §4 / §5 (needs the native app + sandbox + the funded test account).
- [x] 9.5 **RESOLVED:** `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` is confirmed in the `node-families-contract` source (`src/msg.rs`), matching the wallet's `requests/families.ts`, mock execute, and TS types. Verified in `node-families-real-ipc` §6.2.
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+30 -5
View File
@@ -19,11 +19,19 @@
"tauri:build:adhoc": "APPLE_SIGNING_IDENTITY=- tauri build -b app",
"tauri:dev": "tauri dev",
"tauri:buildx86": "tauri build --target x86_64-apple-darwin",
"tauri:build:mock": "WALLET_MOCK_FAMILIES=on run-s webpack:prod tauri:build:mock:bin",
"tauri:build:mock:bin": "tauri build --no-bundle --config src-tauri/tauri.mock.conf.json",
"test": "jest --config jest.config.cjs",
"tsc": "tsc --noEmit true",
"tsc:watch": "tsc --noEmit true --watch",
"webpack:dev": "webpack serve --config webpack.dev.js",
"webpack:prod": "webpack --progress --config webpack.prod.js"
"webpack:dev:mock": "WALLET_MOCK_FAMILIES=on webpack serve --config webpack.dev.js",
"webpack:prod": "webpack --progress --config webpack.prod.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test:e2e": "playwright test",
"test:e2e:tauri": "node e2e-tauri/run.mjs",
"test:storybook": "test-storybook"
},
"dependencies": {
"@babel/helper-simple-access": "catalog:",
@@ -41,21 +49,22 @@
"@emotion/use-insertion-effect-with-fallbacks": "catalog:",
"@emotion/utils": "catalog:",
"@emotion/weak-memoize": "catalog:",
"@fission-ai/openspec": "^1.3.1",
"@hookform/resolvers": "catalog:",
"@mui/base": "catalog:",
"@mui/icons-material": "catalog:",
"@mui/lab": "catalog:",
"@mui/material": "catalog:",
"@mui/private-theming": "catalog:",
"@mui/styles": "catalog:",
"@mui/system": "catalog:",
"@mui/icons-material": "catalog:",
"@mui/utils": "catalog:",
"@nymproject/react": "workspace:*",
"@nymproject/types": "workspace:*",
"@popperjs/core": "catalog:",
"@remix-run/router": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/query-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tauri-apps/api": "catalog:",
"@tauri-apps/plugin-clipboard-manager": "catalog:",
"@tauri-apps/plugin-opener": "catalog:",
@@ -69,8 +78,8 @@
"bn.js": "catalog:",
"bs58": "catalog:",
"buffer": "catalog:",
"clsx": "catalog:",
"clipboard-copy": "catalog:",
"clsx": "catalog:",
"colornames": "catalog:",
"d3-array": "catalog:",
"d3-color": "catalog:",
@@ -137,12 +146,26 @@
"@babel/preset-env": "catalog:",
"@babel/preset-react": "catalog:",
"@babel/preset-typescript": "catalog:",
"@nymproject/webpack": "workspace:*",
"@figma/code-connect": "^1.4.7",
"@nymproject/eslint-config-react-typescript": "workspace:*",
"@nymproject/webpack": "workspace:*",
"@playwright/test": "^1.49.1",
"@pmmmwh/react-refresh-webpack-plugin": "catalog:",
"@storybook/addon-a11y": "^10.4.1",
"@storybook/addon-docs": "^10.4.1",
"@storybook/addon-mcp": "^0.6.0",
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
"@storybook/react-webpack5": "^10.4.1",
"@storybook/test-runner": "^0.23.0",
"@svgr/webpack": "catalog:",
"@tauri-apps/cli": "catalog:",
"@testing-library/dom": "catalog:",
"@wdio/cli": "^9.12.0",
"@wdio/local-runner": "^9.12.0",
"@wdio/mocha-framework": "^9.12.0",
"@wdio/spec-reporter": "^9.12.0",
"webdriverio": "^9.12.0",
"tsx": "^4.19.2",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@types/big.js": "catalog:",
@@ -176,6 +199,7 @@
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "catalog:",
"eslint-plugin-react-hooks": "catalog:",
"eslint-plugin-storybook": "^10.4.1",
"favicons": "catalog:",
"favicons-webpack-plugin": "catalog:",
"file-loader": "catalog:",
@@ -187,6 +211,7 @@
"prettier": "catalog:",
"react-refresh": "catalog:",
"react-refresh-typescript": "catalog:",
"storybook": "^10.4.1",
"style-loader": "catalog:",
"thread-loader": "catalog:",
"ts-jest": "^29.4.9",
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Primary e2e config (design D1/D10). Drives the Family page inside the real app shell +
* router via the mock-wired dev server (`main.mock.html`, design D2) — a real browser
* session with no Tauri runtime or chain. Cross-platform, runs locally on macOS.
*
* Requires the workspace packages to be built (`pnpm --dir .. run build`) plus
* `npx playwright install chromium`. The webServer launches `webpack:dev:mock` on :9000
* with `WALLET_MOCK_FAMILIES=on` (reused if already running locally).
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'list',
// Build the static visual flow report (e2e-report/) from per-step screenshots.
globalSetup: './e2e/report.globalSetup.ts',
globalTeardown: './e2e/report.globalTeardown.ts',
timeout: 60_000,
use: {
baseURL: 'http://localhost:9000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run webpack:dev:mock',
// The mock entry's generated page — only exists when WALLET_MOCK_FAMILIES=on.
url: 'http://localhost:9000/main.mock.html',
reuseExistingServer: !process.env.CI,
timeout: 300_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
+1
View File
@@ -60,6 +60,7 @@ nym-validator-client = { path = "../../common/client-libs/validator-client" }
nym-crypto = { path = "../../common/crypto", features = ["asymmetric"] }
nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" }
nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract" }
nym-node-families-contract-common = { path = "../../common/cosmwasm-smart-contracts/node-families-contract" }
nym-vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract" }
nym-config = { path = "../../common/config" }
nym-types = { path = "../../common/types" }
@@ -0,0 +1,643 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Headless **sandbox** smoke for the node-families real-IPC layer
//! (node-families-real-ipc tasks 4.1 read smoke / 5.2-5.3 guarded writes).
//!
//! GUI automation of the real wallet can't run on macOS (tauri-driver is
//! Linux/Windows-only; Playwright can't drive the native webview), so this
//! exercises the exact `validator-client` calls the Tauri commands in
//! `operations/families/` wrap, against the contract deployed to sandbox
//! (address bundled in `nym-wallet-types/src/network/sandbox.rs`).
//!
//! The funded sandbox account mnemonic is read from `.env` at runtime
//! (`TAURI-WALLET-MNEMONIC`); it is **never** printed, logged, or written
//! anywhere. Run from the `nym-wallet/` directory so `.env` is found:
//!
//! # read-only smoke (safe, no state change) — task 4.1
//! cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke
//!
//! # + guarded write journey (create → rename → disband, with cleanup) — task 5.2
//! cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --write
//!
//! The write journey only touches a throwaway family this account creates and
//! disbands within the run; it refuses to start if the account already owns a
//! family (so it never clobbers pre-existing state).
use std::error::Error;
use std::str::FromStr;
use std::time::Duration;
use bip39::Mnemonic;
use nym_config::defaults::NymNetworkDetails;
use nym_mixnet_contract_common::NodeId;
use nym_node_families_contract_common::{Config as FamilyConfig, NodeFamilyId};
use nym_validator_client::nyxd::contract_traits::{
MixnetQueryClient, NodeFamiliesQueryClient, NodeFamiliesSigningClient, NymContractsProvider,
PagedNodeFamiliesQueryClient,
};
use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult;
use nym_validator_client::nyxd::{AccountId, Coin, CosmWasmClient};
use nym_wallet_types::network::Network as WalletNetwork;
type Smoke = Result<(), Box<dyn Error>>;
type Client = nym_validator_client::DirectSigningHttpRpcValidatorClient;
fn tx(label: &str, res: ExecuteResult) {
println!(" {label} → tx {}", res.transaction_hash);
}
/// Read a value for `key` from `.env` (handles hyphenated keys, which not every
/// dotenv loader exports into the process env) or fall back to the process env.
/// Secret values are returned for use but never printed by this harness.
fn read_env(key: &str) -> Option<String> {
[".env", "nym-wallet/.env", "../.env"]
.iter()
.find_map(|path| std::fs::read_to_string(path).ok())
.and_then(|contents| {
contents.lines().find_map(|line| {
let line = line.trim();
if line.starts_with('#') {
return None;
}
line.strip_prefix(&format!("{key}="))
.map(|v| v.trim().trim_matches(['"', '\'']).to_string())
})
})
.or_else(|| std::env::var(key).ok())
.filter(|v| !v.is_empty())
}
/// Load a mnemonic from the first of `keys` present in `.env`.
fn mnemonic_from(keys: &[&str]) -> Result<Mnemonic, Box<dyn Error>> {
let phrase = keys
.iter()
.find_map(|k| read_env(k))
.ok_or_else(|| format!("none of {keys:?} found in .env (run from the nym-wallet/ dir)"))?;
Ok(Mnemonic::from_str(phrase.trim())?)
}
fn build_client(mnemonic: Mnemonic) -> Result<Client, Box<dyn Error>> {
let network: NymNetworkDetails = WalletNetwork::SANDBOX.into();
let config = nym_validator_client::Config::try_from_nym_network_details(&network)?;
Ok(nym_validator_client::Client::new_signing(config, mnemonic)?)
}
/// The numeric node_id an account controls (nym-node, else legacy mixnode), if any.
async fn controlled_node(client: &Client) -> Result<Option<NodeId>, Box<dyn Error>> {
let me = client.nyxd.address();
if let Some(d) = client.nyxd.get_owned_nymnode(&me).await?.details {
return Ok(Some(d.bond_information.node_id));
}
if let Some(m) = client.nyxd.get_owned_mixnode(&me).await?.mixnode_details {
return Ok(Some(m.bond_information.mix_id));
}
Ok(None)
}
/// Print the on-chain family/node state of the owner + operator accounts so we
/// can pick the right write flow before mutating anything.
async fn accounts_state() -> Smoke {
let owner = build_client(mnemonic_from(&["FAMILY_OWNER_MNEMONIC"])?)?;
let operator = build_client(mnemonic_from(&["ACCOUNT_WITH_BONDED_NODE_MNEMONIC"])?)?;
let o_addr = owner.nyxd.address();
let o_family = owner.nyxd.get_family_by_owner(&o_addr).await?.family;
println!("\n=== ACCOUNTS STATE ===");
println!("FAMILY_OWNER = {o_addr}");
match &o_family {
Some(f) => println!(
" owns family id={} name={:?} members={}",
f.id, f.name, f.members
),
None => println!(" owns no family"),
}
let p_addr = operator.nyxd.address();
let node = controlled_node(&operator).await?;
println!("ACCOUNT_WITH_BONDED_NODE = {p_addr}");
match node {
Some(id) => {
let membership = operator.nyxd.get_family_membership(id).await?.family_id;
println!(" controls node_id={id}, current family membership = {membership:?}");
}
None => println!(" controls no node"),
}
Ok(())
}
/// Read the contract `Config` straight from raw state (the same path
/// `get_family_config` uses — there is no `GetConfig` smart query).
async fn read_config(
client: &nym_validator_client::DirectSigningHttpRpcValidatorClient,
) -> Result<FamilyConfig, Box<dyn Error>> {
let contract = client
.nyxd
.node_families_contract_address()
.ok_or("node_families_contract_address is not set for SANDBOX")?
.clone();
let raw = client
.nyxd
.query_contract_raw(&contract, b"config".to_vec())
.await?;
Ok(serde_json::from_slice(&raw)?)
}
async fn read_smoke(client: &nym_validator_client::DirectSigningHttpRpcValidatorClient) -> Smoke {
println!("\n=== READ SMOKE (task 4.1) ===");
let config = read_config(client).await?;
println!(
"config: create_family_fee={} {}, name_limit={}, desc_limit={}, default_invite_validity={}s",
config.create_family_fee.amount,
config.create_family_fee.denom,
config.family_name_length_limit,
config.family_description_length_limit,
config.default_invitation_validity_secs,
);
let families = client.nyxd.get_all_families().await?;
println!(
"get_all_families → {} family/families on sandbox",
families.len()
);
for f in &families {
println!(
" • id={} name={:?} owner={} members={} created_at={}",
f.id, f.name, f.owner, f.members, f.created_at
);
let members = client.nyxd.get_all_family_members_for_family(f.id).await?;
for m in &members {
println!(
" member node_id={} joined_at={}",
m.node_id, m.membership.joined_at
);
}
let pending = client
.nyxd
.get_all_pending_invitations_for_family(f.id)
.await?;
for p in &pending {
println!(
" pending invite node_id={} expires_at={} expired={}",
p.invitation.node_id, p.invitation.expires_at, p.expired
);
}
}
let me = client.nyxd.address();
let owned = client.nyxd.get_family_by_owner(&me).await?;
match owned.family {
Some(f) => println!("this account owns family id={} ({:?})", f.id, f.name),
None => println!("this account does not currently own a family"),
}
println!("read smoke OK ✅");
Ok(())
}
async fn write_journey(client: &Client) -> Smoke {
println!("\n=== GUARDED WRITE JOURNEY (tasks 5.2 + 5.3: full owner + operator lifecycle) ===");
let me = client.nyxd.address();
// Guard: never clobber a pre-existing family owned by this account.
if let Some(existing) = client.nyxd.get_family_by_owner(&me).await?.family {
return Err(format!(
"account already owns family id={} ({:?}); refusing to run the write journey — \
disband it manually first if this is the throwaway test account",
existing.id, existing.name
)
.into());
}
// Resolve the numeric node_id this account controls. The account acts as
// BOTH family owner (invite/kick/revoke/disband) and node operator
// (accept/reject/leave), so a single funded account drives the whole
// lifecycle — covering all 9 execute commands.
// The member-management commands (invite/accept/reject/kick/revoke/leave)
// need a numeric node_id this account controls; the account is both family
// owner and node operator, so one account can drive the whole lifecycle.
// If no node is bonded we still run the owner-only subset (create/update/
// disband) and skip the member steps with a clear notice.
let nym_node = client.nyxd.get_owned_nymnode(&me).await?.details;
let legacy_mixnode = client.nyxd.get_owned_mixnode(&me).await?.mixnode_details;
let controlled: Option<NodeId> = match (&nym_node, &legacy_mixnode) {
(Some(d), _) => Some(d.bond_information.node_id),
(_, Some(m)) => Some(m.bond_information.mix_id),
(None, None) => None,
};
match controlled {
Some(id) => println!(
"controlled node_id = {id} ({})",
if nym_node.is_some() {
"nym-node"
} else {
"legacy mixnode"
}
),
None => println!(
"⚠️ account controls no node on the sandbox mixnet contract — running the owner-only \
subset (create/update/disband); skipping invite/accept/reject/kick/revoke/leave"
),
}
let config = read_config(client).await?;
let creation_fee: Vec<Coin> = vec![config.create_family_fee.into()];
println!("\n[1] create_family attaching {} ...", creation_fee[0]);
tx(
"create_family",
client
.nyxd
.create_family(
"smoke-test-family".to_string(),
"throwaway family created by sandbox_families_smoke".to_string(),
None,
creation_fee,
)
.await?,
);
let fid = poll_for_owned_family(client, &me).await?.id;
println!(" → family id={fid}");
println!("\n[2] update_family (rename) ...");
tx(
"update_family",
client
.nyxd
.update_family(Some("smoke-test-renamed".to_string()), None, None)
.await?,
);
if let Some(node_id) = controlled {
println!("\n[3] invite_to_family → revoke_family_invitation (owner revokes a pending invite) ...");
tx(
"invite_to_family",
client.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(client, fid, node_id, true).await?;
tx(
"revoke_family_invitation",
client.nyxd.revoke_family_invitation(node_id, None).await?,
);
poll_pending(client, fid, node_id, false).await?;
println!("\n[4] invite_to_family → reject_family_invitation (operator rejects) ...");
tx(
"invite_to_family",
client.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(client, fid, node_id, true).await?;
tx(
"reject_family_invitation",
client
.nyxd
.reject_family_invitation(fid, node_id, None)
.await?,
);
poll_pending(client, fid, node_id, false).await?;
println!("\n[5] invite → accept_family_invitation → leave_family (operator joins then leaves) ...");
tx(
"invite_to_family",
client.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(client, fid, node_id, true).await?;
tx(
"accept_family_invitation",
client
.nyxd
.accept_family_invitation(fid, node_id, None)
.await?,
);
poll_membership(client, node_id, Some(fid)).await?;
tx(
"leave_family",
client.nyxd.leave_family(node_id, None).await?,
);
poll_membership(client, node_id, None).await?;
println!("\n[6] invite → accept → kick_from_family (owner kicks the member) ...");
tx(
"invite_to_family",
client.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(client, fid, node_id, true).await?;
tx(
"accept_family_invitation",
client
.nyxd
.accept_family_invitation(fid, node_id, None)
.await?,
);
poll_membership(client, node_id, Some(fid)).await?;
tx(
"kick_from_family",
client.nyxd.kick_from_family(node_id, None).await?,
);
poll_membership(client, node_id, None).await?;
}
println!("\n[7] disband_family (cleanup) ...");
tx("disband_family", client.nyxd.disband_family(None).await?);
poll_until_no_owned_family(client, &me).await?;
if controlled.is_some() {
println!("\nwrite journey OK ✅ — all 9 execute commands exercised; state cleaned up");
} else {
println!(
"\nwrite journey OK ✅ — owner subset (create/update/disband) exercised; \
state cleaned up. Bond a node to this account to also cover the 6 member commands"
);
}
Ok(())
}
/// On-chain state isn't readable until the block commits; poll briefly.
async fn poll_for_owned_family(
client: &Client,
owner: &AccountId,
) -> Result<nym_node_families_contract_common::NodeFamily, Box<dyn Error>> {
for _ in 0..10 {
if let Some(f) = client.nyxd.get_family_by_owner(owner).await?.family {
return Ok(f);
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err("timed out waiting for the created family to appear on chain".into())
}
async fn poll_until_no_owned_family(client: &Client, owner: &AccountId) -> Smoke {
for _ in 0..10 {
if client
.nyxd
.get_family_by_owner(owner)
.await?
.family
.is_none()
{
return Ok(());
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err("timed out waiting for the family to be disbanded".into())
}
/// Wait until a pending invitation for `(fid, node_id)` is present/absent.
async fn poll_pending(client: &Client, fid: NodeFamilyId, node_id: NodeId, want: bool) -> Smoke {
for _ in 0..10 {
let present = client
.nyxd
.get_pending_invitation(fid, node_id)
.await?
.invitation
.is_some();
if present == want {
return Ok(());
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err(format!("timed out waiting for pending invitation present={want}").into())
}
/// Wait until `node_id`'s membership equals `want` (`Some(fid)` joined / `None` not a member).
async fn poll_membership(client: &Client, node_id: NodeId, want: Option<NodeFamilyId>) -> Smoke {
for _ in 0..10 {
if client.nyxd.get_family_membership(node_id).await?.family_id == want {
return Ok(());
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err(format!("timed out waiting for membership = {want:?}").into())
}
/// Two-account member/operator journey (task 5.3) against the owner's EXISTING
/// family — owner = `FAMILY_OWNER` (invite/revoke/kick), operator =
/// `ACCOUNT_WITH_BONDED_NODE` (accept/reject/leave). Exercises all 6 member
/// commands and restores the node's original membership at the end, so a
/// real pre-existing family is left exactly as it was found.
async fn member_journey(owner: &Client, operator: &Client) -> Smoke {
println!("\n=== MEMBER / OPERATOR JOURNEY (task 5.3) ===");
let owner_addr = owner.nyxd.address();
let fid = owner
.nyxd
.get_family_by_owner(&owner_addr)
.await?
.family
.ok_or("FAMILY_OWNER owns no family to run the member journey against")?
.id;
let node_id = controlled_node(operator)
.await?
.ok_or("ACCOUNT_WITH_BONDED_NODE controls no node")?;
let initial = operator
.nyxd
.get_family_membership(node_id)
.await?
.family_id;
println!("owner={owner_addr}\noperator node_id={node_id}, family={fid}, initial membership={initial:?} (restored at end)");
// Baseline: clear any stray pending invite, then make node a clean member of `fid`.
if owner
.nyxd
.get_pending_invitation(fid, node_id)
.await?
.invitation
.is_some()
{
tx(
"revoke (baseline)",
owner.nyxd.revoke_family_invitation(node_id, None).await?,
);
poll_pending(owner, fid, node_id, false).await?;
}
if operator
.nyxd
.get_family_membership(node_id)
.await?
.family_id
!= Some(fid)
{
tx(
"invite (baseline)",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"accept (baseline)",
operator
.nyxd
.accept_family_invitation(fid, node_id, None)
.await?,
);
poll_membership(operator, node_id, Some(fid)).await?;
}
println!("\n[a] kick_from_family (owner removes the member) ...");
tx(
"kick_from_family",
owner.nyxd.kick_from_family(node_id, None).await?,
);
poll_membership(operator, node_id, None).await?;
println!("\n[b] invite_to_family → reject_family_invitation (operator rejects) ...");
tx(
"invite_to_family",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"reject_family_invitation",
operator
.nyxd
.reject_family_invitation(fid, node_id, None)
.await?,
);
poll_pending(owner, fid, node_id, false).await?;
println!("\n[c] invite_to_family → revoke_family_invitation (owner revokes) ...");
tx(
"invite_to_family",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"revoke_family_invitation",
owner.nyxd.revoke_family_invitation(node_id, None).await?,
);
poll_pending(owner, fid, node_id, false).await?;
println!(
"\n[d] invite → accept_family_invitation → leave_family (operator joins then leaves) ..."
);
tx(
"invite_to_family",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"accept_family_invitation",
operator
.nyxd
.accept_family_invitation(fid, node_id, None)
.await?,
);
poll_membership(operator, node_id, Some(fid)).await?;
tx(
"leave_family",
operator.nyxd.leave_family(node_id, None).await?,
);
poll_membership(operator, node_id, None).await?;
// Restore the node's original membership so a real family is left unchanged.
println!("\n[restore] returning node {node_id} to its initial membership {initial:?} ...");
match initial {
Some(f) if f == fid => {
tx("invite_to_family", owner.nyxd.invite_to_family(node_id, None, None).await?);
poll_pending(owner, fid, node_id, true).await?;
tx("accept_family_invitation", operator.nyxd.accept_family_invitation(fid, node_id, None).await?);
poll_membership(operator, node_id, Some(fid)).await?;
}
None => poll_membership(operator, node_id, None).await?,
Some(other) => println!(
" ⚠️ node was originally in family {other} (not {fid}); leaving it free — re-add manually if needed"
),
}
println!("\nmember journey OK ✅ — all 6 member commands exercised; node membership restored");
Ok(())
}
/// Read-only: report which (if any) node an arbitrary account controls on the
/// sandbox mixnet contract. Signing identity is irrelevant — these are queries.
async fn bond_check(client: &Client, addr: &str) -> Smoke {
let account = AccountId::from_str(addr)?;
let nym_node = client.nyxd.get_owned_nymnode(&account).await?.details;
let legacy = client
.nyxd
.get_owned_mixnode(&account)
.await?
.mixnode_details;
let gateway = client.nyxd.get_owned_gateway(&account).await?.gateway;
println!("\n=== BOND CHECK: {addr} ===");
println!(
"mixnet contract: {}",
client
.nyxd
.mixnet_contract_address()
.map(|a| a.to_string())
.unwrap_or_default()
);
match (&nym_node, &legacy) {
(Some(d), _) => println!(
"✅ controls nym-node → node_id = {}",
d.bond_information.node_id
),
(_, Some(m)) => println!(
"✅ controls legacy mixnode → node_id (mix_id) = {}",
m.bond_information.mix_id
),
(None, None) => println!("❌ controls no nym-node and no mixnode"),
}
println!(
" gateway: {}",
if gateway.is_some() {
"yes (gateways can't be family members)"
} else {
"none"
}
);
Ok(())
}
#[tokio::main]
async fn main() -> Smoke {
let args: Vec<String> = std::env::args().collect();
let do_write = args.iter().any(|a| a == "--write");
let do_accounts = args.iter().any(|a| a == "--accounts");
let do_member = args.iter().any(|a| a == "--member");
let bond_check_addr = args
.iter()
.position(|a| a == "--bond-check")
.and_then(|i| args.get(i + 1))
.cloned();
// These only need the owner/operator keys, not the primary account.
if do_accounts {
return accounts_state().await;
}
if do_member {
let owner = build_client(mnemonic_from(&["FAMILY_OWNER_MNEMONIC"])?)?;
let operator = build_client(mnemonic_from(&["ACCOUNT_WITH_BONDED_NODE_MNEMONIC"])?)?;
return member_journey(&owner, &operator).await;
}
let client = build_client(mnemonic_from(&[
"TAURI-WALLET-MNEMONIC",
"TAURI_WALLET_MNEMONIC",
])?)?;
println!("connected to SANDBOX as {}", client.nyxd.address());
println!(
"node_families_contract_address = {:?}",
client.nyxd.node_families_contract_address()
);
if let Some(addr) = bond_check_addr {
bond_check(&client, &addr).await?;
return Ok(());
}
read_smoke(&client).await?;
if do_write {
write_journey(&client).await?;
} else {
println!("\n(skipping write journey — pass `-- --write` to run create → rename → disband)");
}
println!("\nDone.");
Ok(())
}
+19
View File
@@ -11,6 +11,7 @@ use tauri_plugin_updater::Builder as UpdaterBuilder;
use crate::menu::SHOW_LOG_WINDOW;
use crate::operations::app;
use crate::operations::families;
use crate::operations::help;
use crate::operations::mixnet;
use crate::operations::nym_api;
@@ -109,6 +110,24 @@ fn main() {
mixnet::rewards::get_current_rewarding_parameters,
mixnet::send::send,
mixnet::bond::get_mixnode_uptime,
families::execute::create_family,
families::execute::update_family,
families::execute::disband_family,
families::execute::invite_to_family,
families::execute::revoke_family_invitation,
families::execute::kick_from_family,
families::execute::accept_family_invitation,
families::execute::reject_family_invitation,
families::execute::leave_family,
families::query::get_family_by_id,
families::query::get_family_by_owner,
families::query::get_family_membership,
families::query::get_family_config,
families::query::get_family_members_paged,
families::query::get_pending_invitations_for_family_paged,
families::query::get_pending_invitations_for_node_paged,
families::query::get_past_invitations_for_family_paged,
families::query::get_past_members_for_family_paged,
network_config::add_validator,
network_config::get_nym_api_urls,
network_config::get_nyxd_urls,
@@ -0,0 +1,171 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! State-changing node-families commands over [`NodeFamiliesSigningClient`].
//!
//! Every command uses the wallet's standard auto/simulated gas fee convention
//! (design D7): the frontend bindings don't supply an explicit gas `Fee`, so we
//! pass `None` and let the client simulate. `create_family` additionally attaches
//! the configured `create_family_fee` (a display [`DecCoin`] from
//! `get_family_config`) as funds, converted back to its base denomination.
//!
//! The returned [`TransactionExecuteResult`] is a subset of the frontend's
//! `FamilyTxResult` (which adds an optional `family_events`). Per design D2 we
//! omit `family_events` — the provider re-derives state via `refreshAll()` after
//! every execute, and nothing reads the fabricated events.
use crate::error::BackendError;
use crate::state::WalletState;
use nym_mixnet_contract_common::NodeId;
use nym_node_families_contract_common::NodeFamilyId;
use nym_types::currency::DecCoin;
use nym_types::transaction::TransactionExecuteResult;
use nym_validator_client::nyxd::contract_traits::NodeFamiliesSigningClient;
#[tauri::command]
pub async fn create_family(
name: String,
description: String,
fee: DecCoin,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Create family: name = {name}, creation_fee = {fee}");
let guard = state.read().await;
// `fee` here is the contract's `create_family_fee` (attached as funds), not a gas fee.
let creation_fee = vec![guard.attempt_convert_to_base_coin(fee)?];
let res = guard
.current_client()?
.nyxd
.create_family(name, description, None, creation_fee)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
log::trace!("<<< {res:?}");
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn update_family(
updated_name: Option<String>,
updated_description: Option<String>,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Update family: name = {updated_name:?}, description = {updated_description:?}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.update_family(updated_name, updated_description, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn disband_family(
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Disband family");
let guard = state.read().await;
let res = guard.current_client()?.nyxd.disband_family(None).await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn invite_to_family(
node_id: NodeId,
validity_secs: Option<u64>,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Invite to family: node_id = {node_id}, validity_secs = {validity_secs:?}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.invite_to_family(node_id, validity_secs, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn revoke_family_invitation(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Revoke family invitation: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.revoke_family_invitation(node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn kick_from_family(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Kick from family: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.kick_from_family(node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn accept_family_invitation(
family_id: NodeFamilyId,
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Accept family invitation: family_id = {family_id}, node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.accept_family_invitation(family_id, node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn reject_family_invitation(
family_id: NodeFamilyId,
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Reject family invitation: family_id = {family_id}, node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.reject_family_invitation(family_id, node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn leave_family(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Leave family: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.leave_family(node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
@@ -0,0 +1,15 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Tauri command layer for the `node-families-contract`.
//!
//! Mirrors `operations/mixnet/`: each `#[tauri::command]` acquires the active
//! account's signing client from the wallet [`WalletState`](crate::state::WalletState)
//! and delegates to the existing `validator-client` `NodeFamilies{Signing,Query}Client`
//! traits, returning the shapes the `src/requests/families.ts` bindings expect.
//!
//! Split into `execute` (state-changing txs) and `query` (read-only) to match the
//! rest of the wallet (design D1).
pub mod execute;
pub mod query;
@@ -0,0 +1,319 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Read-only node-families commands over [`NodeFamiliesQueryClient`].
//!
//! The contract's paged responses each carry an echoed key (`family_id` /
//! `node_id`) plus a named items field (`members` / `invitations`) and a
//! `start_next_after` cursor. The frontend bindings expect a uniform
//! `{ items, start_next_after }` envelope ([`FamilyPagedResponse`]), so each
//! command flattens the contract response into it. Per the `src/types/families.ts`
//! contract — "the request layer is responsible for translating the contract's
//! serde envelope into these shapes" — we also normalise the cw_serde tagged
//! `FamilyInvitationStatus` enum into the wallet's `{ kind, at }` union.
use crate::error::BackendError;
use crate::state::WalletState;
use crate::state::WalletStateInner;
use nym_mixnet_contract_common::NodeId;
use nym_node_families_contract_common::{
Config, FamilyInvitation, FamilyInvitationStatus, NodeFamily, NodeFamilyId,
NodeFamilyMembershipResponse, PastFamilyInvitation, PastFamilyInvitationCursor,
PastFamilyMember, PastFamilyMemberCursor, PendingFamilyInvitationDetails,
};
use nym_types::currency::DecCoin;
use nym_validator_client::nyxd::contract_traits::{NodeFamiliesQueryClient, NymContractsProvider};
use nym_validator_client::nyxd::error::NyxdError;
use nym_validator_client::nyxd::{AccountId, Coin, CosmWasmClient};
use serde::Serialize;
use std::str::FromStr;
/// Uniform `{ items, start_next_after }` envelope the frontend's
/// `FamilyPagedResponse<T>` expects. `C` is the (page-specific) cursor type:
/// a `NodeId`/`NodeFamilyId` for single-key pages or a `(node_id, counter)`
/// tuple for the archived listings.
#[derive(Serialize)]
pub struct FamilyPagedResponse<T, C> {
pub items: Vec<T>,
pub start_next_after: Option<C>,
}
/// One current-member row: `{ node_id, joined_at }` (drops the contract's
/// nested `membership` envelope the UI doesn't use here).
#[derive(Serialize)]
pub struct FamilyMemberItem {
pub node_id: NodeId,
pub joined_at: u64,
}
/// Wallet-friendly `{ kind, at }` form of the contract's tagged
/// `FamilyInvitationStatus` enum.
#[derive(Serialize)]
pub struct PastInvitationStatus {
pub kind: String,
pub at: u64,
}
/// `PastFamilyInvitation` with its status normalised for the frontend.
#[derive(Serialize)]
pub struct PastFamilyInvitationItem {
pub invitation: FamilyInvitation,
pub status: PastInvitationStatus,
}
/// Frontend `NodeFamily`: the contract's `paid_fee` (stored in the base
/// denomination) is converted to a display [`DecCoin`] and `owner` to a plain
/// string, so the UI can `formatCoin(paid_fee)` directly.
#[derive(Serialize)]
pub struct NodeFamilyView {
pub id: NodeFamilyId,
pub name: String,
pub description: String,
pub normalised_name: String,
pub members: u64,
pub created_at: u64,
pub paid_fee: DecCoin,
pub owner: String,
}
/// Frontend `FamilyConfig`: `create_family_fee` returned as a display
/// [`DecCoin`] (converted from the contract's base-denom `Coin`) so the UI can
/// round-trip it straight back into `create_family`.
#[derive(Serialize)]
pub struct FamilyConfigResponse {
pub create_family_fee: DecCoin,
pub family_name_length_limit: usize,
pub family_description_length_limit: usize,
pub default_invitation_validity_secs: u64,
}
fn normalise_status(status: FamilyInvitationStatus) -> PastInvitationStatus {
let (kind, at) = match status {
FamilyInvitationStatus::Pending { at } => ("Pending", at),
FamilyInvitationStatus::Accepted { at } => ("Accepted", at),
FamilyInvitationStatus::Rejected { at } => ("Rejected", at),
FamilyInvitationStatus::Revoked { at } => ("Revoked", at),
};
PastInvitationStatus {
kind: kind.to_string(),
at,
}
}
fn map_past_invitation(past: PastFamilyInvitation) -> PastFamilyInvitationItem {
PastFamilyInvitationItem {
invitation: past.invitation,
status: normalise_status(past.status),
}
}
fn map_family(
state: &WalletStateInner,
family: NodeFamily,
) -> Result<NodeFamilyView, BackendError> {
Ok(NodeFamilyView {
id: family.id,
name: family.name,
description: family.description,
normalised_name: family.normalised_name,
members: family.members,
created_at: family.created_at,
paid_fee: state.attempt_convert_to_display_dec_coin(Coin::from(family.paid_fee))?,
owner: family.owner.to_string(),
})
}
// --- Single-entity queries --------------------------------------------------
#[tauri::command]
pub async fn get_family_by_id(
family_id: NodeFamilyId,
state: tauri::State<'_, WalletState>,
) -> Result<Option<NodeFamilyView>, BackendError> {
log::trace!(">>> Get family by id: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_by_id(family_id)
.await?;
res.family.map(|f| map_family(&guard, f)).transpose()
}
#[tauri::command]
pub async fn get_family_by_owner(
owner: String,
state: tauri::State<'_, WalletState>,
) -> Result<Option<NodeFamilyView>, BackendError> {
log::trace!(">>> Get family by owner: owner = {owner}");
let owner_addr = AccountId::from_str(&owner)?;
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_by_owner(&owner_addr)
.await?;
res.family.map(|f| map_family(&guard, f)).transpose()
}
#[tauri::command]
pub async fn get_family_membership(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<NodeFamilyMembershipResponse, BackendError> {
log::trace!(">>> Get family membership: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_membership(node_id)
.await?;
Ok(res)
}
/// The contract exposes no `GetConfig` smart query (design D5), so we read the
/// `Config` `Item` from raw contract state at its storage key (`"config"`).
/// `create_family_fee` is stored in the base denomination; we convert it to a
/// display `DecCoin` for the UI.
#[tauri::command]
pub async fn get_family_config(
state: tauri::State<'_, WalletState>,
) -> Result<FamilyConfigResponse, BackendError> {
log::trace!(">>> Get family config");
let guard = state.read().await;
let client = guard.current_client()?;
let contract = client
.nyxd
.node_families_contract_address()
.ok_or_else(|| NyxdError::unavailable_contract_address("node families contract"))?
.clone();
let raw = client
.nyxd
.query_contract_raw(&contract, b"config".to_vec())
.await?;
let config: Config = serde_json::from_slice(&raw)?;
let create_family_fee =
guard.attempt_convert_to_display_dec_coin(Coin::from(config.create_family_fee))?;
Ok(FamilyConfigResponse {
create_family_fee,
family_name_length_limit: config.family_name_length_limit,
family_description_length_limit: config.family_description_length_limit,
default_invitation_validity_secs: config.default_invitation_validity_secs,
})
}
// --- Paginated queries ------------------------------------------------------
#[tauri::command]
pub async fn get_family_members_paged(
family_id: NodeFamilyId,
start_after: Option<NodeId>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<FamilyMemberItem, NodeId>, BackendError> {
log::trace!(">>> Get family members paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_members_paged(family_id, start_after, limit)
.await?;
let items = res
.members
.into_iter()
.map(|m| FamilyMemberItem {
node_id: m.node_id,
joined_at: m.membership.joined_at,
})
.collect();
Ok(FamilyPagedResponse {
items,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_pending_invitations_for_family_paged(
family_id: NodeFamilyId,
start_after: Option<NodeId>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PendingFamilyInvitationDetails, NodeId>, BackendError> {
log::trace!(">>> Get pending invitations for family paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_pending_invitations_for_family_paged(family_id, start_after, limit)
.await?;
Ok(FamilyPagedResponse {
items: res.invitations,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_pending_invitations_for_node_paged(
node_id: NodeId,
start_after: Option<NodeFamilyId>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PendingFamilyInvitationDetails, NodeFamilyId>, BackendError> {
log::trace!(">>> Get pending invitations for node paged: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_pending_invitations_for_node_paged(node_id, start_after, limit)
.await?;
Ok(FamilyPagedResponse {
items: res.invitations,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_past_invitations_for_family_paged(
family_id: NodeFamilyId,
start_after: Option<PastFamilyInvitationCursor>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PastFamilyInvitationItem, PastFamilyInvitationCursor>, BackendError>
{
log::trace!(">>> Get past invitations for family paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_past_invitations_for_family_paged(family_id, start_after, limit)
.await?;
let items = res
.invitations
.into_iter()
.map(map_past_invitation)
.collect();
Ok(FamilyPagedResponse {
items,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_past_members_for_family_paged(
family_id: NodeFamilyId,
start_after: Option<PastFamilyMemberCursor>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PastFamilyMember, PastFamilyMemberCursor>, BackendError> {
log::trace!(">>> Get past members for family paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_past_members_for_family_paged(family_id, start_after, limit)
.await?;
Ok(FamilyPagedResponse {
items: res.members,
start_next_after: res.start_next_after,
})
}
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
pub mod app;
pub mod families;
pub mod help;
pub(crate) mod helpers;
pub mod mixnet;
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"label": "main",
"title": "Nym Wallet (mock e2e)",
"url": "main.mock.html",
"width": 1268,
"height": 768,
"minWidth": 1024,
"minHeight": 640,
"resizable": true,
"useHttpsScheme": false,
"backgroundColor": "#242b2d"
}
]
}
}
@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { Button, ButtonProps, Stack } from '@mui/material';
import { ConfirmationModal } from '../Modals/ConfirmationModal';
export interface ConfirmActionButtonProps {
label: React.ReactNode;
title: string;
body?: React.ReactNode;
confirmLabel: string;
onConfirm: () => void;
disabled?: boolean;
color?: ButtonProps['color'];
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
dataTestid?: string;
}
/** A button that gates its action behind a confirmation prompt (used across all gated family actions). */
export const ConfirmActionButton = ({
label,
title,
body,
confirmLabel,
onConfirm,
disabled,
color = 'primary',
variant = 'outlined',
size = 'small',
dataTestid,
}: ConfirmActionButtonProps) => {
const [open, setOpen] = useState(false);
const handleConfirm = () => {
setOpen(false);
onConfirm();
};
return (
<>
<Button
variant={variant}
color={color}
size={size}
disabled={disabled}
onClick={() => setOpen(true)}
data-testid={dataTestid}
>
{label}
</Button>
<ConfirmationModal
open={open}
title={title}
confirmButton={
<Stack direction="row" spacing={2} width="100%">
<Button fullWidth variant="outlined" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
fullWidth
variant="contained"
color={color}
onClick={handleConfirm}
data-testid={dataTestid ? `${dataTestid}-confirm` : undefined}
>
{confirmLabel}
</Button>
</Stack>
}
onConfirm={handleConfirm}
onClose={() => setOpen(false)}
>
{body}
</ConfirmationModal>
</>
);
};
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { CreateFamilyForm } from './CreateFamilyForm';
const meta: Meta<typeof CreateFamilyForm> = {
title: 'Families/Components/CreateFamilyForm',
component: CreateFamilyForm,
args: {
fee: { denom: 'nym', amount: '100' },
nameLimit: 30,
descriptionLimit: 120,
onSubmit: () => undefined,
},
};
export default meta;
type Story = StoryObj<typeof CreateFamilyForm>;
export const Default: Story = {};
export const InsufficientBalance: Story = { args: { balance: { denom: 'nym', amount: '5' } } };
export const Submitting: Story = { args: { isSubmitting: true } };
export const ContractError: Story = { args: { errorMessage: 'The attached creation fee is incorrect.' } };
@@ -0,0 +1,101 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React, { useState } from 'react';
import { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material';
import { DecCoin } from '@nymproject/types';
import { NymCard } from '../NymCard';
import { byteLength, formatCoin, isInsufficientBalance, sanitizeInput } from './helpers';
export interface CreateFamilyFormProps {
/** Creation fee read from chain config (never hardcoded). */
fee: DecCoin;
nameLimit: number;
descriptionLimit: number;
/** Connected account balance, used to pre-check insufficient funds. */
balance?: DecCoin;
isSubmitting?: boolean;
/** Contract/fee error surfaced after a failed submit. */
errorMessage?: string;
onSubmit: (name: string, description: string) => void;
}
export const CreateFamilyForm = ({
fee,
nameLimit,
descriptionLimit,
balance,
isSubmitting,
errorMessage,
onSubmit,
}: CreateFamilyFormProps) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const nameBytes = byteLength(name);
const descBytes = byteLength(description);
const nameTooLong = nameBytes > nameLimit;
const descTooLong = descBytes > descriptionLimit;
const insufficient = isInsufficientBalance(balance, fee);
const canSubmit = name.trim().length > 0 && !nameTooLong && !descTooLong && !insufficient && !isSubmitting;
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit(sanitizeInput(name), sanitizeInput(description));
};
return (
<NymCard title="Create a family" data-testid="create-family-form">
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
Group your node with others under a family wallet. Creating a family requires a refundable fee of{' '}
<strong>{formatCoin(fee)}</strong>, returned in full when the family is dissolved.
</Typography>
<TextField
label="Family name"
value={name}
onChange={(e) => setName(e.target.value)}
error={nameTooLong}
helperText={
nameTooLong ? `Name is ${nameBytes}/${nameLimit} bytes — too long` : `${nameBytes}/${nameLimit} bytes`
}
fullWidth
inputProps={{ 'data-testid': 'create-family-name' }}
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
error={descTooLong}
helperText={
descTooLong
? `Description is ${descBytes}/${descriptionLimit} bytes — too long`
: `${descBytes}/${descriptionLimit} bytes`
}
fullWidth
multiline
minRows={2}
inputProps={{ 'data-testid': 'create-family-description' }}
/>
{insufficient && (
<Alert severity="error" data-testid="create-family-insufficient">
Insufficient balance you need at least {formatCoin(fee)} plus gas to create a family.
</Alert>
)}
{errorMessage && (
<Alert severity="error" data-testid="create-family-error">
{errorMessage}
</Alert>
)}
<Box>
<Button variant="contained" disabled={!canSubmit} onClick={handleSubmit} data-testid="create-family-submit">
{isSubmitting ? 'Creating…' : `Create family · ${formatCoin(fee)}`}
</Button>
</Box>
</Stack>
</NymCard>
);
};
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { DeleteFamilyButton } from './DeleteFamilyButton';
const meta: Meta<typeof DeleteFamilyButton> = {
title: 'Families/Components/DeleteFamilyButton',
component: DeleteFamilyButton,
args: { memberCount: 0, onDelete: () => undefined },
};
export default meta;
type Story = StoryObj<typeof DeleteFamilyButton>;
export const Deletable: Story = {};
export const BlockedNonEmpty: Story = { args: { memberCount: 3 } };
export const WithError: Story = { args: { errorMessage: 'The family must be empty before it can be deleted.' } };
@@ -0,0 +1,44 @@
import React from 'react';
import { Alert, Stack, Typography } from '@mui/material';
import { ConfirmActionButton } from './ConfirmActionButton';
export interface DeleteFamilyButtonProps {
/** Current member count; deletion is only allowed at zero. */
memberCount: number;
isBusy?: boolean;
/** `FamilyNotEmpty` (or other) error surfaced from a failed attempt. */
errorMessage?: string;
onDelete: () => void;
}
export const DeleteFamilyButton = ({ memberCount, isBusy, errorMessage, onDelete }: DeleteFamilyButtonProps) => {
const blocked = memberCount > 0;
return (
<Stack spacing={2} data-testid="delete-family">
<Typography variant="body2" color="text.secondary">
A family must be empty before it can be dissolved. Dissolving returns your creation fee.
</Typography>
{blocked && (
<Alert severity="info" data-testid="delete-family-blocked">
This family still has {memberCount} member{memberCount === 1 ? '' : 's'}. Remove or wait for members to leave
before dissolving.
</Alert>
)}
{errorMessage && (
<Alert severity="error" data-testid="delete-family-error">
{errorMessage}
</Alert>
)}
<ConfirmActionButton
label="Dissolve family"
color="error"
title="Dissolve this family?"
body="This permanently removes the family and refunds your creation fee. This cannot be undone."
confirmLabel="Dissolve family"
disabled={blocked || isBusy}
onConfirm={onDelete}
dataTestid="delete-family-button"
/>
</Stack>
);
};
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { EditFamilyForm } from './EditFamilyForm';
const meta: Meta<typeof EditFamilyForm> = {
title: 'Families/Components/EditFamilyForm',
component: EditFamilyForm,
args: {
initialName: 'Tatry Operators',
initialDescription: 'Operators coordinating routing in the Tatra mountains.',
nameLimit: 30,
descriptionLimit: 120,
onSubmit: () => undefined,
},
};
export default meta;
type Story = StoryObj<typeof EditFamilyForm>;
export const Default: Story = {};
export const OverLimitName: Story = {
args: { initialName: 'This family name is far too long to be accepted on chain' },
};
export const Submitting: Story = { args: { isSubmitting: true, initialName: 'Tatry Operators v2' } };
export const ContractError: Story = { args: { errorMessage: 'That family name is already taken.' } };
@@ -0,0 +1,89 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React, { useState } from 'react';
import { Alert, Box, Button, Stack, TextField } from '@mui/material';
import { NymCard } from '../NymCard';
import { byteLength, sanitizeInput } from './helpers';
export interface EditFamilyFormProps {
initialName: string;
initialDescription: string;
nameLimit: number;
descriptionLimit: number;
isSubmitting?: boolean;
errorMessage?: string;
/** Sends only changed fields: `string` to set, `null` to leave unchanged. No-op when nothing changed. */
onSubmit: (updatedName: string | null, updatedDescription: string | null) => void;
}
export const EditFamilyForm = ({
initialName,
initialDescription,
nameLimit,
descriptionLimit,
isSubmitting,
errorMessage,
onSubmit,
}: EditFamilyFormProps) => {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const nameBytes = byteLength(name);
const descBytes = byteLength(description);
const nameTooLong = nameBytes > nameLimit;
const descTooLong = descBytes > descriptionLimit;
const nameChanged = name !== initialName;
const descChanged = description !== initialDescription;
const nothingChanged = !nameChanged && !descChanged;
const canSubmit = !nothingChanged && !nameTooLong && !descTooLong && name.trim().length > 0 && !isSubmitting;
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit(nameChanged ? sanitizeInput(name) : null, descChanged ? sanitizeInput(description) : null);
};
return (
<NymCard title="Edit family" data-testid="edit-family-form">
<Stack spacing={3}>
<TextField
label="Family name"
value={name}
onChange={(e) => setName(e.target.value)}
error={nameTooLong}
helperText={
nameTooLong ? `Name is ${nameBytes}/${nameLimit} bytes — too long` : `${nameBytes}/${nameLimit} bytes`
}
fullWidth
inputProps={{ 'data-testid': 'edit-family-name' }}
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
error={descTooLong}
helperText={
descTooLong
? `Description is ${descBytes}/${descriptionLimit} bytes — too long`
: `${descBytes}/${descriptionLimit} bytes`
}
fullWidth
multiline
minRows={2}
inputProps={{ 'data-testid': 'edit-family-description' }}
/>
{errorMessage && (
<Alert severity="error" data-testid="edit-family-error">
{errorMessage}
</Alert>
)}
<Box>
<Button variant="contained" disabled={!canSubmit} onClick={handleSubmit} data-testid="edit-family-submit">
{isSubmitting ? 'Saving…' : 'Save changes'}
</Button>
</Box>
</Stack>
</NymCard>
);
};
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { MOCK_NOW_SECS, MOCK_OTHER_OWNER_ADDRESS } from 'src/context/mocks/families.fixtures';
import { InviteCard } from './InviteCard';
const meta: Meta<typeof InviteCard> = {
title: 'Families/Components/InviteCard',
component: InviteCard,
args: {
nowSecs: MOCK_NOW_SECS,
onAccept: () => undefined,
onReject: () => undefined,
invite: {
family_id: 2,
family_name: 'Alpine Routers',
owner_address: MOCK_OTHER_OWNER_ADDRESS,
expires_at: MOCK_NOW_SECS + 7200,
expired: false,
},
},
};
export default meta;
type Story = StoryObj<typeof InviteCard>;
export const Active: Story = {};
export const Expired: Story = {
args: {
invite: {
family_id: 2,
family_name: 'Alpine Routers',
owner_address: MOCK_OTHER_OWNER_ADDRESS,
expires_at: MOCK_NOW_SECS - 1,
expired: true,
},
},
};
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React from 'react';
import { Box, Chip, Stack, Typography } from '@mui/material';
import { OperatorInviteView } from 'src/types/families';
import { NymCard } from '../NymCard';
import { ConfirmActionButton } from './ConfirmActionButton';
import { formatExpiry, truncateAddress } from './helpers';
export type InviteCardData = OperatorInviteView;
export interface InviteCardProps {
invite: InviteCardData;
nowSecs: number;
isBusy?: boolean;
onAccept: () => void;
onReject: () => void;
}
export const InviteCard = ({ invite, nowSecs, isBusy, onAccept, onReject }: InviteCardProps) => (
<NymCard borderless title={invite.family_name} data-testid={`invite-card-${invite.family_id}`}>
<Stack spacing={2}>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
Invited by {truncateAddress(invite.owner_address)}
</Typography>
{invite.expired ? (
<Chip size="small" label="Expired" data-testid={`invite-card-${invite.family_id}-expired`} />
) : (
<Chip size="small" color="primary" variant="outlined" label={formatExpiry(invite.expires_at, nowSecs)} />
)}
</Stack>
{invite.expired ? (
<Typography variant="body2" color="text.secondary">
This invitation has expired and can no longer be accepted.
</Typography>
) : (
<Box>
<Stack direction="row" spacing={2}>
<ConfirmActionButton
label="Accept"
variant="contained"
title="Accept this invite?"
body="Accepting this invite has on-chain consequences and records your node as a member of this family."
confirmLabel="Accept invite"
disabled={isBusy}
onConfirm={onAccept}
dataTestid={`invite-card-${invite.family_id}-accept`}
/>
<ConfirmActionButton
label="Reject"
color="error"
title="Reject this invite?"
body="Reject this family invitation? It will no longer be shown."
confirmLabel="Reject invite"
disabled={isBusy}
onConfirm={onReject}
dataTestid={`invite-card-${invite.family_id}-reject`}
/>
</Stack>
</Box>
)}
</Stack>
</NymCard>
);
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { InviteNodeForm } from './InviteNodeForm';
const meta: Meta<typeof InviteNodeForm> = {
title: 'Families/Components/InviteNodeForm',
component: InviteNodeForm,
args: { onSubmit: () => undefined },
};
export default meta;
type Story = StoryObj<typeof InviteNodeForm>;
export const Default: Story = {};
export const WarningAlreadyInFamily: Story = { args: { warning: 'already-in-family' } };
export const WarningNonExistent: Story = { args: { warning: 'non-existent' } };
export const WarningDuplicatePending: Story = { args: { warning: 'duplicate-pending' } };
export const Submitting: Story = { args: { isSubmitting: true } };
export const ContractError: Story = { args: { errorMessage: 'Something went wrong.' } };
@@ -0,0 +1,91 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React, { useState } from 'react';
import { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material';
import { NymCard } from '../NymCard';
import { ConfirmationModal } from '../Modals/ConfirmationModal';
import { INVITE_WARNING_MESSAGES, InviteWarning } from './helpers';
export interface InviteNodeFormProps {
isSubmitting?: boolean;
/** Set after a failed attempt to surface one of the three warning states. */
warning?: InviteWarning;
errorMessage?: string;
onSubmit: (nodeId: number) => void;
}
const isValidNodeId = (raw: string): boolean => /^\d+$/.test(raw.trim()) && Number(raw.trim()) > 0;
export const InviteNodeForm = ({ isSubmitting, warning, errorMessage, onSubmit }: InviteNodeFormProps) => {
const [nodeId, setNodeId] = useState('');
const [confirmOpen, setConfirmOpen] = useState(false);
const trimmed = nodeId.trim();
const malformed = trimmed.length > 0 && !isValidNodeId(trimmed);
const canSubmit = isValidNodeId(trimmed) && !isSubmitting;
const handleConfirm = () => {
setConfirmOpen(false);
if (canSubmit) onSubmit(Number(trimmed));
};
return (
<NymCard title="Invite a node" data-testid="invite-node-form">
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
Only invite nodes you control and that are already bonded and operational.
</Typography>
<TextField
label="Node ID"
value={nodeId}
onChange={(e) => setNodeId(e.target.value)}
error={malformed}
helperText={malformed ? 'Enter a valid numeric node ID' : ' '}
fullWidth
inputProps={{ 'data-testid': 'invite-node-id', inputMode: 'numeric' }}
/>
{warning && (
<Alert severity="warning" data-testid="invite-node-warning">
{INVITE_WARNING_MESSAGES[warning]}
</Alert>
)}
{errorMessage && (
<Alert severity="error" data-testid="invite-node-error">
{errorMessage}
</Alert>
)}
<Box>
<Button
variant="contained"
disabled={!canSubmit}
onClick={() => setConfirmOpen(true)}
data-testid="invite-node-submit"
>
{isSubmitting ? 'Sending…' : 'Send invite'}
</Button>
</Box>
</Stack>
<ConfirmationModal
open={confirmOpen}
title="Confirm invite"
subTitle={`Send a family invitation to node ${trimmed}?`}
confirmButton={
<Stack direction="row" spacing={2} width="100%">
<Button fullWidth variant="outlined" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button fullWidth variant="contained" onClick={handleConfirm} data-testid="invite-node-confirm">
Confirm & send invite
</Button>
</Stack>
}
onConfirm={handleConfirm}
onClose={() => setConfirmOpen(false)}
/>
</NymCard>
);
};
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { LeaveFamilyButton } from './LeaveFamilyButton';
const meta: Meta<typeof LeaveFamilyButton> = {
title: 'Families/Components/LeaveFamilyButton',
component: LeaveFamilyButton,
args: { familyName: 'Alpine Routers', onLeave: () => undefined },
};
export default meta;
type Story = StoryObj<typeof LeaveFamilyButton>;
export const Default: Story = {};
export const Busy: Story = { args: { isBusy: true } };
@@ -0,0 +1,21 @@
import React from 'react';
import { ConfirmActionButton } from './ConfirmActionButton';
export interface LeaveFamilyButtonProps {
familyName: string;
isBusy?: boolean;
onLeave: () => void;
}
export const LeaveFamilyButton = ({ familyName, isBusy, onLeave }: LeaveFamilyButtonProps) => (
<ConfirmActionButton
label="Leave family"
color="error"
title="Leave family?"
body={`Leave "${familyName}"? Your node will be removed from the family. You can be invited again afterwards.`}
confirmLabel="Leave family"
disabled={isBusy}
onConfirm={onLeave}
dataTestid="leave-family-button"
/>
);
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { FamilyMemberSections } from 'src/types/families';
import { MOCK_NOW_SECS } from 'src/context/mocks/families.fixtures';
import { MemberList } from './MemberList';
const emptySections: FamilyMemberSections = { pending: [], joined: [], rejected: [], removed: [] };
const populatedSections: FamilyMemberSections = {
pending: [
{ section: 'pending', node_id: 107, expires_at: MOCK_NOW_SECS + 3600, expired: false },
{ section: 'pending', node_id: 108, expires_at: MOCK_NOW_SECS - 1, expired: true },
],
joined: [
{ section: 'joined', node_id: 101, joined_at: MOCK_NOW_SECS },
{ section: 'joined', node_id: 102, joined_at: MOCK_NOW_SECS },
],
rejected: [{ section: 'rejected', node_id: 105, rejected_at: MOCK_NOW_SECS }],
removed: [
{ section: 'removed', node_id: 103, removed_at: MOCK_NOW_SECS },
{ section: 'removed', node_id: 104, removed_at: MOCK_NOW_SECS },
],
};
const meta: Meta<typeof MemberList> = {
title: 'Families/Components/MemberList',
component: MemberList,
args: {
nowSecs: MOCK_NOW_SECS,
sections: emptySections,
onKick: () => undefined,
onRefresh: () => undefined,
},
};
export default meta;
type Story = StoryObj<typeof MemberList>;
export const Loading: Story = { args: { isLoading: true } };
export const ErrorState: Story = { args: { isError: true } };
export const Empty: Story = {};
export const Populated: Story = { args: { sections: populatedSections } };
@@ -0,0 +1,145 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React from 'react';
import { Box, Button, Chip, Stack, Table, TableBody, TableCell, TableRow, Typography } from '@mui/material';
import { FamilyMemberSections, MemberListSectionKey } from 'src/types/families';
import { NymCard } from '../NymCard';
import { ConfirmActionButton } from './ConfirmActionButton';
import { formatExpiry } from './helpers';
export interface MemberListProps {
sections: FamilyMemberSections;
nowSecs: number;
isLoading?: boolean;
isError?: boolean;
isBusy?: boolean;
onKick: (nodeId: number) => void;
onRefresh: () => void;
}
const SECTION_TITLES: Record<MemberListSectionKey, string> = {
pending: 'Pending',
joined: 'Joined',
rejected: 'Rejected',
removed: 'Removed',
};
const Section = ({
sectionKey,
count,
children,
}: {
sectionKey: MemberListSectionKey;
count: number;
children: React.ReactNode;
}) => (
<Box data-testid={`member-section-${sectionKey}`}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{SECTION_TITLES[sectionKey]} ({count})
</Typography>
{count === 0 ? (
<Typography variant="body2" color="text.secondary" data-testid={`member-section-${sectionKey}-empty`}>
No {SECTION_TITLES[sectionKey].toLowerCase()} entries.
</Typography>
) : (
<Table size="small">
<TableBody>{children}</TableBody>
</Table>
)}
</Box>
);
export const MemberList = ({ sections, nowSecs, isLoading, isError, isBusy, onKick, onRefresh }: MemberListProps) => {
if (isError) {
return (
<NymCard title="Members" data-testid="member-list">
<Stack spacing={2}>
<Typography color="error" data-testid="member-list-error">
Failed to load the member list.
</Typography>
<Box>
<Button variant="outlined" size="small" onClick={onRefresh}>
Retry
</Button>
</Box>
</Stack>
</NymCard>
);
}
return (
<NymCard
title="Members"
data-testid="member-list"
Action={
<Button variant="text" size="small" onClick={onRefresh} disabled={isLoading} data-testid="member-list-refresh">
Refresh
</Button>
}
>
{isLoading ? (
<Typography variant="body2" color="text.secondary" data-testid="member-list-loading">
Loading members
</Typography>
) : (
<Stack spacing={3}>
<Section sectionKey="pending" count={sections.pending.length}>
{sections.pending.map((r) => (
<TableRow key={`pending-${r.node_id}`} data-testid={`member-pending-${r.node_id}`}>
<TableCell>{r.node_id}</TableCell>
<TableCell align="right">
{r.expired ? (
<Chip size="small" label="Expired" />
) : (
<Typography variant="body2">{formatExpiry(r.expires_at, nowSecs)}</Typography>
)}
</TableCell>
</TableRow>
))}
</Section>
<Section sectionKey="joined" count={sections.joined.length}>
{sections.joined.map((r) => (
<TableRow key={`joined-${r.node_id}`} data-testid={`member-joined-${r.node_id}`}>
<TableCell>{r.node_id}</TableCell>
<TableCell align="right">
<ConfirmActionButton
label="Remove"
color="error"
title="Remove member?"
body={`Remove node ${r.node_id} from the family? This cannot be undone.`}
confirmLabel="Remove member"
disabled={isBusy}
onConfirm={() => onKick(r.node_id)}
dataTestid={`member-joined-${r.node_id}-kick`}
/>
</TableCell>
</TableRow>
))}
</Section>
<Section sectionKey="rejected" count={sections.rejected.length}>
{sections.rejected.map((r) => (
<TableRow key={`rejected-${r.node_id}`} data-testid={`member-rejected-${r.node_id}`}>
<TableCell>{r.node_id}</TableCell>
<TableCell align="right">
<Chip size="small" label="Rejected" />
</TableCell>
</TableRow>
))}
</Section>
<Section sectionKey="removed" count={sections.removed.length}>
{sections.removed.map((r) => (
<TableRow key={`removed-${r.node_id}`} data-testid={`member-removed-${r.node_id}`}>
<TableCell>{r.node_id}</TableCell>
<TableCell align="right">
<Chip size="small" label="Removed" />
</TableCell>
</TableRow>
))}
</Section>
</Stack>
)}
</NymCard>
);
};
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { MOCK_NOW_SECS, MOCK_OTHER_OWNER_ADDRESS } from 'src/context/mocks/families.fixtures';
import { NodeInviteGroup } from './NodeInviteGroup';
const meta: Meta<typeof NodeInviteGroup> = {
title: 'Families/Components/NodeInviteGroup',
component: NodeInviteGroup,
args: {
nodeId: 201,
nowSecs: MOCK_NOW_SECS,
onAccept: () => undefined,
onReject: () => undefined,
invites: [],
},
};
export default meta;
type Story = StoryObj<typeof NodeInviteGroup>;
export const Empty: Story = {};
export const WithInvites: Story = {
args: {
invites: [
{
family_id: 2,
family_name: 'Alpine Routers',
owner_address: MOCK_OTHER_OWNER_ADDRESS,
expires_at: MOCK_NOW_SECS + 7200,
expired: false,
},
{
family_id: 3,
family_name: 'Carpathian Mixers',
owner_address: MOCK_OTHER_OWNER_ADDRESS,
expires_at: MOCK_NOW_SECS - 1,
expired: true,
},
],
},
};
@@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React from 'react';
import { Stack, Typography } from '@mui/material';
import { NymCard } from '../NymCard';
import { InviteCard, InviteCardData } from './InviteCard';
export interface NodeInviteGroupProps {
nodeId: number;
invites: InviteCardData[];
nowSecs: number;
isBusy?: boolean;
onAccept: (familyId: number) => void;
onReject: (familyId: number) => void;
}
/** Invitations addressed to a single controlled node (multi-node aware grouping). */
export const NodeInviteGroup = ({ nodeId, invites, nowSecs, isBusy, onAccept, onReject }: NodeInviteGroupProps) => (
<NymCard title={`Node ${nodeId}`} data-testid={`node-invite-group-${nodeId}`}>
{invites.length === 0 ? (
<Typography variant="body2" color="text.secondary" data-testid={`node-invite-group-${nodeId}-empty`}>
No invitations for this node.
</Typography>
) : (
<Stack spacing={2}>
{invites.map((invite) => (
<InviteCard
key={invite.family_id}
invite={invite}
nowSecs={nowSecs}
isBusy={isBusy}
onAccept={() => onAccept(invite.family_id)}
onReject={() => onReject(invite.family_id)}
/>
))}
</Stack>
)}
</NymCard>
);
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { MOCK_NOW_SECS } from 'src/context/mocks/families.fixtures';
import { PendingInvitesList } from './PendingInvitesList';
const meta: Meta<typeof PendingInvitesList> = {
title: 'Families/Components/PendingInvitesList',
component: PendingInvitesList,
args: {
nowSecs: MOCK_NOW_SECS,
onRevoke: () => undefined,
onClearExpired: () => undefined,
},
};
export default meta;
type Story = StoryObj<typeof PendingInvitesList>;
export const Empty: Story = { args: { invites: [] } };
export const ActiveAndExpired: Story = {
args: {
invites: [
{ section: 'pending', node_id: 107, expires_at: MOCK_NOW_SECS + 3600, expired: false },
{ section: 'pending', node_id: 108, expires_at: MOCK_NOW_SECS - 1, expired: true },
],
},
};
@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React from 'react';
import { Chip, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { PendingMemberRow } from 'src/types/families';
import { NymCard } from '../NymCard';
import { ConfirmActionButton } from './ConfirmActionButton';
import { formatExpiry } from './helpers';
export interface PendingInvitesListProps {
invites: PendingMemberRow[];
nowSecs: number;
isBusy?: boolean;
/** Withdraw an active (not-yet-expired) invite. */
onRevoke: (nodeId: number) => void;
/** Dismiss/clear an expired invite. */
onClearExpired: (nodeId: number) => void;
}
export const PendingInvitesList = ({ invites, nowSecs, isBusy, onRevoke, onClearExpired }: PendingInvitesListProps) => (
<NymCard title="Pending invites" data-testid="pending-invites-list">
{invites.length === 0 ? (
<Typography variant="body2" color="text.secondary" data-testid="pending-invites-empty">
No pending invites.
</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Node</TableCell>
<TableCell>Expiry</TableCell>
<TableCell align="right">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invites.map((inv) => (
<TableRow key={inv.node_id} data-testid={`pending-invite-${inv.node_id}`}>
<TableCell>{inv.node_id}</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
{inv.expired ? (
<Chip
size="small"
color="default"
label="Expired"
data-testid={`pending-invite-${inv.node_id}-expired`}
/>
) : (
<Typography variant="body2">{formatExpiry(inv.expires_at, nowSecs)}</Typography>
)}
</Stack>
</TableCell>
<TableCell align="right">
{inv.expired ? (
<ConfirmActionButton
label="Clear"
title="Clear expired invite?"
body={`Remove the expired invitation for node ${inv.node_id}?`}
confirmLabel="Clear invite"
disabled={isBusy}
onConfirm={() => onClearExpired(inv.node_id)}
dataTestid={`pending-invite-${inv.node_id}-clear`}
/>
) : (
<ConfirmActionButton
label="Withdraw"
title="Withdraw invite?"
body={`Withdraw the pending invitation for node ${inv.node_id}?`}
confirmLabel="Withdraw invite"
disabled={isBusy}
onConfirm={() => onRevoke(inv.node_id)}
dataTestid={`pending-invite-${inv.node_id}-withdraw`}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</NymCard>
);
Binary file not shown.
@@ -0,0 +1,90 @@
/* eslint-disable @typescript-eslint/naming-convention */
import Big from 'big.js';
import { DecCoin } from '@nymproject/types';
import { FamilyError, FamilyErrorKind, isFamilyError } from 'src/types/families';
/** Byte length (matches the contract's `String::len` limit checks). */
export const byteLength = (s: string): number => new TextEncoder().encode(s).length;
/** Strip control characters / angle-bracket tags before submission. React escapes on render, but we neutralise eagerly. */
export const sanitizeInput = (s: string): string =>
// eslint-disable-next-line no-control-regex
s.replace(/[\u0000-\u001F\u007F]/g, '').replace(/[<>]/g, '');
export const formatCoin = (coin?: DecCoin): string => (coin ? `${coin.amount} ${coin.denom.toUpperCase()}` : '—');
export const truncateAddress = (addr: string, head = 8, tail = 6): string =>
addr.length <= head + tail + 1 ? addr : `${addr.slice(0, head)}${addr.slice(-tail)}`;
/** Human-readable remaining TTL, or "Expired". */
export const formatExpiry = (expiresAt: number, nowSecs: number): string => {
const remaining = expiresAt - nowSecs;
if (remaining <= 0) return 'Expired';
if (remaining < 60) return `in ${remaining}s`;
if (remaining < 3600) return `in ${Math.floor(remaining / 60)} min`;
if (remaining < 86400) return `in ${Math.floor(remaining / 3600)}h`;
return `in ${Math.floor(remaining / 86400)}d`;
};
/** True when balance is below fee + a gas headroom (best-effort, pre-submit). */
export const isInsufficientBalance = (balance: DecCoin | undefined, fee: DecCoin, gasHeadroom = '0.1'): boolean => {
if (!balance) return false;
try {
return Big(balance.amount).lt(Big(fee.amount).plus(gasHeadroom));
} catch {
return false;
}
};
const ERROR_MESSAGES: Record<FamilyErrorKind, string> = {
InvalidFamilyCreationFee: 'The attached creation fee is incorrect.',
InvalidDeposit: 'The attached funds are invalid for this operation.',
FamilyNameAlreadyTaken: 'That family name is already taken.',
FamilyNameTooLong: 'The family name is too long.',
EmptyFamilyName: 'The family name cannot be empty after normalisation.',
FamilyDescriptionTooLong: 'The family description is too long.',
SenderAlreadyOwnsAFamily: 'You already own a family.',
SenderDoesntOwnAFamily: 'You do not own a family.',
NodeAlreadyInFamily: 'That node is already in a family.',
AlreadyInFamily: 'Your node is already a member of a family.',
NodeDoesntExist: 'That node does not exist or is unbonding.',
PendingInvitationAlreadyExists: 'There is already a pending invitation for that node.',
ZeroInvitationValidity: 'Invitation validity must be greater than zero.',
InvitationExpired: 'That invitation has expired.',
InvitationNotFound: 'No pending invitation was found.',
FamilyNotEmpty: 'The family must be empty before it can be deleted.',
FamilyNotFound: 'That family no longer exists.',
SenderDoesntControlNode: 'You do not control that node.',
NodeNotMemberOfFamily: 'That node is not a member of your family.',
NodeNotInFamily: 'That node is not in any family.',
UnauthorisedMixnetCallback: 'Unauthorised callback.',
};
export const familyErrorMessage = (e: unknown): string => {
if (isFamilyError(e)) return ERROR_MESSAGES[e.kind] ?? e.message;
if (e instanceof Error) return e.message;
return String(e);
};
export type InviteWarning = 'already-in-family' | 'non-existent' | 'duplicate-pending';
/** Map an invite-time contract error to the spec's three warning states. */
export const inviteWarningFromError = (e: unknown): InviteWarning | undefined => {
const kind: FamilyErrorKind | undefined = isFamilyError(e) ? (e as FamilyError).kind : undefined;
switch (kind) {
case 'NodeAlreadyInFamily':
return 'already-in-family';
case 'NodeDoesntExist':
return 'non-existent';
case 'PendingInvitationAlreadyExists':
return 'duplicate-pending';
default:
return undefined;
}
};
export const INVITE_WARNING_MESSAGES: Record<InviteWarning, string> = {
'already-in-family': 'This node is already in a family — the invite was not sent.',
'non-existent': 'This node does not exist or is unbonding — the invite was not sent.',
'duplicate-pending': 'This node already has a pending invite from your family.',
};
@@ -0,0 +1,11 @@
export * from './helpers';
export * from './ConfirmActionButton';
export * from './CreateFamilyForm';
export * from './EditFamilyForm';
export * from './InviteNodeForm';
export * from './PendingInvitesList';
export * from './MemberList';
export * from './DeleteFamilyButton';
export * from './InviteCard';
export * from './NodeInviteGroup';
export * from './LeaveFamilyButton';
@@ -0,0 +1,35 @@
import React, { useMemo, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MockFamiliesContextProvider } from 'src/context/mocks/families';
import type { MockStore } from 'src/context/mocks/familiesMockState';
export interface WithFamiliesMockOptions {
sender?: string;
/** Factory so each story mount gets a fresh, isolated store. */
makeStore?: () => MockStore;
latencyMs?: number;
}
/**
* Storybook decorator: wraps a story in a fresh QueryClient + the mock families
* provider, so pages/flows run against the in-memory contract model (design D3, D7).
*/
export const withFamiliesMock =
(options: WithFamiliesMockOptions = {}) =>
// eslint-disable-next-line react/display-name
(Story: React.ComponentType) => {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
}),
);
const store = useMemo(() => options.makeStore?.(), []);
return (
<QueryClientProvider client={client}>
<MockFamiliesContextProvider store={store} sender={options.sender} latencyMs={options.latencyMs ?? 150}>
<Story />
</MockFamiliesContextProvider>
</QueryClientProvider>
);
};
+14 -1
View File
@@ -3,7 +3,13 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { Divider, List, ListItemButton, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { alpha } from '@mui/material/styles';
import { AccountBalanceWalletOutlined, Description, Settings, VpnKeyOutlined } from '@mui/icons-material';
import {
AccountBalanceWalletOutlined,
Description,
GroupsOutlined,
Settings,
VpnKeyOutlined,
} from '@mui/icons-material';
import { safeOpenUrl } from 'src/utils/safeOpenUrl';
import { AppContext } from '../context/main';
import { Delegate, Bonding } from '../svg-icons';
@@ -40,6 +46,13 @@ export const Nav = () => {
Icon: Bonding,
onClick: () => navigate('/bonding'),
},
{
label: 'Family',
description: 'Manage node families',
route: '/family',
Icon: GroupsOutlined,
onClick: () => navigate('/family'),
},
{
label: 'Docs',
description: 'Internal wallet notes',
+18 -3
View File
@@ -19,14 +19,30 @@ export const NymCard: FCWithChildren<{
borderless?: boolean;
/** Omit the card header row (use for fully custom headers inside children). */
hideHeader?: boolean;
/** Preferred: standard DOM test id. `dataTestid` (camelCase) is kept for back-compat. */
'data-testid'?: string;
dataTestid?: string;
sx?: SxProps;
sxTitle?: SxProps;
children?: React.ReactNode;
}> = ({ title, subheader, Action, Icon, noPadding, borderless, hideHeader, children, dataTestid, sx, sxTitle }) => (
}> = ({
title,
subheader,
Action,
Icon,
noPadding,
borderless,
hideHeader,
children,
dataTestid,
'data-testid': dataTestidAttr,
sx,
sxTitle,
}) => (
<Card
variant="outlined"
data-testid={hideHeader ? dataTestid : undefined}
// Resolved id on the root element (wraps content, so it can scope children).
data-testid={dataTestidAttr ?? dataTestid}
sx={{
overflow: 'hidden',
borderRadius: 4,
@@ -49,7 +65,6 @@ export const NymCard: FCWithChildren<{
}}
title={<Title title={title} Icon={Icon} sx={sxTitle} />}
subheader={subheader}
data-testid={dataTestid || (typeof title === 'string' ? title : 'nym-card')}
subheaderTypographyProps={{ variant: 'subtitle1' }}
action={Action}
/>
@@ -0,0 +1,91 @@
/* eslint-disable @typescript-eslint/naming-convention */
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import * as familyRequests from 'src/requests/families';
import { Console } from 'src/utils/console';
import { FamilyTxResult, NodeId } from 'src/types/families';
import { isMixnode, isNymNode } from 'src/types/global';
import { AppContext } from './main';
import { FamiliesContext, TFamiliesContext, defaultQueries } from './families';
import { useBondingContext } from './bonding';
import { familyQueryKeys } from './familyQueryKeys';
/**
* Real, Tauri-backed FamiliesContext provider. Kept in its own module so it is the
* ONLY families file importing `./main` (which pulls Tauri-runtime code at load).
* Storybook/tests use `MockFamiliesContextProvider` instead and never load this.
*/
export const FamiliesContextProvider: FCWithChildren = ({ children }): React.JSX.Element => {
const queryClient = useQueryClient();
const { clientDetails } = useContext(AppContext);
const ownerAddress = clientDetails?.client_address;
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<string>();
// The operator persona is "nodes I control". An account bonds at most one node,
// so this is the bonded node's id (the unified mixnet node id — `nodeId` for a
// nym-node, `mixId` for a legacy mixnode), or none for a gateway / no bond
// (design D3). Sourced from the `BondingContext` the families route now wraps.
const { bondedNode } = useBondingContext();
const controlledNodeIds = useMemo<NodeId[]>(() => {
if (!bondedNode) return [];
if (isNymNode(bondedNode)) return [bondedNode.nodeId];
if (isMixnode(bondedNode)) return [bondedNode.mixId];
return [];
}, [bondedNode]);
const nowSecs = useMemo(() => Math.floor(Date.now() / 1000), []);
const refreshAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: familyQueryKeys.all });
}, [queryClient]);
const clearError = useCallback(() => setError(undefined), []);
/** Run an execute call: toggle flag, surface + rethrow errors, refresh reads on success. */
const run = useCallback(
async (op: () => Promise<FamilyTxResult>): Promise<FamilyTxResult> => {
setIsExecuting(true);
setError(undefined);
try {
const result = await op();
await refreshAll();
return result;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setError(message);
Console.error(e);
throw e;
} finally {
setIsExecuting(false);
}
},
[refreshAll],
);
const memoizedValue = useMemo<TFamiliesContext>(
() => ({
ownerAddress,
controlledNodeIds,
nowSecs,
queries: defaultQueries,
isExecuting,
error,
clearError,
refreshAll,
createFamily: (args) => run(() => familyRequests.createFamily(args)),
updateFamily: (args) => run(() => familyRequests.updateFamily(args)),
disbandFamily: () => run(() => familyRequests.disbandFamily()),
inviteToFamily: (args) => run(() => familyRequests.inviteToFamily(args)),
revokeFamilyInvitation: (args) => run(() => familyRequests.revokeFamilyInvitation(args)),
kickFromFamily: (args) => run(() => familyRequests.kickFromFamily(args)),
acceptFamilyInvitation: (args) => run(() => familyRequests.acceptFamilyInvitation(args)),
rejectFamilyInvitation: (args) => run(() => familyRequests.rejectFamilyInvitation(args)),
leaveFamily: (args) => run(() => familyRequests.leaveFamily(args)),
}),
[ownerAddress, controlledNodeIds, nowSecs, isExecuting, error, clearError, refreshAll, run],
);
return <FamiliesContext.Provider value={memoizedValue}>{children}</FamiliesContext.Provider>;
};

Some files were not shown because too many files have changed in this diff Show More