Skip to content

Commit b0782df

Browse files
authored
refactor(web-basic): split main.ts into responsibility-oriented files (#38)
* refactor(web-basic): split main.ts into responsibility-oriented files The single-file demo is now split into: - types.ts — shared app types (AppRoutes, AppServices) - data.ts — in-memory data store and resource helpers - screens.ts — all semantic screen definitions - router.ts — typed router construction - demo-panels.ts — demo-only DOM panel helpers - main.ts — small entrypoint (mount, observe, wire panels) Behavior is preserved. No public API changes. No new dependencies. No barrel files. No circular imports. Closes the 201-line single-file pattern. * refactor(web-basic): remove demo-panels.ts dependency from screens.ts screens.ts no longer imports updateTeamInfo from demo-panels.ts. Demo panel wiring is now handled entirely from main.ts via the existing onRouteChanged() function, which reads current route state from router.match() and updates panels reactively through the MutationObserver/requestAnimationFrame path. Import direction after this change: main.ts -> screens.ts main.ts -> router.ts main.ts -> demo-panels.ts demo-panels.ts -> data.ts screens.ts -> data.ts screens.ts -> types.ts Behavior preserved: - Team info panel updates on navigation and after resource reload - Diagnostics panel updates on every route change - Refresh team increments visible version - Invite increments visible member count - Navigation after invite unchanged - Enter key, blocked reason, feedback behavior unchanged
1 parent 8b8b1c8 commit b0782df

8 files changed

Lines changed: 188 additions & 174 deletions

File tree

docs/Demo.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,12 @@ The panel is updated by a `MutationObserver` on `#root` that debounces through
7272

7373
## What to notice in the code
7474

75-
Open `examples/web-basic/src/main.ts` (201 lines, single file).
75+
Open `examples/web-basic/src/` to see the demo split into screens, data, router,
76+
and demo-only DOM panel helpers.
7677

7778
The entire app — four screens, router, data layer, diagnostics, and DOM updates
78-
— fits in one file because the framework handles rendering, reactivity,
79-
validation, and lifecycle.
79+
— fits in a small, responsibility-oriented structure because the framework
80+
handles rendering, reactivity, validation, and lifecycle.
8081

8182
Key patterns:
8283

docs/MVP-Checkpoint.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ It now has:
7373

7474
## Demo state
7575

76-
The `examples/web-basic` package contains a 201-line single-file team invite demo with four screens, router, data layer, diagnostics, and DOM rendering.
76+
The `examples/web-basic` package contains a small team invite demo with four screens, router, data layer, diagnostics, and DOM rendering. Source is split by responsibility across `types.ts`, `data.ts`, `screens.ts`, `router.ts`, `demo-panels.ts`, and `main.ts`.
7777

7878
Demo demonstrates: semantic screens, independent actions, router navigation, route context, runtime-scoped resources, resource reload, ask validation, blocked reasons, feedback, keyboard default action, accessible hint, screen-name headings, and graph diagnostics.
7979

examples/web-basic/src/data.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export type Team = {
2+
id: string
3+
name: string
4+
members: string[]
5+
version: number
6+
}
7+
8+
export const teams: Record<string, { id: string; name: string; members: string[] }> = {
9+
team_1: { id: "team_1", name: "Alpha", members: [] },
10+
team_2: { id: "team_2", name: "Beta", members: [] },
11+
team_3: { id: "team_3", name: "Gamma", members: [] },
12+
}
13+
14+
export const teamVersions: Record<string, number> = {}
15+
16+
let teamLoadVersion = 0
17+
18+
function delay(ms: number) {
19+
return new Promise(resolve => setTimeout(resolve, ms))
20+
}
21+
22+
export async function loadTeam(teamId: string): Promise<Team> {
23+
await delay(80)
24+
const team = teams[teamId]
25+
if (!team) throw new Error(`Team "${teamId}" not found`)
26+
const version = ++teamLoadVersion
27+
teamVersions[teamId] = version
28+
return { ...team, version }
29+
}
30+
31+
export async function inviteMember(teamId: string, email: string): Promise<void> {
32+
await delay(200)
33+
const team = teams[teamId]
34+
if (!team) throw new Error(`Team "${teamId}" not found`)
35+
team.members.push(email)
36+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { inspectScreen, type ScreenDefinition } from "@intent/core"
2+
import type { AppServices } from "./types.js"
3+
import { teams, teamVersions } from "./data.js"
4+
5+
export function updateTeamInfo(teamId: string | undefined) {
6+
const el = document.getElementById("team-info")
7+
if (!el) return
8+
if (!teamId) {
9+
el.textContent = ""
10+
return
11+
}
12+
const team = teams[teamId]
13+
const version = teamVersions[teamId]
14+
if (team) {
15+
const versionStr = version ? `(v${version}) ` : ""
16+
el.textContent = `${versionStr}${team.name}${team.members.length} member${team.members.length === 1 ? "" : "s"}`
17+
}
18+
}
19+
20+
export function updateDiagnostics(screenDef: ScreenDefinition<AppServices>) {
21+
const el = document.getElementById("diagnostics")
22+
if (!el) return
23+
const inspected = inspectScreen(screenDef)
24+
if (inspected.diagnostics.length === 0) {
25+
el.textContent = "✓ No diagnostics."
26+
} else {
27+
el.textContent = inspected.diagnostics.map(d =>
28+
`[${d.severity}] ${d.code}${d.nodeId ? ` (${d.nodeId})` : ""}: ${d.message}`
29+
).join("\n")
30+
}
31+
}

examples/web-basic/src/main.ts

Lines changed: 3 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -1,174 +1,7 @@
1-
import { screen, inspectScreen, type ScreenDefinition } from "@intent/core"
2-
import { createRouter } from "@intent/router"
31
import { renderRouter } from "@intent/dom"
4-
import type { RouterServices, RoutesFromPaths, RouteContext } from "@intent/router"
5-
6-
const appPaths = {
7-
home: "/",
8-
"team.details": "/teams/:teamId",
9-
"team.invite": "/teams/:teamId/invite",
10-
} as const
11-
12-
type AppRoutes = RoutesFromPaths<typeof appPaths>
13-
type AppServices = RouterServices<AppRoutes, {
14-
route: RouteContext<AppRoutes>
15-
}>
16-
17-
function delay(ms: number) {
18-
return new Promise(resolve => setTimeout(resolve, ms))
19-
}
20-
21-
let teamLoadVersion = 0
22-
const teamVersions: Record<string, number> = {}
23-
24-
const teams: Record<string, { id: string; name: string; members: string[] }> = {
25-
team_1: { id: "team_1", name: "Alpha", members: [] },
26-
team_2: { id: "team_2", name: "Beta", members: [] },
27-
team_3: { id: "team_3", name: "Gamma", members: [] },
28-
}
29-
30-
async function loadTeam(teamId: string) {
31-
await delay(80)
32-
const team = teams[teamId]
33-
if (!team) throw new Error(`Team "${teamId}" not found`)
34-
const version = ++teamLoadVersion
35-
teamVersions[teamId] = version
36-
return { ...team, version }
37-
}
38-
39-
async function inviteMember(teamId: string, email: string) {
40-
await delay(200)
41-
const team = teams[teamId]
42-
if (!team) throw new Error(`Team "${teamId}" not found`)
43-
team.members.push(email)
44-
}
45-
46-
function updateTeamInfo(teamId: string | undefined) {
47-
const el = document.getElementById("team-info")
48-
if (!el) return
49-
if (!teamId) {
50-
el.textContent = ""
51-
return
52-
}
53-
const team = teams[teamId]
54-
const version = teamVersions[teamId]
55-
if (team) {
56-
const versionStr = version ? `(v${version}) ` : ""
57-
el.textContent = `${versionStr}${team.name}${team.members.length} member${team.members.length === 1 ? "" : "s"}`
58-
}
59-
}
60-
61-
function updateDiagnostics(screenDef: ScreenDefinition<AppServices>) {
62-
const el = document.getElementById("diagnostics")
63-
if (!el) return
64-
const inspected = inspectScreen(screenDef)
65-
if (inspected.diagnostics.length === 0) {
66-
el.textContent = "✓ No diagnostics."
67-
} else {
68-
el.textContent = inspected.diagnostics.map(d =>
69-
`[${d.severity}] ${d.code}${d.nodeId ? ` (${d.nodeId})` : ""}: ${d.message}`
70-
).join("\n")
71-
}
72-
}
73-
74-
const HomeScreen = screen<AppServices>("Home", $ => {
75-
const openAlpha = $.act("Open Alpha — team_1")
76-
.primary()
77-
.does(({ navigate }) => {
78-
navigate("team.details", { teamId: "team_1" })
79-
})
80-
81-
const openBeta = $.act("Open Beta — team_2")
82-
.does(({ navigate }) => {
83-
navigate("team.details", { teamId: "team_2" })
84-
})
85-
86-
const openGamma = $.act("Open Gamma — team_3")
87-
.does(({ navigate }) => {
88-
navigate("team.details", { teamId: "team_3" })
89-
})
90-
91-
$.surface("main").contains(openAlpha, openBeta, openGamma)
92-
})
93-
94-
const TeamDetailScreen = screen<AppServices>("Team Details", $ => {
95-
const team = $.resource("team", {
96-
load: async ({ route }) => {
97-
if (route.name === "team.details") {
98-
return loadTeam(route.params.teamId)
99-
}
100-
throw new Error("Expected team.details route")
101-
},
102-
})
103-
104-
const refresh = $.act("Refresh team")
105-
.does(async ({ route }) => {
106-
await team.reload()
107-
if (route.name === "team.details") {
108-
updateTeamInfo(route.params.teamId)
109-
}
110-
})
111-
.feedback({
112-
pending: "Refreshing...",
113-
success: "Team refreshed.",
114-
failure: "Could not refresh.",
115-
})
116-
117-
const invite = $.act("Invite member")
118-
.primary()
119-
.when(team.ready, "Team must load first.")
120-
.does(({ navigate, route }) => {
121-
if (route.name === "team.details") {
122-
navigate("team.invite", { teamId: route.params.teamId })
123-
}
124-
})
125-
126-
const back = $.act("Back home")
127-
.does(({ navigate }) => {
128-
navigate("home")
129-
})
130-
131-
$.surface("main").contains(refresh, invite, back)
132-
})
133-
134-
const InviteScreen = screen<AppServices>("Invite", $ => {
135-
const emailText = $.state.text("email")
136-
const emailAsk = $.ask("Email", emailText)
137-
.asContact("email")
138-
.required()
139-
.private()
140-
141-
const sendInvite = $.act("Send invite")
142-
.primary()
143-
.when(emailAsk.valid, "Enter a valid email address.")
144-
.does(async ({ navigate, route }) => {
145-
if (route.name === "team.invite") {
146-
await inviteMember(route.params.teamId, emailText.value)
147-
updateTeamInfo(route.params.teamId)
148-
navigate("team.details", { teamId: route.params.teamId })
149-
}
150-
})
151-
.feedback({
152-
pending: "Sending invite...",
153-
success: "Invite sent.",
154-
failure: "Could not send invite.",
155-
})
156-
157-
$.flow("invite")
158-
.startsWith(emailAsk)
159-
.then(sendInvite)
160-
161-
$.surface("main").contains(emailAsk, sendInvite)
162-
})
163-
164-
const NotFoundScreen = screen<AppServices>("Not Found", $ => {
165-
$.surface("main").contains()
166-
})
167-
168-
const router = createRouter<AppServices>()
169-
.route("home", appPaths.home, HomeScreen)
170-
.route("team.details", appPaths["team.details"], TeamDetailScreen)
171-
.route("team.invite", appPaths["team.invite"], InviteScreen)
2+
import { router } from "./router.js"
3+
import { NotFoundScreen } from "./screens.js"
4+
import { updateTeamInfo, updateDiagnostics } from "./demo-panels.js"
1725

1736
const root = document.getElementById("root")!
1747
renderRouter(router, {

examples/web-basic/src/router.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { createRouter } from "@intent/router"
2+
import { appPaths, type AppServices } from "./types.js"
3+
import { HomeScreen, TeamDetailScreen, InviteScreen } from "./screens.js"
4+
5+
export const router = createRouter<AppServices>()
6+
.route("home", appPaths.home, HomeScreen)
7+
.route("team.details", appPaths["team.details"], TeamDetailScreen)
8+
.route("team.invite", appPaths["team.invite"], InviteScreen)

examples/web-basic/src/screens.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { screen } from "@intent/core"
2+
import type { AppServices } from "./types.js"
3+
import { loadTeam, inviteMember } from "./data.js"
4+
5+
export const HomeScreen = screen<AppServices>("Home", $ => {
6+
const openAlpha = $.act("Open Alpha — team_1")
7+
.primary()
8+
.does(({ navigate }) => {
9+
navigate("team.details", { teamId: "team_1" })
10+
})
11+
12+
const openBeta = $.act("Open Beta — team_2")
13+
.does(({ navigate }) => {
14+
navigate("team.details", { teamId: "team_2" })
15+
})
16+
17+
const openGamma = $.act("Open Gamma — team_3")
18+
.does(({ navigate }) => {
19+
navigate("team.details", { teamId: "team_3" })
20+
})
21+
22+
$.surface("main").contains(openAlpha, openBeta, openGamma)
23+
})
24+
25+
export const TeamDetailScreen = screen<AppServices>("Team Details", $ => {
26+
const team = $.resource("team", {
27+
load: async ({ route }) => {
28+
if (route.name === "team.details") {
29+
return loadTeam(route.params.teamId)
30+
}
31+
throw new Error("Expected team.details route")
32+
},
33+
})
34+
35+
const refresh = $.act("Refresh team")
36+
.does(async () => {
37+
await team.reload()
38+
})
39+
.feedback({
40+
pending: "Refreshing...",
41+
success: "Team refreshed.",
42+
failure: "Could not refresh.",
43+
})
44+
45+
const invite = $.act("Invite member")
46+
.primary()
47+
.when(team.ready, "Team must load first.")
48+
.does(({ navigate, route }) => {
49+
if (route.name === "team.details") {
50+
navigate("team.invite", { teamId: route.params.teamId })
51+
}
52+
})
53+
54+
const back = $.act("Back home")
55+
.does(({ navigate }) => {
56+
navigate("home")
57+
})
58+
59+
$.surface("main").contains(refresh, invite, back)
60+
})
61+
62+
export const InviteScreen = screen<AppServices>("Invite", $ => {
63+
const emailText = $.state.text("email")
64+
const emailAsk = $.ask("Email", emailText)
65+
.asContact("email")
66+
.required()
67+
.private()
68+
69+
const sendInvite = $.act("Send invite")
70+
.primary()
71+
.when(emailAsk.valid, "Enter a valid email address.")
72+
.does(async ({ navigate, route }) => {
73+
if (route.name === "team.invite") {
74+
await inviteMember(route.params.teamId, emailText.value)
75+
navigate("team.details", { teamId: route.params.teamId })
76+
}
77+
})
78+
.feedback({
79+
pending: "Sending invite...",
80+
success: "Invite sent.",
81+
failure: "Could not send invite.",
82+
})
83+
84+
$.flow("invite")
85+
.startsWith(emailAsk)
86+
.then(sendInvite)
87+
88+
$.surface("main").contains(emailAsk, sendInvite)
89+
})
90+
91+
export const NotFoundScreen = screen<AppServices>("Not Found", $ => {
92+
$.surface("main").contains()
93+
})

examples/web-basic/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { RouteContext, RouterServices, RoutesFromPaths } from "@intent/router"
2+
3+
export const appPaths = {
4+
home: "/",
5+
"team.details": "/teams/:teamId",
6+
"team.invite": "/teams/:teamId/invite",
7+
} as const
8+
9+
export type AppRoutes = RoutesFromPaths<typeof appPaths>
10+
export type AppServices = RouterServices<AppRoutes, {
11+
route: RouteContext<AppRoutes>
12+
}>

0 commit comments

Comments
 (0)