Skip to content

Commit 3bb4b5b

Browse files
authored
feat: add secret-vault example
feat: add secret-vault example
2 parents 5d4747d + bcafdb5 commit 3bb4b5b

13 files changed

Lines changed: 449 additions & 2 deletions

File tree

docs/proposals/Examples-Roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Examples Roadmap — Design Proposal
22

3-
**Status:** Implementing — Example 1 (`choice-form`) done in #108, Example 2 (`resource-lifecycle`) done in #111
3+
**Status:** Implementing — Example 1 (`choice-form`) done in #108, Example 2 (`resource-lifecycle`) done in #111, Example 3 (`secret-vault`) done in #<TBD>
44
**Date:** 2026-06-26
55
**Author:** Big Pickle
66
**Affected packages:** `@intent-framework/core`, `@intent-framework/dom`, `@intent-framework/router`, `@intent-framework/testing`

examples/secret-vault/index.html

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Intent — Secret Vault Example</title>
7+
<style>
8+
body { font-family: system-ui, sans-serif; max-width: 650px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
9+
h1:first-of-type { font-size: 1.25rem; }
10+
h2 { font-size: 1rem; margin-top: 2rem; }
11+
label { display: block; font-weight: 600; margin-top: 1rem; margin-bottom: 0.25rem; }
12+
input { width: 100%; box-sizing: border-box; padding: 0.5rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; }
13+
button { margin-right: 0.5rem; margin-bottom: 0.5rem; padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; border: 1px solid #aaa; border-radius: 4px; background: #fff; }
14+
button.primary { font-weight: 700; background: #1a73e8; color: #fff; border-color: #1a73e8; }
15+
button:disabled { opacity: 0.5; cursor: not-allowed; }
16+
output { display: block; margin-top: 0.5rem; font-style: italic; color: #555; }
17+
pre { background: #f5f5f5; padding: 0.75rem; border-radius: 4px; font-size: 0.8rem; overflow-x: auto; }
18+
.proves { background: #e8f0fe; padding: 0.75rem; border-radius: 4px; margin-top: 1.5rem; }
19+
.proves ul { margin: 0.25rem 0; padding-left: 1.25rem; }
20+
.intent-blocked-reason { color: #c62828; background: #ffebee; padding: 0.5rem; border-radius: 4px; font-weight: 600; font-size: 0.875rem; margin-top: 0.25rem; }
21+
</style>
22+
</head>
23+
<body>
24+
<h1>Intent: Secret Vault</h1>
25+
<p>A multi-screen vault app demonstrating complex flows, <code>asSecret()</code> + <code>private()</code>, <code>BooleanState</code>, and router navigation.</p>
26+
<div id="root"></div>
27+
<h2>Semantic Graph (<code>inspectScreen()</code>)</h2>
28+
<pre id="inspect">Loading...</pre>
29+
<div class="proves">
30+
<strong>What this proves:</strong>
31+
<ul>
32+
<li>Multi-step <code>$.flow()</code> with 3+ steps on LoginScreen and VaultScreen.</li>
33+
<li><code>asSecret()</code> + <code>private()</code> combined on password and secret key fields.</li>
34+
<li><code>BooleanState</code> for locked/unlocked vault state.</li>
35+
<li>Flow diagnostics: no <code>orphaned-flow</code> or <code>flow-step-not-surfaced</code> in correct config.</li>
36+
<li><code>renderRouter()</code> with 3 screens (login, vault, recovery) and navigation between them.</li>
37+
<li><code>inspectScreen()</code> reports <code>stepCount</code> for each flow.</li>
38+
</ul>
39+
</div>
40+
<script type="module" src="/src/main.ts"></script>
41+
</body>
42+
</html>

examples/secret-vault/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "secret-vault",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"test": "vitest run",
10+
"typecheck": "tsc --noEmit",
11+
"lint": "tsc --noEmit"
12+
},
13+
"dependencies": {
14+
"@intent-framework/core": "workspace:*",
15+
"@intent-framework/dom": "workspace:*",
16+
"@intent-framework/router": "workspace:*"
17+
},
18+
"devDependencies": {
19+
"@intent-framework/testing": "workspace:*",
20+
"typescript": "^5.7.0",
21+
"vite": "^6.0.0",
22+
"vitest": "^3.1.1"
23+
}
24+
}

examples/secret-vault/src/main.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { renderRouter } from "@intent-framework/dom"
2+
import { inspectScreen } from "@intent-framework/core"
3+
import { vaultRouter } from "./vault/router.js"
4+
5+
const root = document.getElementById("root")!
6+
renderRouter(vaultRouter, {
7+
target: root,
8+
showScreenName: true,
9+
})
10+
11+
function updateInspect() {
12+
const match = vaultRouter.match(location.pathname)
13+
if (match.found) {
14+
const inspectEl = document.getElementById("inspect")
15+
if (inspectEl) {
16+
inspectEl.textContent = JSON.stringify(inspectScreen(match.screen), null, 2)
17+
}
18+
}
19+
}
20+
21+
let framePending: number | undefined
22+
new MutationObserver(() => {
23+
if (framePending) cancelAnimationFrame(framePending)
24+
framePending = requestAnimationFrame(() => {
25+
framePending = undefined
26+
updateInspect()
27+
})
28+
}).observe(root, { childList: true })
29+
30+
updateInspect()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { screen } from "@intent-framework/core"
2+
import type { VaultServices } from "./router.js"
3+
4+
export const LoginScreen = screen<VaultServices>("Login", $ => {
5+
const username = $.state.text("username")
6+
const password = $.state.text("password")
7+
8+
const usernameAsk = $.ask("Username or email", username)
9+
.required("Enter your username or email")
10+
.private()
11+
12+
const passwordAsk = $.ask("Password", password)
13+
.asSecret()
14+
.required("Enter your password")
15+
.private()
16+
.hint("Enter your vault password")
17+
18+
const login = $.act("Unlock vault")
19+
.primary()
20+
.when(usernameAsk.valid, "Enter your username or email")
21+
.when(passwordAsk.valid, "Enter your password")
22+
.does(({ navigate }) => {
23+
navigate("vault")
24+
})
25+
26+
const forgotPassword = $.act("Forgot password?")
27+
.does(({ navigate }) => {
28+
navigate("recovery")
29+
})
30+
31+
$.flow("login")
32+
.startsWith(usernameAsk)
33+
.then(passwordAsk)
34+
.then(login)
35+
36+
$.surface("main").contains(usernameAsk, passwordAsk, login, forgotPassword)
37+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { screen } from "@intent-framework/core"
2+
import type { VaultServices } from "./router.js"
3+
4+
export const RecoveryScreen = screen<VaultServices>("Recovery", $ => {
5+
const recoveryEmail = $.state.text("recoveryEmail")
6+
7+
const recoveryAsk = $.ask("Recovery email", recoveryEmail)
8+
.asContact("email")
9+
.required("Enter your recovery email")
10+
.validate(v => v.includes("@") ? true : "Enter a valid email")
11+
.private()
12+
13+
const reset = $.act("Reset vault")
14+
.primary()
15+
.when(recoveryAsk.valid, "Enter a valid recovery email")
16+
.does(() => {
17+
// Simulate vault reset
18+
})
19+
.feedback({
20+
pending: "Resetting...",
21+
success: "Vault reset! Check your email.",
22+
failure: "Reset failed.",
23+
})
24+
25+
const backToLogin = $.act("Back to login")
26+
.does(({ navigate }) => {
27+
navigate("login")
28+
})
29+
30+
$.flow("recovery")
31+
.startsWith(recoveryAsk)
32+
.then(reset)
33+
34+
$.surface("main").contains(recoveryAsk, reset, backToLogin)
35+
})
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect } from "vitest"
2+
import { testScreen } from "@intent-framework/testing"
3+
import { inspectScreen } from "@intent-framework/core"
4+
import { LoginScreen } from "./LoginScreen.js"
5+
import { VaultScreen } from "./VaultScreen.js"
6+
import { RecoveryScreen } from "./RecoveryScreen.js"
7+
import { vaultRouter, type VaultServices } from "./router.js"
8+
9+
function navServices(routeName: string): VaultServices {
10+
return {
11+
navigate: () => {},
12+
route: { name: routeName, path: `/${routeName}`, params: {} },
13+
} as unknown as VaultServices
14+
}
15+
16+
describe("LoginScreen", () => {
17+
it("flow has steps in expected order", () => {
18+
const flow = LoginScreen.flows.find(f => f.name === "login")!
19+
expect(flow.steps).toHaveLength(3)
20+
expect(flow.steps[0]!.node).toMatchObject({ label: "Username or email" })
21+
expect(flow.steps[1]!.node).toMatchObject({ label: "Password" })
22+
expect(flow.steps[2]!.node).toMatchObject({ label: "Unlock vault" })
23+
})
24+
25+
it("password ask is secret and private", () => {
26+
const ask = LoginScreen.asks.find(a => a.label === "Password")!
27+
expect(ask.kind).toBe("secret")
28+
expect(ask.isPrivate).toBe(true)
29+
})
30+
31+
it("login action is blocked until username/email and password are valid", async () => {
32+
await testScreen(LoginScreen, async app => {
33+
app.act("Unlock vault").toBeBlocked()
34+
}, { services: navServices("login") })
35+
})
36+
37+
it("login action enables after valid inputs", async () => {
38+
await testScreen(LoginScreen, async app => {
39+
await app.answer("Username or email", "user@example.com")
40+
await app.answer("Password", "password123")
41+
app.act("Unlock vault").toBeEnabled()
42+
}, { services: navServices("login") })
43+
})
44+
})
45+
46+
describe("VaultScreen", () => {
47+
it("secret key ask is secret and private", () => {
48+
const ask = VaultScreen.asks.find(a => a.label === "Secret key")!
49+
expect(ask.kind).toBe("secret")
50+
expect(ask.isPrivate).toBe(true)
51+
})
52+
53+
it("decrypt action is blocked until secret key is valid", async () => {
54+
await testScreen(VaultScreen, async app => {
55+
app.act("Decrypt vault").toBeBlocked()
56+
}, { services: navServices("vault") })
57+
})
58+
59+
it("BooleanState toggles locked/unlocked state", async () => {
60+
await testScreen(VaultScreen, async app => {
61+
// Initially locked — lock is blocked, decrypt is blocked until keys entered
62+
app.act("Lock vault").toBeBlockedBy("Vault is already locked")
63+
64+
await app.answer("Secret key", "my-secret-key")
65+
await app.answer("Confirm key", "my-secret-key")
66+
67+
// Decrypt then verify unlocked
68+
await app.act("Decrypt vault").run()
69+
app.act("Decrypt vault").toBeBlockedBy("Vault is already unlocked")
70+
app.act("Lock vault").toBeEnabled()
71+
72+
// Lock back
73+
await app.act("Lock vault").run()
74+
app.act("Lock vault").toBeBlockedBy("Vault is already locked")
75+
}, { services: navServices("vault") })
76+
})
77+
})
78+
79+
describe("RecoveryScreen", () => {
80+
it("reset action is blocked until valid recovery email", async () => {
81+
await testScreen(RecoveryScreen, async app => {
82+
app.act("Reset vault").toBeBlocked()
83+
84+
await app.answer("Recovery email", "not-an-email")
85+
app.act("Reset vault").toBeBlocked()
86+
87+
await app.answer("Recovery email", "user@example.com")
88+
app.act("Reset vault").toBeEnabled()
89+
}, { services: navServices("recovery") })
90+
})
91+
})
92+
93+
describe("Router", () => {
94+
it("has login, vault, and recovery routes", () => {
95+
const routes = vaultRouter.routes()
96+
expect(routes).toHaveLength(3)
97+
98+
const names = routes.map(r => r.name)
99+
expect(names).toContain("login")
100+
expect(names).toContain("vault")
101+
expect(names).toContain("recovery")
102+
})
103+
104+
it("navigation flow can move login → vault → recovery → login", async () => {
105+
const navigated: string[] = []
106+
const trackNav = (name: string) => navigated.push(name)
107+
108+
// Login → Vault
109+
await testScreen(LoginScreen, async app => {
110+
await app.answer("Username or email", "user@example.com")
111+
await app.answer("Password", "password123")
112+
await app.act("Unlock vault").run()
113+
}, {
114+
services: { navigate: trackNav, route: { name: "login", path: "/login", params: {} } } as unknown as VaultServices,
115+
})
116+
expect(navigated).toContain("vault")
117+
navigated.length = 0
118+
119+
// Vault → Recovery
120+
await testScreen(VaultScreen, async app => {
121+
await app.act("Forgot key?").run()
122+
}, {
123+
services: { navigate: trackNav, route: { name: "vault", path: "/vault", params: {} } } as unknown as VaultServices,
124+
})
125+
expect(navigated).toContain("recovery")
126+
navigated.length = 0
127+
128+
// Recovery → Login
129+
await testScreen(RecoveryScreen, async app => {
130+
await app.act("Back to login").run()
131+
}, {
132+
services: { navigate: trackNav, route: { name: "recovery", path: "/recovery", params: {} } } as unknown as VaultServices,
133+
})
134+
expect(navigated).toContain("login")
135+
})
136+
})
137+
138+
describe("Diagnostics", () => {
139+
it("inspectScreen reports no orphaned-flow or flow-step-not-surfaced for correct screens", () => {
140+
for (const screen of [LoginScreen, VaultScreen, RecoveryScreen]) {
141+
const graph = inspectScreen(screen)
142+
const flowIssues = graph.diagnostics.filter(
143+
d => d.code === "orphaned-flow" || d.code === "flow-step-not-surfaced",
144+
)
145+
expect(flowIssues).toHaveLength(0)
146+
}
147+
})
148+
149+
it("inspectScreen reports flow stepCount for each screen", () => {
150+
const loginGraph = inspectScreen(LoginScreen)
151+
const vaultGraph = inspectScreen(VaultScreen)
152+
const recoveryGraph = inspectScreen(RecoveryScreen)
153+
154+
expect(loginGraph.flows.find(f => f.name === "login")!.stepCount).toBe(3)
155+
expect(vaultGraph.flows.find(f => f.name === "vaultAccess")!.stepCount).toBe(3)
156+
expect(recoveryGraph.flows.find(f => f.name === "recovery")!.stepCount).toBe(2)
157+
})
158+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { screen } from "@intent-framework/core"
2+
import type { VaultServices } from "./router.js"
3+
4+
export const VaultScreen = screen<VaultServices>("Vault", $ => {
5+
const secretKey = $.state.text("secretKey")
6+
const confirmKey = $.state.text("confirmKey")
7+
const isLocked = $.state.boolean("isLocked", { initial: true })
8+
9+
const secretKeyAsk = $.ask("Secret key", secretKey)
10+
.asSecret()
11+
.required("Enter the secret key")
12+
.private()
13+
.hint("The key was set when the vault was created")
14+
15+
const confirmKeyAsk = $.ask("Confirm key", confirmKey)
16+
.asSecret()
17+
.required("Confirm your secret key")
18+
.private()
19+
20+
const decrypt = $.act("Decrypt vault")
21+
.primary()
22+
.when(secretKeyAsk.valid, "Enter a valid secret key")
23+
.when(confirmKeyAsk.valid, "Confirm the secret key")
24+
.when(() => isLocked.value, "Vault is already unlocked")
25+
.does(() => {
26+
isLocked.set(false)
27+
})
28+
29+
const lock = $.act("Lock vault")
30+
.when(() => !isLocked.value, "Vault is already locked")
31+
.does(() => {
32+
isLocked.set(true)
33+
})
34+
35+
const forgotKey = $.act("Forgot key?")
36+
.does(({ navigate }) => {
37+
navigate("recovery")
38+
})
39+
40+
const backToLogin = $.act("Back to login")
41+
.does(({ navigate }) => {
42+
navigate("login")
43+
})
44+
45+
$.flow("vaultAccess")
46+
.startsWith(secretKeyAsk)
47+
.then(confirmKeyAsk)
48+
.then(decrypt)
49+
50+
$.surface("main").contains(secretKeyAsk, confirmKeyAsk, decrypt, lock, forgotKey, backToLogin)
51+
})

0 commit comments

Comments
 (0)