create-minlang-app 0.2.0 → 0.4.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/bin.mjs +2 -1
- package/package.json +1 -1
- package/template/AGENTS.md +17 -0
- package/template/Makefile +12 -1
- package/template/__APP_NAME__.ml +110 -176
- package/template/app/e2e/01-welcome.spec.ts +36 -0
- package/template/app/e2e/02-a11y.spec.ts +12 -16
- package/template/app/e2e/99-screens.spec.ts +32 -0
- package/template/app/package.json +3 -3
- package/template/app/seed.ts +23 -6
- package/template/app/skins/README.md +15 -0
- package/template/app/tests/welcome-flow.test.ts +77 -0
- package/template/gitattributes +4 -0
- package/template/gitignore +1 -0
- package/template/scripts/lint-skins.mjs +197 -0
- package/template/app/e2e/01-board.spec.ts +0 -68
- package/template/app/tests/task-flow.test.ts +0 -111
package/bin.mjs
CHANGED
|
@@ -37,6 +37,7 @@ substitute(dest);
|
|
|
37
37
|
renameSync(join(dest, "__APP_NAME__.ml"), join(dest, `${name}.ml`));
|
|
38
38
|
// npm strips dotfiles from published packages; restore them.
|
|
39
39
|
renameSync(join(dest, "gitignore"), join(dest, ".gitignore"));
|
|
40
|
+
renameSync(join(dest, "gitattributes"), join(dest, ".gitattributes"));
|
|
40
41
|
renameSync(join(dest, "github"), join(dest, ".github"));
|
|
41
42
|
|
|
42
43
|
// .github/ only works at the repository root: warn when the scaffold is
|
|
@@ -64,7 +65,7 @@ Next steps:
|
|
|
64
65
|
pnpm --dir app install
|
|
65
66
|
make dev # http://localhost:3111
|
|
66
67
|
|
|
67
|
-
Your whole app lives in ${name}.ml (it starts as a
|
|
68
|
+
Your whole app lives in ${name}.ml (it starts as a MinLang welcome tour — replace it).
|
|
68
69
|
Agent instructions: ${name}/AGENTS.md
|
|
69
70
|
Language rules: https://github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md
|
|
70
71
|
Deploys: push to GitHub with org secrets VERCEL_TOKEN/VERCEL_ORG_ID set;
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -27,6 +27,17 @@ detector list, and the end-to-end web-app authoring guide. Highlights:
|
|
|
27
27
|
- Generate -> validate -> fix -> revalidate until zero violations, then
|
|
28
28
|
output. `ml1 validate` is the same gate CI runs.
|
|
29
29
|
|
|
30
|
+
## Language version & updates
|
|
31
|
+
|
|
32
|
+
This repo is pinned to a MinLang language version in `minlang.json`
|
|
33
|
+
(`languageBundleVersion`, written by `ml1 init`). Run `make update` (or
|
|
34
|
+
`ml1 update`) to pull a newer compiler + language. When the language has
|
|
35
|
+
moved on, update writes `MINLANG_MIGRATION.md` — a checklist of
|
|
36
|
+
old-form -> new-form recommendations. Read it, apply the changes to
|
|
37
|
+
`__APP_NAME__.ml`, run `make compile && make test` until green, then delete
|
|
38
|
+
it. `ml1 update` advances the pin for additive upgrades and never edits your
|
|
39
|
+
`.ml` itself.
|
|
40
|
+
|
|
30
41
|
## Commands
|
|
31
42
|
|
|
32
43
|
| Task | Command |
|
|
@@ -36,9 +47,12 @@ detector list, and the end-to-end web-app authoring guide. Highlights:
|
|
|
36
47
|
| Compile to `app/generated/` | `make compile` |
|
|
37
48
|
| Install shell deps | `pnpm --dir app install` |
|
|
38
49
|
| Unit + generated test triads | `make test` |
|
|
50
|
+
| Lint custom skins (`app/skins/`) | `make lint-skins` (also runs in `make test`) |
|
|
39
51
|
| Dev server (http://localhost:3111) | `make dev` |
|
|
52
|
+
| Update toolchain, language pin + deps | `make update` (writes MINLANG_MIGRATION.md if the language moved on) |
|
|
40
53
|
| Production build | `make build` |
|
|
41
54
|
| Browser tests (a11y, perf) | `make test-e2e` (installs the browser on first run) |
|
|
55
|
+
| Screen previews (`screen-previews/*.png`, all screens, mobile + desktop) | `make preview` |
|
|
42
56
|
|
|
43
57
|
`make compile` requires the `ml1` compiler on PATH — install per README.
|
|
44
58
|
If `pnpm typecheck` complains about routes you deleted, remove the stale
|
|
@@ -58,3 +72,6 @@ user-facing copy, or input validation in shell code — they belong in
|
|
|
58
72
|
`__APP_NAME__.ml`, where the validator and generated tests cover them.
|
|
59
73
|
Screens route through the single dynamic page
|
|
60
74
|
`app/app/screens/[screen]/page.tsx`; do not create per-screen folders.
|
|
75
|
+
Custom widget renderers ("skins") go in `app/skins/` — see the Skins
|
|
76
|
+
section of the language bundle; `make lint-skins` enforces the contract
|
|
77
|
+
(no hardcoded copy, no raw colors, no fetch/Date.now/adapter imports).
|
package/template/Makefile
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
APP := __APP_NAME__
|
|
2
2
|
|
|
3
|
-
.PHONY: validate compile build test test-e2e dev
|
|
3
|
+
.PHONY: validate compile build test test-e2e lint-skins preview dev update
|
|
4
4
|
|
|
5
5
|
validate:
|
|
6
6
|
ml1 validate $(APP).ml
|
|
@@ -13,10 +13,21 @@ build:
|
|
|
13
13
|
|
|
14
14
|
test:
|
|
15
15
|
pnpm --dir app run test
|
|
16
|
+
node scripts/lint-skins.mjs app/skins
|
|
17
|
+
|
|
18
|
+
lint-skins:
|
|
19
|
+
node scripts/lint-skins.mjs app/skins
|
|
16
20
|
|
|
17
21
|
test-e2e:
|
|
18
22
|
pnpm --dir app exec playwright install chromium
|
|
19
23
|
pnpm --dir app run test:e2e
|
|
20
24
|
|
|
25
|
+
preview:
|
|
26
|
+
pnpm --dir app exec playwright install chromium
|
|
27
|
+
pnpm --dir app run test:e2e e2e/99-screens.spec.ts
|
|
28
|
+
|
|
21
29
|
dev:
|
|
22
30
|
pnpm --dir app run dev
|
|
31
|
+
|
|
32
|
+
update:
|
|
33
|
+
ml1 update
|
package/template/__APP_NAME__.ml
CHANGED
|
@@ -1,240 +1,174 @@
|
|
|
1
|
-
|
|
1
|
+
// Welcome to MinLang — the starter app `ml1 init` / `npm create minlang-app`
|
|
2
|
+
// scaffolds. The WHOLE app is this one file: entities (data shape),
|
|
3
|
+
// constraints (the only place rules live), actions (pure create/set/delete),
|
|
4
|
+
// queries (read-only view-models), screens (declarative UI + copy), and
|
|
5
|
+
// tests (the success / failure / no-change triad). Edit this file, run
|
|
6
|
+
// `make compile`, and the generated app updates. Delete the demo below and
|
|
7
|
+
// model your own domain when you're ready.
|
|
8
|
+
|
|
9
|
+
// --- Navigation state -------------------------------------------------------
|
|
10
|
+
// A single Tour row drives which screen is active (see the `when` selectors).
|
|
11
|
+
entity Tour {
|
|
2
12
|
id: string req
|
|
3
|
-
|
|
13
|
+
page: enum(welcome, files, enhance, resources)
|
|
4
14
|
}
|
|
5
15
|
|
|
6
|
-
|
|
16
|
+
// --- A tiny live demo: a guestbook note -------------------------------------
|
|
17
|
+
// Shows the core loop — a form creates a row, a constraint guards it, a board
|
|
18
|
+
// lists it, and tests prove all three.
|
|
19
|
+
entity Note {
|
|
7
20
|
id: string req
|
|
8
|
-
|
|
21
|
+
greeting: string
|
|
9
22
|
}
|
|
10
23
|
|
|
11
|
-
|
|
24
|
+
// --- Seeded content shown as tables (rows live in app/seed.ts) --------------
|
|
25
|
+
entity FileGuide {
|
|
12
26
|
id: string req
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
status: enum(todo, doing, done)
|
|
16
|
-
assignee: string
|
|
17
|
-
created_at: string
|
|
18
|
-
actor_id: string
|
|
27
|
+
file: string
|
|
28
|
+
purpose: string
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 })
|
|
31
|
+
entity BuildStep {
|
|
32
|
+
id: string req
|
|
33
|
+
step: string
|
|
34
|
+
detail: string
|
|
59
35
|
}
|
|
60
36
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
37
|
+
entity Resource {
|
|
38
|
+
id: string req
|
|
39
|
+
topic: string
|
|
40
|
+
link: string
|
|
64
41
|
}
|
|
65
42
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
43
|
+
// --- Rules live in constraints, never in actions ----------------------------
|
|
44
|
+
constraint GreetingRequired {
|
|
45
|
+
on: Note
|
|
46
|
+
validate: NOT(self.greeting == '')
|
|
47
|
+
message: 'a greeting is required'
|
|
69
48
|
}
|
|
70
49
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
50
|
+
// --- Actions are pure mutation (create / set / delete) ----------------------
|
|
51
|
+
action AddNote(id: string, greeting: string, actor_id: string) {
|
|
52
|
+
on: Note
|
|
53
|
+
create(Note, { id: id, greeting: greeting })
|
|
74
54
|
}
|
|
75
55
|
|
|
76
|
-
action
|
|
77
|
-
on:
|
|
78
|
-
set(
|
|
56
|
+
action ShowWelcome(tour: ref(Tour), actor_id: string) {
|
|
57
|
+
on: Tour
|
|
58
|
+
set(page, 'welcome')
|
|
79
59
|
}
|
|
80
60
|
|
|
81
|
-
|
|
82
|
-
|
|
61
|
+
action ShowFiles(tour: ref(Tour), actor_id: string) {
|
|
62
|
+
on: Tour
|
|
63
|
+
set(page, 'files')
|
|
83
64
|
}
|
|
84
65
|
|
|
85
|
-
|
|
86
|
-
|
|
66
|
+
action ShowEnhance(tour: ref(Tour), actor_id: string) {
|
|
67
|
+
on: Tour
|
|
68
|
+
set(page, 'enhance')
|
|
87
69
|
}
|
|
88
70
|
|
|
89
|
-
|
|
90
|
-
|
|
71
|
+
action ShowResources(tour: ref(Tour), actor_id: string) {
|
|
72
|
+
on: Tour
|
|
73
|
+
set(page, 'resources')
|
|
91
74
|
}
|
|
92
75
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 }
|
|
76
|
+
// --- Queries are read-only view-models --------------------------------------
|
|
77
|
+
query NoteList(tour: ref(Tour)) -> list<Note> {
|
|
78
|
+
from Note
|
|
102
79
|
}
|
|
103
80
|
|
|
104
|
-
|
|
105
|
-
|
|
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 }
|
|
81
|
+
query FileList(tour: ref(Tour)) -> list<FileGuide> {
|
|
82
|
+
from FileGuide
|
|
111
83
|
}
|
|
112
84
|
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
]
|
|
85
|
+
query StepList(tour: ref(Tour)) -> list<BuildStep> {
|
|
86
|
+
from BuildStep
|
|
135
87
|
}
|
|
136
88
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
]
|
|
89
|
+
query ResourceList(tour: ref(Tour)) -> list<Resource> {
|
|
90
|
+
from Resource
|
|
147
91
|
}
|
|
148
92
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
93
|
+
// --- Screens: declarative flow + copy (all text lives here) -----------------
|
|
94
|
+
screen Welcome {
|
|
95
|
+
when tour.page == 'welcome'
|
|
96
|
+
title "Welcome to MinLang"
|
|
97
|
+
body "The whole app is one .ml file. Edit it, run make compile, and this UI updates."
|
|
98
|
+
hint "Try the demo: add a note below, then take the tour."
|
|
99
|
+
board NoteList
|
|
100
|
+
primary { label "Say hello" action AddNote }
|
|
101
|
+
button { label "What's in this project" action ShowFiles }
|
|
102
|
+
button { label "Learn more" action ShowResources }
|
|
158
103
|
}
|
|
159
104
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
then: [
|
|
168
|
-
entity_has(Task.1, status == "doing"),
|
|
169
|
-
entity_count(Task) == 1
|
|
170
|
-
]
|
|
105
|
+
screen Files {
|
|
106
|
+
when tour.page == 'files'
|
|
107
|
+
title "What's in this project"
|
|
108
|
+
body "Everything is generated from your .ml source. Edit it, never app/generated."
|
|
109
|
+
board FileList
|
|
110
|
+
primary { label "How to enhance it" action ShowEnhance }
|
|
111
|
+
button { label "Back to start" action ShowWelcome }
|
|
171
112
|
}
|
|
172
113
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
then: [
|
|
181
|
-
error_raised('assignee is required when a task is doing'),
|
|
182
|
-
entity_has(Task.1, status == "todo")
|
|
183
|
-
]
|
|
114
|
+
screen Enhance {
|
|
115
|
+
when tour.page == 'enhance'
|
|
116
|
+
title "Make it yours"
|
|
117
|
+
body "Describe your own domain in the .ml, then compile and test."
|
|
118
|
+
board StepList
|
|
119
|
+
primary { label "Resources" action ShowResources }
|
|
120
|
+
button { label "Back to start" action ShowWelcome }
|
|
184
121
|
}
|
|
185
122
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
entity_has(Task.1, title == "New name")
|
|
195
|
-
]
|
|
123
|
+
screen Resources {
|
|
124
|
+
when tour.page == 'resources'
|
|
125
|
+
title "Learn more"
|
|
126
|
+
body "The language bundle has the full rules and the web authoring guide."
|
|
127
|
+
hint "Fetch it: github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md"
|
|
128
|
+
board ResourceList
|
|
129
|
+
primary { label "Back to start" action ShowWelcome }
|
|
130
|
+
button { label "What's in this project" action ShowFiles }
|
|
196
131
|
}
|
|
197
132
|
|
|
198
|
-
|
|
133
|
+
// --- Tests: the success / failure / no-change triad -------------------------
|
|
134
|
+
test AddNoteSucceeds {
|
|
199
135
|
setup: [
|
|
200
|
-
create(
|
|
136
|
+
create(Tour, { id: "t1", page: "welcome" })
|
|
201
137
|
]
|
|
202
|
-
when:
|
|
138
|
+
when: AddNote on Note { id: "n1", greeting: "Hello, MinLang" }
|
|
203
139
|
then: [
|
|
204
|
-
entity_count(
|
|
205
|
-
entity_has(
|
|
140
|
+
entity_count(Note) == 1,
|
|
141
|
+
entity_has(Note.1, greeting == "Hello, MinLang")
|
|
206
142
|
]
|
|
207
143
|
}
|
|
208
144
|
|
|
209
|
-
test
|
|
145
|
+
test GreetingRequiredRejectsEmpty {
|
|
210
146
|
setup: [
|
|
211
|
-
create(
|
|
147
|
+
create(Tour, { id: "t1", page: "welcome" })
|
|
212
148
|
]
|
|
213
|
-
when:
|
|
149
|
+
when: AddNote on Note { id: "n1", greeting: "" }
|
|
214
150
|
then: [
|
|
215
|
-
error_raised('
|
|
216
|
-
entity_count(
|
|
151
|
+
error_raised('a greeting is required'),
|
|
152
|
+
entity_count(Note) == 0
|
|
217
153
|
]
|
|
218
154
|
}
|
|
219
155
|
|
|
220
|
-
test
|
|
156
|
+
test ShowFilesSwitchesPage {
|
|
221
157
|
setup: [
|
|
222
|
-
create(
|
|
223
|
-
create(Project, { id: "p0", name: "General" })
|
|
158
|
+
create(Tour, { id: "t1", page: "welcome" })
|
|
224
159
|
]
|
|
225
|
-
when:
|
|
160
|
+
when: ShowFiles on Tour { tour: "t1" }
|
|
226
161
|
then: [
|
|
227
|
-
|
|
228
|
-
entity_count(Project) == 1
|
|
162
|
+
entity_has(Tour.1, page == "files")
|
|
229
163
|
]
|
|
230
164
|
}
|
|
231
165
|
|
|
232
|
-
test
|
|
166
|
+
test ShowWelcomeReturnsHome {
|
|
233
167
|
setup: [
|
|
234
|
-
create(
|
|
168
|
+
create(Tour, { id: "t1", page: "files" })
|
|
235
169
|
]
|
|
236
|
-
when:
|
|
170
|
+
when: ShowWelcome on Tour { tour: "t1" }
|
|
237
171
|
then: [
|
|
238
|
-
entity_has(
|
|
172
|
+
entity_has(Tour.1, page == "welcome")
|
|
239
173
|
]
|
|
240
174
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Core journeys: land on the welcome screen, add a greeting through the demo
|
|
2
|
+
// form, see the rejection path, and confirm the tour screens render their
|
|
3
|
+
// seeded tables. Deterministic: every run starts a fresh server with the
|
|
4
|
+
// same seed.
|
|
5
|
+
|
|
6
|
+
import { expect, test } from "@playwright/test";
|
|
7
|
+
|
|
8
|
+
test.describe.configure({ mode: "serial" });
|
|
9
|
+
|
|
10
|
+
test("welcome screen: add a greeting -> table row", async ({ page }) => {
|
|
11
|
+
await page.goto("/");
|
|
12
|
+
await expect(page.getByRole("heading", { name: "Welcome to MinLang" })).toBeVisible();
|
|
13
|
+
|
|
14
|
+
const form = page.getByRole("form", { name: "Add note" });
|
|
15
|
+
await form.getByLabel("Greeting").fill("Hi there");
|
|
16
|
+
await form.getByRole("button", { name: "Say hello" }).click();
|
|
17
|
+
|
|
18
|
+
await expect(page.getByRole("row").filter({ hasText: "Hi there" })).toBeVisible();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("validation rejection shows the exact constraint message", async ({ page }) => {
|
|
22
|
+
await page.goto("/");
|
|
23
|
+
const form = page.getByRole("form", { name: "Add note" });
|
|
24
|
+
await form.getByRole("button", { name: "Say hello" }).click();
|
|
25
|
+
await expect(form.getByRole("alert")).toHaveText("a greeting is required");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("tour screens render their seeded content tables", async ({ page }) => {
|
|
29
|
+
await page.goto("/screens/files");
|
|
30
|
+
await expect(page.getByRole("heading", { name: "What's in this project" })).toBeVisible();
|
|
31
|
+
await expect(page.getByRole("row").filter({ hasText: "app/generated/" })).toBeVisible();
|
|
32
|
+
|
|
33
|
+
await page.goto("/screens/resources");
|
|
34
|
+
await expect(page.getByRole("heading", { name: "Learn more" })).toBeVisible();
|
|
35
|
+
await expect(page.getByRole("row").filter({ hasText: "Language rules" })).toBeVisible();
|
|
36
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG 2.1 AA gate: axe-core scans per screen (no serious/critical
|
|
2
|
-
// violations) and a keyboard-only
|
|
2
|
+
// violations) and a keyboard-only "add a greeting" journey.
|
|
3
3
|
|
|
4
4
|
import AxeBuilder from "@axe-core/playwright";
|
|
5
5
|
import { expect, test } from "@playwright/test";
|
|
@@ -14,31 +14,27 @@ const scan = async (page: import("@playwright/test").Page): Promise<void> => {
|
|
|
14
14
|
expect(serious, JSON.stringify(serious, null, 2)).toEqual([]);
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
test("
|
|
17
|
+
test("welcome screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
|
|
18
18
|
await page.goto("/");
|
|
19
|
-
await expect(page.getByRole("heading", { name: "
|
|
19
|
+
await expect(page.getByRole("heading", { name: "Welcome to MinLang" })).toBeVisible();
|
|
20
20
|
await scan(page);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("
|
|
24
|
-
await page.goto("/screens/
|
|
25
|
-
await expect(page.getByRole("heading", { name: "
|
|
23
|
+
test("files screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
|
|
24
|
+
await page.goto("/screens/files");
|
|
25
|
+
await expect(page.getByRole("heading", { name: "What's in this project" })).toBeVisible();
|
|
26
26
|
await scan(page);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
test("keyboard-only journey:
|
|
29
|
+
test("keyboard-only journey: add a greeting without the mouse", async ({ page }) => {
|
|
30
30
|
await page.goto("/");
|
|
31
|
-
const form = page.getByRole("form", { name: "
|
|
32
|
-
await form.getByLabel("
|
|
33
|
-
await page.keyboard.
|
|
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");
|
|
31
|
+
const form = page.getByRole("form", { name: "Add note" });
|
|
32
|
+
await form.getByLabel("Greeting").focus();
|
|
33
|
+
await page.keyboard.type("Keyboard hello");
|
|
38
34
|
await page.keyboard.press("Tab"); // -> submit
|
|
39
|
-
await expect(form.getByRole("button", { name: "
|
|
35
|
+
await expect(form.getByRole("button", { name: "Say hello" })).toBeFocused();
|
|
40
36
|
await page.keyboard.press("Enter");
|
|
41
37
|
await expect(
|
|
42
|
-
page.getByRole("row").filter({ hasText: "Keyboard
|
|
38
|
+
page.getByRole("row").filter({ hasText: "Keyboard hello" }),
|
|
43
39
|
).toBeVisible();
|
|
44
40
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Screen previews: a full-page screenshot of every manifest screen at
|
|
2
|
+
// mobile (390x844) and desktop (1280x800) viewports, written to
|
|
3
|
+
// screen-previews/ (gitignored) for the CI artifact and `make preview`.
|
|
4
|
+
// No assertions beyond successful page load. Runs last (99-) so data
|
|
5
|
+
// created by earlier specs makes the previews realistic.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { expect, test } from "@playwright/test";
|
|
10
|
+
import manifest from "../generated/manifest.json";
|
|
11
|
+
|
|
12
|
+
const viewports = [
|
|
13
|
+
{ name: "mobile", width: 390, height: 844 },
|
|
14
|
+
{ name: "desktop", width: 1280, height: 800 },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
const outDir = join(__dirname, "..", "screen-previews");
|
|
18
|
+
|
|
19
|
+
for (const screen of manifest.screens) {
|
|
20
|
+
for (const viewport of viewports) {
|
|
21
|
+
test(`preview: ${screen.key} (${viewport.name})`, async ({ page }) => {
|
|
22
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
23
|
+
await page.goto(screen.route, { waitUntil: "networkidle" });
|
|
24
|
+
await expect(page.getByRole("heading", { level: 1 }).first()).toBeVisible();
|
|
25
|
+
mkdirSync(outDir, { recursive: true });
|
|
26
|
+
await page.screenshot({
|
|
27
|
+
path: join(outDir, `${screen.key}-${viewport.name}.png`),
|
|
28
|
+
fullPage: true,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"test:e2e": "next build && playwright test"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@minlang/design-system": "^0.2.
|
|
14
|
-
"@minlang/runtime-web": "^0.2.
|
|
15
|
-
"@minlang/tailwind-preset": "^0.2.
|
|
13
|
+
"@minlang/design-system": "^0.2.1",
|
|
14
|
+
"@minlang/runtime-web": "^0.2.1",
|
|
15
|
+
"@minlang/tailwind-preset": "^0.2.1",
|
|
16
16
|
"next": "15.5.19",
|
|
17
17
|
"react": "19.2.7",
|
|
18
18
|
"react-dom": "19.2.7",
|
package/template/app/seed.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// App wiring: bind the data adapter (in-memory default) with the
|
|
2
|
-
//
|
|
1
|
+
// App wiring: bind the data adapter (in-memory default) with the welcome
|
|
2
|
+
// app's starting content. Imported for its side effect by app/layout.tsx so
|
|
3
3
|
// it runs once in the route-server bundle before any render or action.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// The tables on the Files / Enhance / Resources screens are seeded here; the
|
|
5
|
+
// notes list starts empty so the first thing you do is add one.
|
|
6
6
|
|
|
7
7
|
import { createInMemoryAdapter } from "@minlang/runtime-web";
|
|
8
8
|
import { bindServerAdapter } from "@minlang/runtime-web/server";
|
|
@@ -11,7 +11,24 @@ import { emptyAppState } from "./generated/state";
|
|
|
11
11
|
bindServerAdapter(
|
|
12
12
|
createInMemoryAdapter({
|
|
13
13
|
...emptyAppState,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
tours: [{ id: "t1", page: "welcome" }],
|
|
15
|
+
fileGuides: [
|
|
16
|
+
{ id: "f1", file: "*.ml", purpose: "The whole app: entities, rules, actions, screens, tests." },
|
|
17
|
+
{ id: "f2", file: "app/generated/", purpose: "Compiler output. Committed, never hand-edited." },
|
|
18
|
+
{ id: "f3", file: "app/", purpose: "Thin Next.js shell over the @minlang runtime." },
|
|
19
|
+
{ id: "f4", file: "minlang.json", purpose: "Pins the MinLang language + compiler versions." },
|
|
20
|
+
{ id: "f5", file: "AGENTS.md", purpose: "Instructions for AI coding agents working here." },
|
|
21
|
+
],
|
|
22
|
+
buildSteps: [
|
|
23
|
+
{ id: "s1", step: "Edit welcome.ml", detail: "Model your domain: entities, constraints, actions, screens." },
|
|
24
|
+
{ id: "s2", step: "make compile", detail: "Regenerate app/generated from the .ml source." },
|
|
25
|
+
{ id: "s3", step: "make test", detail: "Run the generated success / failure / no-change triads." },
|
|
26
|
+
{ id: "s4", step: "make dev", detail: "Open http://localhost:3111 and use your app." },
|
|
27
|
+
],
|
|
28
|
+
resources: [
|
|
29
|
+
{ id: "r1", topic: "Language rules", link: "github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md" },
|
|
30
|
+
{ id: "r2", topic: "Run it", link: "make dev, then http://localhost:3111" },
|
|
31
|
+
{ id: "r3", topic: "Enhance it", link: "Edit welcome.ml, then make compile && make test" },
|
|
32
|
+
],
|
|
16
33
|
}),
|
|
17
34
|
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Skins
|
|
2
|
+
|
|
3
|
+
Custom renderers ("skins") for widget keys, registered in `../seed.ts` via
|
|
4
|
+
`bindRegistryOverrides({ <widget>: Component })`. One skin per file
|
|
5
|
+
(`<widget-key>.tsx`), presentation only: props are exactly `WidgetProps<K>`,
|
|
6
|
+
copy comes only from the schema node, styling uses design-token classes
|
|
7
|
+
(never hex/rgb literals), and state changes go through the provided
|
|
8
|
+
server-action refs.
|
|
9
|
+
|
|
10
|
+
The full recipe (including adapting Figma Make exports) is the **Skins**
|
|
11
|
+
section of the language bundle:
|
|
12
|
+
https://github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md
|
|
13
|
+
|
|
14
|
+
The contract is mechanically enforced — `make lint-skins`
|
|
15
|
+
(`node scripts/lint-skins.mjs app/skins`) runs as part of `make test` and CI.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// BDD: the welcome demo's domain flows through dispatch + the in-memory
|
|
2
|
+
// adapter — add a greeting, see the rejection path keep state unchanged,
|
|
3
|
+
// navigate between screens, and replay a fixed trace deterministically.
|
|
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 { applyAddNote, applyShowFiles } from "../generated/actions";
|
|
9
|
+
import type { AddNoteInput } from "../generated/actions";
|
|
10
|
+
import { runNoteList } from "../generated/queries";
|
|
11
|
+
import { emptyAppState } from "../generated/state";
|
|
12
|
+
import type { AppState } from "../generated/state";
|
|
13
|
+
|
|
14
|
+
const seed: AppState = {
|
|
15
|
+
...emptyAppState,
|
|
16
|
+
tours: [{ id: "t1", page: "welcome" }],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const fixedDeps = (adapter: DataAdapter<AppState>): DispatchDeps<AppState> => ({
|
|
20
|
+
adapter,
|
|
21
|
+
clock: { currentTime: () => "2026-01-01T00:00:00Z" },
|
|
22
|
+
rng: { draw: () => 7 },
|
|
23
|
+
identity: { currentActorId: () => "test-actor" },
|
|
24
|
+
logger: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
|
25
|
+
correlationId: "c-test",
|
|
26
|
+
emptyState: emptyAppState,
|
|
27
|
+
newId: () => "id-fixed",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const noteInput = (overrides: Partial<AddNoteInput>): AddNoteInput => ({
|
|
31
|
+
id: "n1",
|
|
32
|
+
greeting: "Hello, MinLang",
|
|
33
|
+
actor_id: "test-actor",
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("welcome demo flows", () => {
|
|
38
|
+
test("adding a greeting persists it to the notes list", async () => {
|
|
39
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
40
|
+
const deps = fixedDeps(adapter);
|
|
41
|
+
expect((await dispatch(applyAddNote, noteInput({}), deps)).ok).toBe(true);
|
|
42
|
+
const state = (await adapter.load()) ?? emptyAppState;
|
|
43
|
+
expect(runNoteList(state, { tour: "t1" }).map((n) => n.greeting)).toEqual(["Hello, MinLang"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("an empty greeting is rejected with the exact constraint message", async () => {
|
|
47
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
48
|
+
const outcome = await dispatch(applyAddNote, noteInput({ greeting: "" }), fixedDeps(adapter));
|
|
49
|
+
expect(outcome.ok).toBe(false);
|
|
50
|
+
if (!outcome.ok) {
|
|
51
|
+
expect(outcome.error.message).toBe("a greeting is required");
|
|
52
|
+
}
|
|
53
|
+
expect(((await adapter.load()) ?? emptyAppState).notes).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("navigation switches the active page", async () => {
|
|
57
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
58
|
+
const deps = fixedDeps(adapter);
|
|
59
|
+
expect((await dispatch(applyShowFiles, { tour: "t1", actor_id: "a" }, deps)).ok).toBe(true);
|
|
60
|
+
const state = (await adapter.load()) ?? emptyAppState;
|
|
61
|
+
expect(state.tours[0]?.page).toBe("files");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("replay: a fixed trace yields a deep-equal final state across runs", async () => {
|
|
65
|
+
const run = async (): Promise<AppState> => {
|
|
66
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
67
|
+
const deps = fixedDeps(adapter);
|
|
68
|
+
await dispatch(applyAddNote, noteInput({ id: "n1", greeting: "One" }), deps);
|
|
69
|
+
await dispatch(applyAddNote, noteInput({ id: "n2", greeting: "Two" }), deps);
|
|
70
|
+
await dispatch(applyShowFiles, { tour: "t1", actor_id: "test-actor" }, deps);
|
|
71
|
+
return (await adapter.load()) ?? emptyAppState;
|
|
72
|
+
};
|
|
73
|
+
const [first, second] = [await run(), await run()];
|
|
74
|
+
expect(first).toEqual(second);
|
|
75
|
+
expect(runNoteList(first, { tour: "t1" }).map((n) => n.greeting)).toEqual(["One", "Two"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/template/gitignore
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mechanical SHELL_CONTRACT lint for registry skins (spec/target-web/SKIN_GUIDE.md).
|
|
3
|
+
//
|
|
4
|
+
// Usage: node scripts/lint-skins.mjs [dir...] (default: app/skins, if it exists)
|
|
5
|
+
//
|
|
6
|
+
// Scans .ts/.tsx files line by line and exits 1 with file:line findings on:
|
|
7
|
+
// jsx-text hardcoded copy as JSX text (.tsx only — heuristic below)
|
|
8
|
+
// color-literal #hex (3/4/6/8) · rgb(/rgba(/hsl(/hsla( · Tailwind -[# classes
|
|
9
|
+
// forbidden-api fetch( · Date.now · Math.random · new Date( · toLocale*
|
|
10
|
+
// adapter-import imports of @minlang/runtime-web/server or any */adapters/* path
|
|
11
|
+
//
|
|
12
|
+
// jsx-text heuristic (documented contract): string literals are blanked, the
|
|
13
|
+
// line is cut at any // comment, =>/<=/>= and space-surrounded </> comparison
|
|
14
|
+
// operators are blanked, {…} JSX expressions are removed; the line is flagged when (a) letters remain between a `>` and a `<`
|
|
15
|
+
// on the same line, or (b) the line is bare prose (letters/spaces/simple
|
|
16
|
+
// punctuation, not a JS keyword) and the previous non-blank line ends with `>`
|
|
17
|
+
// but not `=>`. Block comments are not masked. The shipped design system must
|
|
18
|
+
// lint clean (regression-tested in scripts/tests/lint-skins.test.mjs).
|
|
19
|
+
//
|
|
20
|
+
// Opt-out, per offending line: trailing `// skin-lint-allow: <rule>[, <rule>...]`.
|
|
21
|
+
// Exit codes: 0 clean · 1 findings · 2 usage error (explicit dir missing).
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/;
|
|
27
|
+
const COLOR_FN = /\b(?:rgb|rgba|hsl|hsla)\s*\(/;
|
|
28
|
+
const ARBITRARY_COLOR = /-\[#/;
|
|
29
|
+
const FORBIDDEN_APIS = [
|
|
30
|
+
[/\bfetch\s*\(/, "fetch("],
|
|
31
|
+
[/\bDate\.now\b/, "Date.now"],
|
|
32
|
+
[/\bMath\.random\b/, "Math.random"],
|
|
33
|
+
[/\bnew\s+Date\s*\(/, "new Date("],
|
|
34
|
+
[/toLocale/, "toLocale* (locale-ambient formatting)"],
|
|
35
|
+
];
|
|
36
|
+
const SERVER_IMPORT = /@minlang\/runtime-web\/server/;
|
|
37
|
+
const ADAPTER_PATH = /["'][^"']*\/adapters\/[^"']*["']/;
|
|
38
|
+
const JS_KEYWORDS = new Set([
|
|
39
|
+
"return", "break", "continue", "else", "try", "finally", "default", "do",
|
|
40
|
+
"case", "new", "typeof", "void", "delete", "await", "yield", "async",
|
|
41
|
+
"export", "import", "const", "let", "var", "function", "class", "if",
|
|
42
|
+
"for", "while", "switch", "throw", "true", "false", "null", "undefined",
|
|
43
|
+
]);
|
|
44
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "generated", "coverage"]);
|
|
45
|
+
|
|
46
|
+
/** Blank string-literal contents, preserving length and the quote chars. */
|
|
47
|
+
const blankStrings = (line) =>
|
|
48
|
+
line.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, (m) =>
|
|
49
|
+
m[0] + " ".repeat(m.length - 2) + m[0],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** Remove {…} JSX expressions and blank arrow/comparison operators. */
|
|
53
|
+
const dropExpressions = (text) => {
|
|
54
|
+
let t = text.replace(/=>|<=|>=/g, " ").replace(/ [<>] /g, " ");
|
|
55
|
+
for (let prev = ""; prev !== t; ) {
|
|
56
|
+
prev = t;
|
|
57
|
+
t = t.replace(/\{[^{}]*\}/g, "");
|
|
58
|
+
}
|
|
59
|
+
return t;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const BARE_PROSE = /^[A-Za-z][A-Za-z0-9 ,.'!?-]*$/;
|
|
63
|
+
|
|
64
|
+
/** Lint one file; returns findings [{line, rule, message}]. */
|
|
65
|
+
const lintFile = (path) => {
|
|
66
|
+
const findings = [];
|
|
67
|
+
const isTsx = path.endsWith(".tsx");
|
|
68
|
+
let prevCode = "";
|
|
69
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
70
|
+
lines.forEach((raw, index) => {
|
|
71
|
+
const blanked = blankStrings(raw);
|
|
72
|
+
const commentIdx = blanked.indexOf("//");
|
|
73
|
+
const allowed = new Set();
|
|
74
|
+
let code = raw;
|
|
75
|
+
let codeBlanked = blanked;
|
|
76
|
+
if (commentIdx !== -1) {
|
|
77
|
+
const match = raw.slice(commentIdx).match(/skin-lint-allow:\s*([a-z, -]+)/);
|
|
78
|
+
for (const rule of match === null ? [] : match[1].split(",")) {
|
|
79
|
+
allowed.add(rule.trim());
|
|
80
|
+
}
|
|
81
|
+
code = raw.slice(0, commentIdx);
|
|
82
|
+
codeBlanked = blanked.slice(0, commentIdx);
|
|
83
|
+
}
|
|
84
|
+
const report = (rule, message) => {
|
|
85
|
+
if (!allowed.has(rule)) {
|
|
86
|
+
findings.push({ line: index + 1, rule, message });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
checkColors(code, report);
|
|
90
|
+
checkForbiddenApis(codeBlanked, report);
|
|
91
|
+
checkAdapterImports(code, report);
|
|
92
|
+
if (isTsx) {
|
|
93
|
+
checkJsxText(codeBlanked, prevCode, report);
|
|
94
|
+
}
|
|
95
|
+
if (code.trim() !== "") {
|
|
96
|
+
prevCode = code;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return findings;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const checkColors = (code, report) => {
|
|
103
|
+
if (HEX_COLOR.test(code)) {
|
|
104
|
+
report("color-literal", "hex color literal — style via design tokens / token classes");
|
|
105
|
+
}
|
|
106
|
+
if (COLOR_FN.test(code)) {
|
|
107
|
+
report("color-literal", "rgb()/hsl() color literal — style via design tokens");
|
|
108
|
+
}
|
|
109
|
+
if (ARBITRARY_COLOR.test(code)) {
|
|
110
|
+
report("color-literal", "Tailwind arbitrary color class — use token classes");
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const checkForbiddenApis = (codeBlanked, report) => {
|
|
115
|
+
for (const [pattern, name] of FORBIDDEN_APIS) {
|
|
116
|
+
if (pattern.test(codeBlanked)) {
|
|
117
|
+
report("forbidden-api", `${name} is forbidden in skins (SHELL_CONTRACT nondeterminism/IO)`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const checkAdapterImports = (code, report) => {
|
|
123
|
+
if (SERVER_IMPORT.test(code)) {
|
|
124
|
+
report("adapter-import", "@minlang/runtime-web/server import — skins never touch the adapter");
|
|
125
|
+
}
|
|
126
|
+
if (ADAPTER_PATH.test(code)) {
|
|
127
|
+
report("adapter-import", "adapter/store import — skins read only the schema node and deps");
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const checkJsxText = (codeBlanked, prevCode, report) => {
|
|
132
|
+
const masked = dropExpressions(codeBlanked);
|
|
133
|
+
const between = masked.match(/>([^<>]*[A-Za-z][^<>]*)</);
|
|
134
|
+
if (between !== null) {
|
|
135
|
+
report("jsx-text", `hardcoded JSX text "${between[1].trim()}" — copy must come from the schema node`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const bare = masked.trim();
|
|
139
|
+
const prev = prevCode.trim();
|
|
140
|
+
if (
|
|
141
|
+
BARE_PROSE.test(bare) &&
|
|
142
|
+
!JS_KEYWORDS.has(bare) &&
|
|
143
|
+
prev.endsWith(">") &&
|
|
144
|
+
!prev.endsWith("=>")
|
|
145
|
+
) {
|
|
146
|
+
report("jsx-text", `hardcoded JSX text "${bare}" — copy must come from the schema node`);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const collectFiles = (dir, out) => {
|
|
151
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
152
|
+
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const path = join(dir, entry);
|
|
156
|
+
if (statSync(path).isDirectory()) {
|
|
157
|
+
collectFiles(path, out);
|
|
158
|
+
} else if (/\.tsx?$/.test(entry)) {
|
|
159
|
+
out.push(path);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const main = () => {
|
|
166
|
+
const args = process.argv.slice(2);
|
|
167
|
+
let dirs = args;
|
|
168
|
+
if (args.length === 0) {
|
|
169
|
+
if (!existsSync("app/skins")) {
|
|
170
|
+
console.log("lint-skins: no app/skins directory — nothing to lint");
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
dirs = ["app/skins"];
|
|
174
|
+
}
|
|
175
|
+
for (const dir of dirs) {
|
|
176
|
+
if (!existsSync(dir)) {
|
|
177
|
+
console.error(`lint-skins: no such directory: ${dir}`);
|
|
178
|
+
return 2;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const files = dirs.flatMap((dir) => collectFiles(dir, []));
|
|
182
|
+
let total = 0;
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
for (const finding of lintFile(file)) {
|
|
185
|
+
total += 1;
|
|
186
|
+
console.error(`${file}:${finding.line}: [${finding.rule}] ${finding.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (total > 0) {
|
|
190
|
+
console.error(`lint-skins: ${total} finding(s) in ${files.length} file(s)`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
console.log(`lint-skins: ${files.length} file(s) clean`);
|
|
194
|
+
return 0;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
process.exit(main());
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
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
|
-
});
|