create-minlang-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin.mjs +58 -0
- package/package.json +28 -0
- package/template/Makefile +21 -0
- package/template/README.md +15 -0
- package/template/__APP_NAME__.ml +240 -0
- package/template/app/app/layout.tsx +5 -0
- package/template/app/app/page.tsx +5 -0
- package/template/app/app/screens/board/page.tsx +5 -0
- package/template/app/app/screens/projects/page.tsx +5 -0
- package/template/app/e2e/01-board.spec.ts +68 -0
- package/template/app/e2e/02-a11y.spec.ts +44 -0
- package/template/app/e2e/03-perf.spec.ts +21 -0
- package/template/app/next.config.mjs +10 -0
- package/template/app/package.json +35 -0
- package/template/app/playwright.config.ts +18 -0
- package/template/app/postcss.config.mjs +6 -0
- package/template/app/seed.ts +17 -0
- package/template/app/tailwind.config.ts +17 -0
- package/template/app/tests/task-flow.test.ts +111 -0
- package/template/app/tsconfig.json +19 -0
- package/template/app/vitest.config.ts +9 -0
- package/template/github/workflows/deploy-vercel.yml +67 -0
- package/template/gitignore +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Codeshift AI Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Scaffold a MinLang web app. Usage: npm create minlang-app <name>
|
|
3
|
+
// The app is one .ml file plus a thin Next.js shell that consumes the
|
|
4
|
+
// published @minlang/* runtime packages. Compile with ml1 (see the printed
|
|
5
|
+
// next steps), run with pnpm, deploy via the included Vercel workflow.
|
|
6
|
+
|
|
7
|
+
import { cpSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const name = process.argv[2];
|
|
12
|
+
if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
13
|
+
console.error("usage: npm create minlang-app <name> (lowercase, digits, dashes)");
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const templateDir = join(dirname(fileURLToPath(import.meta.url)), "template");
|
|
18
|
+
const dest = join(process.cwd(), name);
|
|
19
|
+
mkdirSync(dest, { recursive: false });
|
|
20
|
+
cpSync(templateDir, dest, { recursive: true });
|
|
21
|
+
|
|
22
|
+
// Substitute the app name in every text file.
|
|
23
|
+
const substitute = (dir) => {
|
|
24
|
+
for (const entry of readdirSync(dir)) {
|
|
25
|
+
const path = join(dir, entry);
|
|
26
|
+
if (statSync(path).isDirectory()) {
|
|
27
|
+
substitute(path);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const text = readFileSync(path, "utf8");
|
|
31
|
+
if (text.includes("__APP_NAME__")) {
|
|
32
|
+
writeFileSync(path, text.replaceAll("__APP_NAME__", name));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
substitute(dest);
|
|
37
|
+
renameSync(join(dest, "__APP_NAME__.ml"), join(dest, `${name}.ml`));
|
|
38
|
+
// npm strips dotfiles from published packages; restore them.
|
|
39
|
+
renameSync(join(dest, "gitignore"), join(dest, ".gitignore"));
|
|
40
|
+
renameSync(join(dest, "github"), join(dest, ".github"));
|
|
41
|
+
|
|
42
|
+
console.log(`
|
|
43
|
+
Created ${name}/ — a MinLang web app.
|
|
44
|
+
|
|
45
|
+
Next steps:
|
|
46
|
+
cd ${name}
|
|
47
|
+
# 1. install the ml1 compiler (pick one):
|
|
48
|
+
# brew install codeshift-ai-solutions/tap/ml1
|
|
49
|
+
# bash <(curl -fsSL https://raw.githubusercontent.com/codeshift-ai-solutions/minlang-core/main/install/install.sh)
|
|
50
|
+
# 2. compile, install, run:
|
|
51
|
+
make compile
|
|
52
|
+
pnpm --dir app install
|
|
53
|
+
pnpm --dir app dev
|
|
54
|
+
|
|
55
|
+
Your whole app lives in ${name}.ml (it starts as a task tracker — replace it).
|
|
56
|
+
Authoring guide: https://github.com/codeshift-ai-solutions/minlang-core/blob/main/docs/ai/language/WEB_APP_GUIDE.md
|
|
57
|
+
Deploys: push to GitHub with org secrets VERCEL_TOKEN/VERCEL_ORG_ID set.
|
|
58
|
+
`);
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-minlang-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a MinLang web app: write one .ml file, compile with ml1, deploy.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/codeshift-ai-solutions/minlang-core.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"create-minlang-app": "./bin.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin.mjs",
|
|
16
|
+
"template"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "node --check bin.mjs",
|
|
26
|
+
"test": "node --test test/scaffold.test.mjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
APP := __APP_NAME__
|
|
2
|
+
|
|
3
|
+
.PHONY: validate compile build test test-e2e dev
|
|
4
|
+
|
|
5
|
+
validate:
|
|
6
|
+
ml1 validate $(APP).ml
|
|
7
|
+
|
|
8
|
+
compile: validate
|
|
9
|
+
ml1 compile $(APP).ml --target web --out app
|
|
10
|
+
|
|
11
|
+
build:
|
|
12
|
+
pnpm --dir app run build
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
pnpm --dir app run test
|
|
16
|
+
|
|
17
|
+
test-e2e:
|
|
18
|
+
pnpm --dir app run test:e2e
|
|
19
|
+
|
|
20
|
+
dev:
|
|
21
|
+
pnpm --dir app run dev
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A MinLang web app. The whole application lives in `__APP_NAME__.ml`;
|
|
4
|
+
`app/generated/` is compiler output (committed, never edited); `app/` holds
|
|
5
|
+
the thin Next.js shell consuming the published `@minlang/*` runtime.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
make compile # __APP_NAME__.ml -> app/generated/ (needs ml1 on PATH)
|
|
9
|
+
pnpm --dir app install
|
|
10
|
+
pnpm --dir app dev
|
|
11
|
+
make test # generated test triads + integration
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Authoring guide:
|
|
15
|
+
https://github.com/codeshift-ai-solutions/minlang-core/blob/main/docs/ai/language/WEB_APP_GUIDE.md
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
entity Workspace {
|
|
2
|
+
id: string req
|
|
3
|
+
view: enum(board, projects)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
entity Project {
|
|
7
|
+
id: string req
|
|
8
|
+
name: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
entity Task {
|
|
12
|
+
id: string req
|
|
13
|
+
project: ref(Project)
|
|
14
|
+
title: string
|
|
15
|
+
status: enum(todo, doing, done)
|
|
16
|
+
assignee: string
|
|
17
|
+
created_at: string
|
|
18
|
+
actor_id: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
constraint TitleRequired {
|
|
22
|
+
on: Task
|
|
23
|
+
validate: NOT(self.title == '')
|
|
24
|
+
message: 'title is required'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
constraint AssigneeRequiredWhenDoing {
|
|
28
|
+
on: Task
|
|
29
|
+
validate: NOT(self.status == 'doing' AND self.assignee == '')
|
|
30
|
+
message: 'assignee is required when a task is doing'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
constraint ProjectExists {
|
|
34
|
+
on: Task
|
|
35
|
+
validate: count(Project as p, p == self.project) == 1
|
|
36
|
+
message: 'project does not exist'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
constraint ProjectNameRequired {
|
|
40
|
+
on: Project
|
|
41
|
+
validate: NOT(self.name == '')
|
|
42
|
+
message: 'project name is required'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
constraint UniqueProjectName {
|
|
46
|
+
on: Project
|
|
47
|
+
validate: count(Project as p, p.name == name) == 0
|
|
48
|
+
message: 'a project with this name already exists'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
action CreateProject(id: string, name: string, actor_id: string) {
|
|
52
|
+
on: Project
|
|
53
|
+
create(Project, { id: id, name: name })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
action CreateTask(id: string, project: ref(Project), title: string, status: enum(todo, doing, done), assignee: string, created_at: string, actor_id: string) {
|
|
57
|
+
on: Task
|
|
58
|
+
create(Task, { id: id, project: project, title: title, status: status, assignee: assignee, created_at: created_at, actor_id: actor_id })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
action MoveTask(id: string, status: enum(todo, doing, done), actor_id: string) {
|
|
62
|
+
on: Task
|
|
63
|
+
set(status, status)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
action RenameTask(id: string, title: string, actor_id: string) {
|
|
67
|
+
on: Task
|
|
68
|
+
set(title, title)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
action OpenProjects(workspace: ref(Workspace), actor_id: string) {
|
|
72
|
+
on: Workspace
|
|
73
|
+
set(view, 'projects')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
action OpenBoard(workspace: ref(Workspace), actor_id: string) {
|
|
77
|
+
on: Workspace
|
|
78
|
+
set(view, 'board')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
query TaskBoard(workspace: ref(Workspace)) -> list<Task> {
|
|
82
|
+
from Task
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
query ProjectList(workspace: ref(Workspace)) -> list<Project> {
|
|
86
|
+
from Project
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
query TaskDetail(task: ref(Task)) -> Task {
|
|
90
|
+
from Task
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
screen Board {
|
|
94
|
+
when workspace.view == 'board'
|
|
95
|
+
title "Task board"
|
|
96
|
+
hint "Create tasks, move them between statuses, and rename them."
|
|
97
|
+
board TaskBoard
|
|
98
|
+
primary { label "Add task" action CreateTask }
|
|
99
|
+
button { label "Move task" action MoveTask }
|
|
100
|
+
button { label "Rename task" action RenameTask }
|
|
101
|
+
button { label "Projects" action OpenProjects }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
screen Projects {
|
|
105
|
+
when workspace.view == 'projects'
|
|
106
|
+
title "Projects"
|
|
107
|
+
body "Every task belongs to a project."
|
|
108
|
+
board ProjectList
|
|
109
|
+
primary { label "Add project" action CreateProject }
|
|
110
|
+
button { label "Back to board" action OpenBoard }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test CreateTaskSucceeds {
|
|
114
|
+
setup: [
|
|
115
|
+
create(Workspace, { id: "w1", view: "board" }),
|
|
116
|
+
create(Project, { id: "p1", name: "General" })
|
|
117
|
+
]
|
|
118
|
+
when: CreateTask on Task { id: "t1", project: "p1", title: "Ship the compiler", status: "todo", assignee: "" }
|
|
119
|
+
then: [
|
|
120
|
+
entity_count(Task) == 1,
|
|
121
|
+
entity_has(Task.1, title == "Ship the compiler")
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test TitleRequiredRejectsEmpty {
|
|
126
|
+
setup: [
|
|
127
|
+
create(Workspace, { id: "w1", view: "board" }),
|
|
128
|
+
create(Project, { id: "p1", name: "General" })
|
|
129
|
+
]
|
|
130
|
+
when: CreateTask on Task { id: "t1", project: "p1", title: "", status: "todo", assignee: "" }
|
|
131
|
+
then: [
|
|
132
|
+
error_raised('title is required'),
|
|
133
|
+
entity_count(Task) == 0
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test AssigneeRequiredWhenDoingRejects {
|
|
138
|
+
setup: [
|
|
139
|
+
create(Workspace, { id: "w1", view: "board" }),
|
|
140
|
+
create(Project, { id: "p1", name: "General" })
|
|
141
|
+
]
|
|
142
|
+
when: CreateTask on Task { id: "t1", project: "p1", title: "Ship the compiler", status: "doing", assignee: "" }
|
|
143
|
+
then: [
|
|
144
|
+
error_raised('assignee is required when a task is doing'),
|
|
145
|
+
entity_count(Task) == 0
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test ProjectExistsRejectsUnknown {
|
|
150
|
+
setup: [
|
|
151
|
+
create(Workspace, { id: "w1", view: "board" })
|
|
152
|
+
]
|
|
153
|
+
when: CreateTask on Task { id: "t1", project: "ghost", title: "Ship the compiler", status: "todo", assignee: "" }
|
|
154
|
+
then: [
|
|
155
|
+
error_raised('project does not exist'),
|
|
156
|
+
entity_count(Task) == 0
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
test MoveTaskSucceeds {
|
|
161
|
+
setup: [
|
|
162
|
+
create(Workspace, { id: "w1", view: "board" }),
|
|
163
|
+
create(Project, { id: "p1", name: "General" }),
|
|
164
|
+
create(Task, { id: "t1", project: "p1", title: "Ship the compiler", status: "todo", assignee: "casey", created_at: "2026-01-01T00:00:00Z", actor_id: "a1" })
|
|
165
|
+
]
|
|
166
|
+
when: MoveTask on Task { id: "t1", status: "doing" }
|
|
167
|
+
then: [
|
|
168
|
+
entity_has(Task.1, status == "doing"),
|
|
169
|
+
entity_count(Task) == 1
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test MoveTaskRejectsDoingWithoutAssignee {
|
|
174
|
+
setup: [
|
|
175
|
+
create(Workspace, { id: "w1", view: "board" }),
|
|
176
|
+
create(Project, { id: "p1", name: "General" }),
|
|
177
|
+
create(Task, { id: "t1", project: "p1", title: "Ship the compiler", status: "todo", assignee: "", created_at: "2026-01-01T00:00:00Z", actor_id: "a1" })
|
|
178
|
+
]
|
|
179
|
+
when: MoveTask on Task { id: "t1", status: "doing" }
|
|
180
|
+
then: [
|
|
181
|
+
error_raised('assignee is required when a task is doing'),
|
|
182
|
+
entity_has(Task.1, status == "todo")
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test RenameTaskSucceeds {
|
|
187
|
+
setup: [
|
|
188
|
+
create(Workspace, { id: "w1", view: "board" }),
|
|
189
|
+
create(Project, { id: "p1", name: "General" }),
|
|
190
|
+
create(Task, { id: "t1", project: "p1", title: "Old name", status: "todo", assignee: "", created_at: "2026-01-01T00:00:00Z", actor_id: "a1" })
|
|
191
|
+
]
|
|
192
|
+
when: RenameTask on Task { id: "t1", title: "New name" }
|
|
193
|
+
then: [
|
|
194
|
+
entity_has(Task.1, title == "New name")
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test CreateProjectSucceeds {
|
|
199
|
+
setup: [
|
|
200
|
+
create(Workspace, { id: "w1", view: "board" })
|
|
201
|
+
]
|
|
202
|
+
when: CreateProject on Project { id: "p1", name: "Platform" }
|
|
203
|
+
then: [
|
|
204
|
+
entity_count(Project) == 1,
|
|
205
|
+
entity_has(Project.1, name == "Platform")
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test ProjectNameRequiredRejectsEmpty {
|
|
210
|
+
setup: [
|
|
211
|
+
create(Workspace, { id: "w1", view: "board" })
|
|
212
|
+
]
|
|
213
|
+
when: CreateProject on Project { id: "p1", name: "" }
|
|
214
|
+
then: [
|
|
215
|
+
error_raised('project name is required'),
|
|
216
|
+
entity_count(Project) == 0
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
test UniqueProjectNameRejectsDuplicate {
|
|
221
|
+
setup: [
|
|
222
|
+
create(Workspace, { id: "w1", view: "board" }),
|
|
223
|
+
create(Project, { id: "p0", name: "General" })
|
|
224
|
+
]
|
|
225
|
+
when: CreateProject on Project { id: "p1", name: "General" }
|
|
226
|
+
then: [
|
|
227
|
+
error_raised('a project with this name already exists'),
|
|
228
|
+
entity_count(Project) == 1
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
test OpenProjectsSwitchesView {
|
|
233
|
+
setup: [
|
|
234
|
+
create(Workspace, { id: "w1", view: "board" })
|
|
235
|
+
]
|
|
236
|
+
when: OpenProjects on Workspace { workspace: "w1" }
|
|
237
|
+
then: [
|
|
238
|
+
entity_has(Workspace.1, view == "projects")
|
|
239
|
+
]
|
|
240
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Core journeys: create a task, see the rejection path, move and rename it.
|
|
2
|
+
// Deterministic: every run starts a fresh server with the same seed.
|
|
3
|
+
|
|
4
|
+
import { expect, test } from "@playwright/test";
|
|
5
|
+
|
|
6
|
+
test.describe.configure({ mode: "serial" });
|
|
7
|
+
|
|
8
|
+
test("create task journey: form -> table row", async ({ page }) => {
|
|
9
|
+
await page.goto("/");
|
|
10
|
+
await expect(page.getByRole("heading", { name: "Task board" })).toBeVisible();
|
|
11
|
+
await expect(page.getByRole("note")).toContainText("No task yet");
|
|
12
|
+
|
|
13
|
+
const form = page.getByRole("form", { name: "Create task" });
|
|
14
|
+
await form.getByLabel("Title").fill("Ship the web target");
|
|
15
|
+
await form.getByLabel("Status").selectOption("todo");
|
|
16
|
+
await form.getByLabel("Assignee").fill("casey");
|
|
17
|
+
await form.getByRole("button", { name: "Add task" }).click();
|
|
18
|
+
|
|
19
|
+
const table = page.getByRole("table", { name: "Task board" });
|
|
20
|
+
await expect(table).toBeVisible();
|
|
21
|
+
await expect(table.getByRole("row").filter({ hasText: "Ship the web target" })).toBeVisible();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("validation rejection shows the exact constraint message", async ({ page }) => {
|
|
25
|
+
await page.goto("/");
|
|
26
|
+
const form = page.getByRole("form", { name: "Create task" });
|
|
27
|
+
await form.getByLabel("Status").selectOption("doing");
|
|
28
|
+
await form.getByLabel("Title").fill("Doing without assignee");
|
|
29
|
+
await form.getByRole("button", { name: "Add task" }).click();
|
|
30
|
+
await expect(form.getByRole("alert")).toHaveText("assignee is required when a task is doing");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("move task journey: status changes in the table", async ({ page }) => {
|
|
34
|
+
await page.goto("/");
|
|
35
|
+
const create = page.getByRole("form", { name: "Create task" });
|
|
36
|
+
await create.getByLabel("Title").fill("Move me");
|
|
37
|
+
await create.getByLabel("Assignee").fill("alex");
|
|
38
|
+
await create.getByRole("button", { name: "Add task" }).click();
|
|
39
|
+
await expect(page.getByRole("table", { name: "Task board" })).toBeVisible();
|
|
40
|
+
|
|
41
|
+
const move = page.getByRole("form", { name: "Move task" });
|
|
42
|
+
await move.getByLabel("Id").selectOption({ label: "Move me" });
|
|
43
|
+
await move.getByLabel("Status").selectOption("doing");
|
|
44
|
+
await move.getByRole("button", { name: "Move task" }).click();
|
|
45
|
+
const row = page.getByRole("row").filter({ hasText: "Move me" });
|
|
46
|
+
await expect(row).toContainText("doing");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rename task journey + projects navigation", async ({ page }) => {
|
|
50
|
+
await page.goto("/");
|
|
51
|
+
const create = page.getByRole("form", { name: "Create task" });
|
|
52
|
+
await create.getByLabel("Title").fill("Old title");
|
|
53
|
+
await create.getByRole("button", { name: "Add task" }).click();
|
|
54
|
+
await expect(page.getByRole("table", { name: "Task board" })).toBeVisible();
|
|
55
|
+
|
|
56
|
+
const rename = page.getByRole("form", { name: "Rename task" });
|
|
57
|
+
await rename.getByLabel("Id").selectOption({ label: "Old title" });
|
|
58
|
+
await rename.getByLabel("Title").fill("New title");
|
|
59
|
+
await rename.getByRole("button", { name: "Rename task" }).click();
|
|
60
|
+
await expect(page.getByRole("row").filter({ hasText: "New title" })).toBeVisible();
|
|
61
|
+
|
|
62
|
+
await page.goto("/screens/projects");
|
|
63
|
+
await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible();
|
|
64
|
+
const project = page.getByRole("form", { name: "Create project" });
|
|
65
|
+
await project.getByLabel("Name").fill("Platform");
|
|
66
|
+
await project.getByRole("button", { name: "Add project" }).click();
|
|
67
|
+
await expect(page.getByRole("row").filter({ hasText: "Platform" })).toBeVisible();
|
|
68
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// WCAG 2.1 AA gate: axe-core scans per screen (no serious/critical
|
|
2
|
+
// violations) and a keyboard-only create journey.
|
|
3
|
+
|
|
4
|
+
import AxeBuilder from "@axe-core/playwright";
|
|
5
|
+
import { expect, test } from "@playwright/test";
|
|
6
|
+
|
|
7
|
+
const scan = async (page: import("@playwright/test").Page): Promise<void> => {
|
|
8
|
+
const results = await new AxeBuilder({ page })
|
|
9
|
+
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
|
10
|
+
.analyze();
|
|
11
|
+
const serious = results.violations.filter(
|
|
12
|
+
(v) => v.impact === "serious" || v.impact === "critical",
|
|
13
|
+
);
|
|
14
|
+
expect(serious, JSON.stringify(serious, null, 2)).toEqual([]);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
test("board screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
|
|
18
|
+
await page.goto("/");
|
|
19
|
+
await expect(page.getByRole("heading", { name: "Task board" })).toBeVisible();
|
|
20
|
+
await scan(page);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("projects screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
|
|
24
|
+
await page.goto("/screens/projects");
|
|
25
|
+
await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible();
|
|
26
|
+
await scan(page);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("keyboard-only journey: create a task without the mouse", async ({ page }) => {
|
|
30
|
+
await page.goto("/");
|
|
31
|
+
const form = page.getByRole("form", { name: "Create task" });
|
|
32
|
+
await form.getByLabel("Project").focus();
|
|
33
|
+
await page.keyboard.press("Tab"); // -> Title
|
|
34
|
+
await page.keyboard.type("Keyboard task");
|
|
35
|
+
await page.keyboard.press("Tab"); // -> Status (select)
|
|
36
|
+
await page.keyboard.press("Tab"); // -> Assignee
|
|
37
|
+
await page.keyboard.type("sam");
|
|
38
|
+
await page.keyboard.press("Tab"); // -> submit
|
|
39
|
+
await expect(form.getByRole("button", { name: "Add task" })).toBeFocused();
|
|
40
|
+
await page.keyboard.press("Enter");
|
|
41
|
+
await expect(
|
|
42
|
+
page.getByRole("row").filter({ hasText: "Keyboard task" }),
|
|
43
|
+
).toBeVisible();
|
|
44
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Performance budget smoke: initial route JS <= 120KB compressed
|
|
2
|
+
// (ADR-0016, revised 2026-06-11) measured by gzipping every script
|
|
3
|
+
// response on first load.
|
|
4
|
+
|
|
5
|
+
import { gzipSync } from "node:zlib";
|
|
6
|
+
import { expect, test } from "@playwright/test";
|
|
7
|
+
|
|
8
|
+
test("initial route JS stays within the 120KB compressed budget", async ({ page }) => {
|
|
9
|
+
const sizes: Array<number> = [];
|
|
10
|
+
page.on("response", (response) => {
|
|
11
|
+
const url = response.url();
|
|
12
|
+
if (url.includes("/_next/static/") && url.endsWith(".js")) {
|
|
13
|
+
void response.body().then((body) => {
|
|
14
|
+
sizes.push(gzipSync(body).length);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
await page.goto("/", { waitUntil: "networkidle" });
|
|
19
|
+
const totalKb = sizes.reduce((sum, n) => sum + n, 0) / 1024;
|
|
20
|
+
expect(totalKb).toBeLessThanOrEqual(120);
|
|
21
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "next dev",
|
|
6
|
+
"build": "next build",
|
|
7
|
+
"start": "next start --port 3111",
|
|
8
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:e2e": "next build && playwright test"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@minlang/design-system": "^0.1.0",
|
|
14
|
+
"@minlang/runtime-web": "^0.1.0",
|
|
15
|
+
"@minlang/tailwind-preset": "^0.1.0",
|
|
16
|
+
"next": "15.5.19",
|
|
17
|
+
"react": "19.2.7",
|
|
18
|
+
"react-dom": "19.2.7",
|
|
19
|
+
"zod": "3.23.8"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@axe-core/playwright": "4.11.3",
|
|
23
|
+
"@playwright/test": "1.60.0",
|
|
24
|
+
"@types/node": "22.19.21",
|
|
25
|
+
"@types/react": "19.2.17",
|
|
26
|
+
"@vitejs/plugin-react": "4.7.0",
|
|
27
|
+
"autoprefixer": "10.4.21",
|
|
28
|
+
"jsdom": "25.0.1",
|
|
29
|
+
"postcss": "8.5.6",
|
|
30
|
+
"tailwindcss": "3.4.19",
|
|
31
|
+
"typescript": "5.6.3",
|
|
32
|
+
"vitest": "3.2.6"
|
|
33
|
+
},
|
|
34
|
+
"packageManager": "pnpm@10.33.0"
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: "./e2e",
|
|
5
|
+
fullyParallel: false,
|
|
6
|
+
workers: 1,
|
|
7
|
+
retries: 0,
|
|
8
|
+
reporter: [["list"]],
|
|
9
|
+
use: {
|
|
10
|
+
baseURL: "http://localhost:3111",
|
|
11
|
+
},
|
|
12
|
+
webServer: {
|
|
13
|
+
command: "pnpm run start",
|
|
14
|
+
url: "http://localhost:3111",
|
|
15
|
+
reuseExistingServer: false,
|
|
16
|
+
timeout: 60_000,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// App wiring: bind the data adapter (in-memory default) with the
|
|
2
|
+
// deterministic demo seed. Imported for its side effect by app/layout.tsx so
|
|
3
|
+
// it runs once in the route-server bundle before any render or action.
|
|
4
|
+
// WEB_DATA_ADAPTER=memory is the supported adapter for plan completion; a
|
|
5
|
+
// real adapter (USE_REAL_SERVICES=true) would bind here instead.
|
|
6
|
+
|
|
7
|
+
import { createInMemoryAdapter } from "@minlang/runtime-web";
|
|
8
|
+
import { bindServerAdapter } from "@minlang/runtime-web/server";
|
|
9
|
+
import { emptyAppState } from "./generated/state";
|
|
10
|
+
|
|
11
|
+
bindServerAdapter(
|
|
12
|
+
createInMemoryAdapter({
|
|
13
|
+
...emptyAppState,
|
|
14
|
+
workspaces: [{ id: "w1", view: "board" }],
|
|
15
|
+
projects: [{ id: "p1", name: "General" }],
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Theme wiring: the preset (token-mapped utilities) + content globs covering
|
|
2
|
+
// the generated app and the design system. Restyle by changing tokens.
|
|
3
|
+
|
|
4
|
+
import { minlangPreset } from "@minlang/tailwind-preset";
|
|
5
|
+
import type { Config } from "tailwindcss";
|
|
6
|
+
|
|
7
|
+
const config: Config = {
|
|
8
|
+
presets: [minlangPreset],
|
|
9
|
+
content: [
|
|
10
|
+
"./app/**/*.{ts,tsx}",
|
|
11
|
+
"./generated/**/*.{ts,tsx}",
|
|
12
|
+
"./node_modules/@minlang/design-system/dist/**/*.js",
|
|
13
|
+
"./node_modules/@minlang/runtime-web/dist/**/*.js",
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default config;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// BDD: end-to-end domain flows through dispatch + the in-memory adapter —
|
|
2
|
+
// create → move → rename journeys, rejection leaves state unchanged, and a
|
|
3
|
+
// deterministic replay over a fixed input trace.
|
|
4
|
+
|
|
5
|
+
import { createInMemoryAdapter, dispatch } from "@minlang/runtime-web";
|
|
6
|
+
import type { DataAdapter, DispatchDeps } from "@minlang/runtime-web";
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { applyCreateTask, applyMoveTask, applyRenameTask } from "../generated/actions";
|
|
9
|
+
import type { CreateTaskInput } from "../generated/actions";
|
|
10
|
+
import { runTaskBoard } from "../generated/queries";
|
|
11
|
+
import { emptyAppState } from "../generated/state";
|
|
12
|
+
import type { AppState } from "../generated/state";
|
|
13
|
+
|
|
14
|
+
const seed: AppState = {
|
|
15
|
+
...emptyAppState,
|
|
16
|
+
workspaces: [{ id: "w1", view: "board" }],
|
|
17
|
+
projects: [{ id: "p1", name: "General" }],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const fixedDeps = (adapter: DataAdapter<AppState>): DispatchDeps<AppState> => ({
|
|
21
|
+
adapter,
|
|
22
|
+
clock: { currentTime: () => "2026-01-01T00:00:00Z" },
|
|
23
|
+
rng: { draw: () => 7 },
|
|
24
|
+
identity: { currentActorId: () => "test-actor" },
|
|
25
|
+
logger: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
|
26
|
+
correlationId: "c-test",
|
|
27
|
+
emptyState: emptyAppState,
|
|
28
|
+
newId: () => "id-fixed",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const taskInput = (overrides: Partial<CreateTaskInput>): CreateTaskInput => ({
|
|
32
|
+
id: "t1",
|
|
33
|
+
project: "p1",
|
|
34
|
+
title: "Ship the web target",
|
|
35
|
+
status: "todo",
|
|
36
|
+
assignee: "",
|
|
37
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
38
|
+
actor_id: "test-actor",
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("task-tracker domain flows", () => {
|
|
43
|
+
test("create → move → rename journey persists each step", async () => {
|
|
44
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
45
|
+
const deps = fixedDeps(adapter);
|
|
46
|
+
expect((await dispatch(applyCreateTask, taskInput({}), deps)).ok).toBe(true);
|
|
47
|
+
expect(
|
|
48
|
+
(await dispatch(applyMoveTask, { id: "t1", status: "doing", actor_id: "a" }, deps)).ok,
|
|
49
|
+
).toBe(false); // assignee required when doing
|
|
50
|
+
expect(
|
|
51
|
+
(
|
|
52
|
+
await dispatch(
|
|
53
|
+
applyRenameTask,
|
|
54
|
+
{ id: "t1", title: "Ship it", actor_id: "a" },
|
|
55
|
+
deps,
|
|
56
|
+
)
|
|
57
|
+
).ok,
|
|
58
|
+
).toBe(true);
|
|
59
|
+
const state = (await adapter.load()) ?? emptyAppState;
|
|
60
|
+
expect(runTaskBoard(state, { workspace: "w1" }).map((t) => t.title)).toEqual(["Ship it"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejection returns the exact constraint message and persists nothing", async () => {
|
|
64
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
65
|
+
const outcome = await dispatch(
|
|
66
|
+
applyCreateTask,
|
|
67
|
+
taskInput({ title: "" }),
|
|
68
|
+
fixedDeps(adapter),
|
|
69
|
+
);
|
|
70
|
+
expect(outcome.ok).toBe(false);
|
|
71
|
+
if (!outcome.ok) {
|
|
72
|
+
expect(outcome.error.message).toBe("title is required");
|
|
73
|
+
}
|
|
74
|
+
expect(((await adapter.load()) ?? emptyAppState).tasks).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("unknown project is rejected by the count join", async () => {
|
|
78
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
79
|
+
const outcome = await dispatch(
|
|
80
|
+
applyCreateTask,
|
|
81
|
+
taskInput({ project: "ghost" }),
|
|
82
|
+
fixedDeps(adapter),
|
|
83
|
+
);
|
|
84
|
+
expect(outcome.ok).toBe(false);
|
|
85
|
+
if (!outcome.ok) {
|
|
86
|
+
expect(outcome.error.message).toBe("project does not exist");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("replay: a fixed trace yields a deep-equal final state across runs", async () => {
|
|
91
|
+
const run = async (): Promise<AppState> => {
|
|
92
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
93
|
+
const deps = fixedDeps(adapter);
|
|
94
|
+
await dispatch(applyCreateTask, taskInput({ id: "t1", title: "One" }), deps);
|
|
95
|
+
await dispatch(applyCreateTask, taskInput({ id: "t2", title: "Two" }), deps);
|
|
96
|
+
await dispatch(
|
|
97
|
+
applyMoveTask,
|
|
98
|
+
{ id: "t2", status: "done", actor_id: "test-actor" },
|
|
99
|
+
deps,
|
|
100
|
+
);
|
|
101
|
+
await dispatch(applyRenameTask, { id: "t1", title: "Won", actor_id: "test-actor" }, deps);
|
|
102
|
+
return (await adapter.load()) ?? emptyAppState;
|
|
103
|
+
};
|
|
104
|
+
const [first, second] = [await run(), await run()];
|
|
105
|
+
expect(first).toEqual(second);
|
|
106
|
+
expect(runTaskBoard(first, { workspace: "w1" }).map((t) => [t.title, t.status])).toEqual([
|
|
107
|
+
["Won", "todo"],
|
|
108
|
+
["Two", "done"],
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@minlang/runtime-web/tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"jsx": "preserve",
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"incremental": true,
|
|
7
|
+
"plugins": [{ "name": "next" }]
|
|
8
|
+
},
|
|
9
|
+
"include": [
|
|
10
|
+
"next-env.d.ts",
|
|
11
|
+
"*.ts",
|
|
12
|
+
"app/**/*",
|
|
13
|
+
"generated/**/*",
|
|
14
|
+
"tests/**/*",
|
|
15
|
+
"e2e/**/*",
|
|
16
|
+
".next/types/**/*.ts"
|
|
17
|
+
],
|
|
18
|
+
"exclude": ["node_modules"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Verifies the committed generated output matches the .ml source (via the
|
|
2
|
+
# minlang-compile action), tests, builds, and deploys prebuilt to Vercel.
|
|
3
|
+
# Org-level secrets: VERCEL_TOKEN + VERCEL_ORG_ID (skips when absent).
|
|
4
|
+
# The minlang actions require the minlang-core repo's Actions access setting
|
|
5
|
+
# to allow this repository.
|
|
6
|
+
|
|
7
|
+
name: Deploy (Vercel)
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
branches: [main]
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
concurrency:
|
|
15
|
+
group: deploy-vercel
|
|
16
|
+
cancel-in-progress: true
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
deploy:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
env:
|
|
22
|
+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
|
23
|
+
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
|
|
27
|
+
- name: Generated output is current
|
|
28
|
+
uses: codeshift-ai-solutions/minlang-core/.github/actions/minlang-compile@main
|
|
29
|
+
with:
|
|
30
|
+
file: __APP_NAME__.ml
|
|
31
|
+
target: web
|
|
32
|
+
out: app
|
|
33
|
+
check: "true"
|
|
34
|
+
|
|
35
|
+
- name: Setup Node + pnpm
|
|
36
|
+
uses: actions/setup-node@v4
|
|
37
|
+
with:
|
|
38
|
+
node-version: 22
|
|
39
|
+
- run: corepack enable
|
|
40
|
+
|
|
41
|
+
- name: Install, typecheck, test
|
|
42
|
+
working-directory: app
|
|
43
|
+
run: |
|
|
44
|
+
pnpm install --frozen-lockfile
|
|
45
|
+
pnpm run typecheck
|
|
46
|
+
pnpm run test
|
|
47
|
+
|
|
48
|
+
- name: Gate on Vercel secrets
|
|
49
|
+
id: gate
|
|
50
|
+
run: |
|
|
51
|
+
if [ -z "${VERCEL_TOKEN}" ] || [ -z "${VERCEL_ORG_ID}" ]; then
|
|
52
|
+
echo "notice: VERCEL_TOKEN / VERCEL_ORG_ID not set — skipping deploy"
|
|
53
|
+
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
|
54
|
+
else
|
|
55
|
+
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Link, build, deploy
|
|
59
|
+
if: steps.gate.outputs.enabled == 'true'
|
|
60
|
+
working-directory: app
|
|
61
|
+
run: |
|
|
62
|
+
pnpm dlx vercel@latest link --yes \
|
|
63
|
+
--project "${{ github.event.repository.name }}" --token "${VERCEL_TOKEN}"
|
|
64
|
+
pnpm dlx vercel@latest pull --yes --environment=production --token "${VERCEL_TOKEN}"
|
|
65
|
+
pnpm dlx vercel@latest build --prod --token "${VERCEL_TOKEN}"
|
|
66
|
+
url=$(pnpm dlx vercel@latest deploy --prebuilt --prod --yes --token "${VERCEL_TOKEN}")
|
|
67
|
+
echo "deployed: $url" | tee -a "$GITHUB_STEP_SUMMARY"
|