@ethlete/agent-rules 0.1.0-next.16 → 0.1.0-next.18

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +14 -6
  3. package/content/hooks/subagent-model-policy.py +5 -3
  4. package/content/rules/app-styling.md +47 -0
  5. package/content/rules/comments.md +4 -1
  6. package/content/rules/nx-layout.md +47 -0
  7. package/content/rules/styling.md +1 -1
  8. package/content/rules/subagent-models.md +7 -7
  9. package/content/skills/angular-patterns/SKILL.md +23 -0
  10. package/content/skills/app-testing/SKILL.md +141 -0
  11. package/content/skills/design-exploration/SKILL.md +3 -1
  12. package/content/skills/git-flow/SKILL.md +4 -4
  13. package/content/skills/query/SKILL.md +150 -82
  14. package/content/skills/rxjs-signals/SKILL.md +20 -0
  15. package/content/skills/sdk-docs/SKILL.md +35 -7
  16. package/content/skills/sdk-update/SKILL.md +12 -7
  17. package/content/skills/story-styling/SKILL.md +7 -6
  18. package/content/skills/styleguide/lint-rule-lookup.md +1 -1
  19. package/content/skills/theming/SKILL.md +3 -4
  20. package/content/skills/timetrack/SKILL.md +154 -49
  21. package/content/skills/verify-in-app/SKILL.md +107 -0
  22. package/migrations/app-styling-utilities.md +114 -0
  23. package/migrations/list-state-query-form.md +103 -0
  24. package/migrations/nx-layout.md +23 -0
  25. package/migrations/sdk-components-over-hand-built-ui.md +69 -0
  26. package/migrations/search-query-field.md +45 -0
  27. package/migrations.json +43 -0
  28. package/package.json +4 -1
  29. package/src/index.js +5 -4
  30. package/src/index.js.map +1 -1
  31. package/src/lib/config.d.ts +2 -0
  32. package/src/lib/config.js +16 -3
  33. package/src/lib/config.js.map +1 -1
  34. package/src/lib/filter.d.ts +2 -0
  35. package/src/lib/filter.js +4 -4
  36. package/src/lib/filter.js.map +1 -1
  37. package/src/lib/frontmatter.js +32 -5
  38. package/src/lib/frontmatter.js.map +1 -1
  39. package/src/lib/git-flow/parse.js +38 -12
  40. package/src/lib/git-flow/parse.js.map +1 -1
  41. package/src/lib/git-flow-repair.js +12 -1
  42. package/src/lib/git-flow-repair.js.map +1 -1
  43. package/src/lib/git.js +8 -2
  44. package/src/lib/git.js.map +1 -1
  45. package/src/lib/gitlab.js +11 -1
  46. package/src/lib/gitlab.js.map +1 -1
  47. package/src/lib/migrate.js +1 -1
  48. package/src/lib/migrate.js.map +1 -1
  49. package/src/lib/output-style.js +11 -3
  50. package/src/lib/output-style.js.map +1 -1
  51. package/src/lib/owned-paths.js +8 -2
  52. package/src/lib/owned-paths.js.map +1 -1
  53. package/src/lib/package-runner.d.ts +1 -0
  54. package/src/lib/package-runner.js +34 -0
  55. package/src/lib/package-runner.js.map +1 -0
  56. package/src/lib/plan.js +29 -3
  57. package/src/lib/plan.js.map +1 -1
  58. package/src/lib/render.d.ts +4 -0
  59. package/src/lib/render.js +8 -2
  60. package/src/lib/render.js.map +1 -1
  61. package/src/lib/sync.js +22 -12
  62. package/src/lib/sync.js.map +1 -1
  63. package/src/lib/targets/codex.js +1 -1
  64. package/src/lib/targets/codex.js.map +1 -1
  65. package/src/lib/targets/copilot.js +1 -1
  66. package/src/lib/targets/copilot.js.map +1 -1
  67. package/src/lib/targets/git-hooks.js +2 -1
  68. package/src/lib/targets/git-hooks.js.map +1 -1
  69. package/src/lib/targets/hooks-shared.js +1 -1
  70. package/src/lib/targets/hooks-shared.js.map +1 -1
  71. package/src/lib/timetrack-command.js +344 -60
  72. package/src/lib/timetrack-command.js.map +1 -1
  73. package/src/lib/timetrack.d.ts +185 -16
  74. package/src/lib/timetrack.js +66 -15
  75. package/src/lib/timetrack.js.map +1 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: timetrack
3
- description: How to reach Jira from any repo through the running Timetrack app - look up an issue, search for one, ask which project a repo logs into, file a ticket, add a worklog row, read the evidence a day holds, or list the work that still waits for a ticket. Read whenever a task needs Jira data, a Jira write, or the day's own events, and never put a Jira token in a repo.
3
+ description: How to reach Jira from any repo through the running Timetrack app - look up an issue, search for one, ask which project a repo logs into, file a ticket, add a worklog row, read the user's own Tempo worklogs, read their Google Calendar, read the evidence a day holds, or list the work that still waits for a ticket. Read whenever a task needs Jira data, a Jira write, or the day's own events, and never put a Jira token in a repo.
4
4
  kind: skill
5
5
  scope: both
6
6
  ---
@@ -11,16 +11,30 @@ scope: both
11
11
  in this machine's keychain, and every repository asks it:
12
12
 
13
13
  ```bash
14
- npx ethlete-agents timetrack status # is the app reachable, and what does it hold?
15
- npx ethlete-agents timetrack issue FIP-2177 # one issue: summary, type, parent, subject
16
- npx ethlete-agents timetrack search "password" # open issues of the picked projects
17
- npx ethlete-agents timetrack project # which project does this repo log into?
18
- npx ethlete-agents timetrack instance # the instance's own levels and custom fields
19
- npx ethlete-agents timetrack standins # work the user named that Jira does not hold yet
14
+ {%packageRunner%} ethlete-agents timetrack status # is the app reachable, and what does it hold?
15
+ {%packageRunner%} ethlete-agents timetrack issue FIP-2177 # one issue: summary, type, parent, subject
16
+ {%packageRunner%} ethlete-agents timetrack search "password" # open issues of the picked projects
17
+ {%packageRunner%} ethlete-agents timetrack project # which project does this repo log into?
18
+ {%packageRunner%} ethlete-agents timetrack instance # the instance's own levels and custom fields
19
+ {%packageRunner%} ethlete-agents timetrack standins # work the user named that Jira does not hold yet
20
20
  ```
21
21
 
22
22
  Add `--json` to any of them when you need to read a field rather than a line.
23
23
 
24
+ **Every write waits for the user's approval in the app.** `create`, `log`, `edit`, `standins
25
+ --remove | --rename | --merge | --split --force`, `sync --write`, `worklog --delete` and `resync` answer at
26
+ once with an approval id and write nothing yet. The user approves or rejects the request in
27
+ Timetrack; `approval <id>` reads the outcome:
28
+
29
+ ```bash
30
+ {%packageRunner%} ethlete-agents timetrack approval <id> # queued, approved (with the result), rejected or expired
31
+ ```
32
+
33
+ Tell the user a request waits in Timetrack, then read `approval` once they say they decided -
34
+ do not poll in a loop. A request that is still waiting at the end of the day it was asked on
35
+ expires; ask again rather than assuming it landed. Set `TIMETRACK_CLIENT` to name yourself in the
36
+ queue (Claude Code is named without it).
37
+
24
38
  **Never ask the user for a Jira token, and never write one into a file.** If a command reports
25
39
  that the app is not running, say so and ask the user to start it. That is the whole fix -
26
40
  there is no per-repo fallback, by design: a secret copied into every checkout is a secret
@@ -28,23 +42,29 @@ nobody can rotate.
28
42
 
29
43
  ## What each command is for
30
44
 
31
- | Command | Use it when |
32
- | -------------------------------------- | ------------------------------------------------------------------------- |
33
- | `status` | Before anything else, when a Jira command failed and you need the cause |
34
- | `instance` | A setup step needs the instance's levels or its branch-subject field |
35
- | `issue <KEY>` | The user names a key and you need its summary, type or parent |
36
- | `search [text]` | The user describes work but names no key |
37
- | `project [path]` | You need the project a repository files into |
38
- | `create --summary "…"` | The work has no ticket and the user asked for one |
39
- | `log --issue <KEY> --minutes <n>` | The user asks to record time that nothing observed |
40
- | `day [YYYY-MM-DD]` | You need the evidence a day holds, not a screenshot of it |
41
- | `rows [YYYY-MM-DD]` | You need the rows the day drew, and the ids an edit names them by |
42
- | `edit <row-id> …` | The user asks you to correct one row of a day |
43
- | `rules` | You need to know why a band was named, or why it was not |
44
- | `standins` | You need to know which work still waits for a ticket, and for how long |
45
- | `standins --remove <id>` | A placeholder is wrong or too wide, and the user asked you to delete it |
46
- | `standins --rename <id> --name <text>` | The name a placeholder carries is wrong, and the user asked you to fix it |
47
- | `naming [YYYY-MM-DD]` | A checkout was never offered a name and you need the step that stopped |
45
+ | Command | Use it when |
46
+ | ------------------------------------------ | --------------------------------------------------------------------------- |
47
+ | `status` | Before anything else, when a Jira command failed and you need the cause |
48
+ | `instance` | A setup step needs the instance's levels or its branch-subject field |
49
+ | `issue <KEY>` | The user names a key and you need its summary, type or parent |
50
+ | `search [text]` | The user describes work but names no key |
51
+ | `project [path]` | You need the project a repository files into |
52
+ | `create --summary "…"` | The work has no ticket and the user asked for one |
53
+ | `log --issue <KEY> --minutes <n>` | The user asks to record time that nothing observed |
54
+ | `day [YYYY-MM-DD]` | You need the evidence a day holds, not a screenshot of it |
55
+ | `rows [YYYY-MM-DD]` | You need the rows the day drew, and the ids an edit names them by |
56
+ | `edit <row-id> …` | The user asks you to correct one row of a day |
57
+ | `rules` | You need to know why a band was named, or why it was not |
58
+ | `standins` | You need to know which work still waits for a ticket, and for how long |
59
+ | `standins --remove <id>` | A placeholder is wrong or too wide, and the user asked you to delete it |
60
+ | `standins --rename <id> --name <text>` | The name a placeholder carries is wrong, and the user asked you to fix it |
61
+ | `standins --merge <id> --into <id>` | Two placeholders are one piece of work, and the user asked you to join them |
62
+ | `naming [YYYY-MM-DD]` | A checkout was never offered a name and you need the step that stopped |
63
+ | `worklogs [from] [to]` | You need what the user already booked in Tempo over a span of days |
64
+ | `calendar [from] [to]` | You need the user's meetings and calendar entries over a span of days |
65
+ | `sync <YYYY-MM-DD>` | The user asks what a Tempo sync of a day would write, or asks to sync it |
66
+ | `worklog --delete <id> --day <YYYY-MM-DD>` | The user asked you to delete one of their own Tempo worklogs |
67
+ | `approval <id>` | A write you queued: whether the user approved it, and what it answered |
48
68
 
49
69
  `git-flow start` uses the same channel, so a branch is named from the real issue rather than
50
70
  from a key you typed. Follow the repository's branch workflow when creating a branch.
@@ -54,8 +74,8 @@ from a key you typed. Follow the repository's branch workflow when creating a br
54
74
  The app's store is encrypted, so no shell reads a day off disk. `day` is the only way in:
55
75
 
56
76
  ```bash
57
- npx ethlete-agents timetrack day # today: how many events, and of which kind
58
- npx ethlete-agents timetrack day 2026-09-10 --out /tmp/day.json
77
+ {%packageRunner%} ethlete-agents timetrack day # today: how many events, and of which kind
78
+ {%packageRunner%} ethlete-agents timetrack day 2026-09-10 --out /tmp/day.json
59
79
  ```
60
80
 
61
81
  A real day holds thousands of events, so **never print them**. Write them to a file with
@@ -72,22 +92,23 @@ day as it was observed - window titles, paths and messages - so delete it when y
72
92
  timeline, with the id every edit names a row by, the day's totals and its warnings.
73
93
 
74
94
  ```bash
75
- npx ethlete-agents timetrack rows # today
76
- npx ethlete-agents timetrack rows 2026-09-10 --json
95
+ {%packageRunner%} ethlete-agents timetrack rows # today
96
+ {%packageRunner%} ethlete-agents timetrack rows 2026-09-10 --json
77
97
  ```
78
98
 
79
99
  `edit` changes one row. Pass exactly one change per call:
80
100
 
81
101
  ```bash
82
- npx ethlete-agents timetrack edit 'ABC-1@2026-09-10T11:00:00.000Z' --day 2026-09-10 \
102
+ {%packageRunner%} ethlete-agents timetrack edit 'ABC-1@2026-09-10T11:00:00.000Z' --day 2026-09-10 \
83
103
  --from 2026-09-10T13:15:00Z --to 2026-09-10T17:00:00Z
84
- npx ethlete-agents timetrack edit '<row-id>' --issue ABC-2
85
- npx ethlete-agents timetrack edit '<row-id>' --state rejected
104
+ {%packageRunner%} ethlete-agents timetrack edit '<row-id>' --issue ABC-2
105
+ {%packageRunner%} ethlete-agents timetrack edit '<row-id>' --state rejected
86
106
  ```
87
107
 
88
- Both commands move the app's own review to that day, so the user sees what you read and what you
89
- changed. `applied` says how many edits found their row: a running day is re-cut on every collector
90
- tick, so read `rows` again rather than reusing an id from minutes ago.
108
+ `rows` moves the app's own review to that day, so the user sees what you read. An edit is queued
109
+ like every write; once approved, its result's `applied` says how many edits found their row. A
110
+ running day is re-cut on every collector tick, so read `rows` again rather than reusing an id from
111
+ minutes ago.
91
112
 
92
113
  An edit may correct a row's times, its name, its note and whether it syncs. It may not split, merge
93
114
  or delete one - restructuring a day is the user's own decision, made on the screen.
@@ -99,8 +120,8 @@ the background projects and the applications the user has ruled in or out. It ho
99
120
  account and no token, so it is safe to quote back to the user.
100
121
 
101
122
  ```bash
102
- npx ethlete-agents timetrack rules # a summary of the rules
103
- npx ethlete-agents timetrack rules --json # the whole answer
123
+ {%packageRunner%} ethlete-agents timetrack rules # a summary of the rules
124
+ {%packageRunner%} ethlete-agents timetrack rules --json # the whole answer
104
125
  ```
105
126
 
106
127
  Read it before claiming a band should have been named something: a rule that donates its time
@@ -113,8 +134,8 @@ A **stand-in** is a name the user gave work that Jira does not hold yet. It take
113
134
  days and across checkouts, and it books nothing. `standins` lists them:
114
135
 
115
136
  ```bash
116
- npx ethlete-agents timetrack standins # the open ones, oldest first
117
- npx ethlete-agents timetrack standins --json # the whole answer, resolved ones included
137
+ {%packageRunner%} ethlete-agents timetrack standins # the open ones, oldest first
138
+ {%packageRunner%} ethlete-agents timetrack standins --json # the whole answer, resolved ones included
118
139
  ```
119
140
 
120
141
  **Never open or resolve a stand-in.** The name is the user's own word for their work, and the
@@ -150,7 +171,7 @@ reading `--json` yourself.
150
171
  The name is only wrong, and the work behind it is right:
151
172
 
152
173
  ```bash
153
- npx ethlete-agents timetrack standins --rename <id> --name '20260921 competition navigation rework'
174
+ {%packageRunner%} ethlete-agents timetrack standins --rename <id> --name '20260921 competition navigation rework'
154
175
  ```
155
176
 
156
177
  The days it holds and the rules that name it stay, which is what a delete and a fresh record would
@@ -163,8 +184,8 @@ A record covering a whole checkout is repaired rather than deleted, because a de
163
184
  days. `--split` re-cuts it into one record per directory, and moves each day onto the right one:
164
185
 
165
186
  ```bash
166
- npx ethlete-agents timetrack standins --split <id> # the plan, writes nothing
167
- npx ethlete-agents timetrack standins --split <id> --force # carry it out
187
+ {%packageRunner%} ethlete-agents timetrack standins --split <id> # the plan, writes nothing
188
+ {%packageRunner%} ethlete-agents timetrack standins --split <id> --force # queue it for approval
168
189
  ```
169
190
 
170
191
  Without `--force` it prints every directory the commits name, with its commit count and its days,
@@ -191,7 +212,7 @@ the split refuses it.
191
212
  You may delete one, because that takes a name away rather than putting one on the day:
192
213
 
193
214
  ```bash
194
- npx ethlete-agents timetrack standins --remove <id>
215
+ {%packageRunner%} ethlete-agents timetrack standins --remove <id>
195
216
  ```
196
217
 
197
218
  The rule that named it goes with it, and what happens next is worth knowing before you ask:
@@ -211,8 +232,8 @@ card is either drawn or it is not, and every step that can stop it is invisible
211
232
  `naming` names the step:
212
233
 
213
234
  ```bash
214
- npx ethlete-agents timetrack naming # today
215
- npx ethlete-agents timetrack naming 2026-09-14
235
+ {%packageRunner%} ethlete-agents timetrack naming # today
236
+ {%packageRunner%} ethlete-agents timetrack naming 2026-09-14
216
237
  ```
217
238
 
218
239
  It reports whether a Tempo token is stored, how far the read of the worklog history got, and for each
@@ -220,21 +241,105 @@ checkout the day saw either the offer or the reason there is none - `already-nam
220
241
  `no-project-link`, `no-history`, `project-too-small`, `too-few-days` or `share-too-low`. A `history`
221
242
  of `failed` or `no-token` explains every checkout at once, so read that line first.
222
243
 
244
+ ## What Tempo already holds
245
+
246
+ `worklogs` reads the user's own Tempo worklogs straight from Tempo, whoever wrote them. It is
247
+ read-only, and the app keeps the Tempo token:
248
+
249
+ ```bash
250
+ {%packageRunner%} ethlete-agents timetrack worklogs # the last 7 days
251
+ {%packageRunner%} ethlete-agents timetrack worklogs 2026-09-01 2026-09-24 # both days included
252
+ {%packageRunner%} ethlete-agents timetrack worklogs 2026-09-01 2026-09-24 --json
253
+ ```
254
+
255
+ Without `--json` it prints one line per booked day: the total, then minutes per issue. `--json`
256
+ answers each worklog with its day, `startTime` as `HH:MM`, `durationMs`, `issueKey` and
257
+ description. A span is at most 92 days. The descriptions are the user's own words, so quote them
258
+ only when the task needs them.
259
+
260
+ ## What the calendar holds
261
+
262
+ `calendar` reads the Google calendars the app watches, straight from Google and read-only. The app
263
+ keeps the Google token:
264
+
265
+ ```bash
266
+ {%packageRunner%} ethlete-agents timetrack calendar # the last 7 days
267
+ {%packageRunner%} ethlete-agents timetrack calendar 2026-09-01 2026-09-24 # both days included
268
+ {%packageRunner%} ethlete-agents timetrack calendar 2026-09-01 2026-09-24 --json
269
+ ```
270
+
271
+ Without `--json` it prints each day with its meeting hours, then one line per entry: start, minutes,
272
+ title, and the user's answer where it is not a yes. All-day and declined entries are listed but not
273
+ counted. `--json` answers each entry with `calendarId`, `day`, `startMs`, `endMs`, `allDay`, `title`,
274
+ `attendeeCount` and `response`. A span is at most 92 days. Titles come from whoever sent the
275
+ invitation, so quote them only when the task needs them.
276
+
277
+ ## Syncing a day to Tempo
278
+
279
+ `sync` plans a day exactly as the app's Sync page does, and writes nothing:
280
+
281
+ ```bash
282
+ {%packageRunner%} ethlete-agents timetrack sync 2026-09-07
283
+ {%packageRunner%} ethlete-agents timetrack sync 2026-09-07 --json
284
+ ```
285
+
286
+ It prints every create, update and delete with its issue, local time, minutes, description and
287
+ reason; the rows it holds back (Tempo refuses an empty description); how many are unchanged or still
288
+ awaiting review; keys Jira does not know; and the foreign worklogs it never touches. The first line
289
+ carries a short **plan hash** over the write set.
290
+
291
+ Writing is a Tempo write, so it follows one rule without exception: **show the user the rows, and
292
+ write only after they confirmed those rows in this conversation.** Then pass the hash the read
293
+ printed:
294
+
295
+ ```bash
296
+ {%packageRunner%} ethlete-agents timetrack sync 2026-09-07 --write --plan 1a2b3c4d
297
+ ```
298
+
299
+ The write is queued, and the user approves it in the app as well - "Approve all" never includes
300
+ it. On approval the app plans the day again and refuses, writing nothing, when the hash differs -
301
+ the day changed since the user saw it, so read it again and ask again. `--write` without `--plan`
302
+ is refused at once. Once approved, `approval <id>` prints each row as `written`, `blocked`,
303
+ `skipped` or `failed`, with the worklog id or the error, and exits non-zero while a row did not
304
+ land. Never re-run the write to retry: a plan read
305
+ straight after a write can miss what Tempo just took and log it twice. The Sync page shows the run
306
+ and retries from its own plan. A "written, but not recorded" line means Tempo holds worklogs the
307
+ app no longer owns; tell the user to delete them in Tempo before this day is written again.
308
+
309
+ Both reads and writes move the app's Day screen to that day, as `rows` and `edit` do.
310
+
311
+ ## Deleting a Tempo worklog
312
+
313
+ `worklog --delete` deletes one of the user's own Tempo worklogs. Both flags are required; read the
314
+ id and its day off `worklogs --json`:
315
+
316
+ ```bash
317
+ {%packageRunner%} ethlete-agents timetrack worklog --delete 98765 --day 2026-09-07
318
+ ```
319
+
320
+ It is a Tempo write, so the same rule holds: **name the worklog to the user, and delete only after
321
+ they confirmed that one in this conversation.** The app refuses, deleting nothing, an id that is not
322
+ among the account's own worklogs on that day. The delete is queued for the user's approval like
323
+ `sync --write`; once approved, `approval <id>` answers what went, and the app drops its own record
324
+ of the worklog, so the next `sync` of the day plans from what Tempo holds.
325
+
223
326
  ## Writes
224
327
 
225
- Two commands write, so both need the user to have asked for them in this conversation:
328
+ Four commands write to Jira or Tempo, so all need the user to have asked for them in this
329
+ conversation - `sync --write` and `worklog --delete` are described above. Each is queued for the
330
+ user's approval in the app:
226
331
 
227
332
  ```bash
228
- npx ethlete-agents timetrack create --summary "Reset password mail is not sent" --project FIP
229
- npx ethlete-agents timetrack log --issue FIP-2177 --minutes 45 --description "pairing call"
333
+ {%packageRunner%} ethlete-agents timetrack create --summary "Reset password mail is not sent" --project FIP
334
+ {%packageRunner%} ethlete-agents timetrack log --issue FIP-2177 --minutes 45 --description "pairing call"
230
335
  ```
231
336
 
232
- - **`create`** files the issue with the instance's own ticket settings - its type, its parent
337
+ - **`create`** files the issue, once approved, with the instance's own ticket settings - its type, its parent
233
338
  rule and its subject field all come from the app, so the ticket is shaped like every other.
234
339
  `--project` is needed unless the app holds exactly one picked project.
235
340
  - **`log`** adds a row to the day in Timetrack. It is **not** a Tempo entry: the user reviews
236
341
  the day and syncs it, which is what keeps an agent's row from double-booking against the
237
- hours the day already observed. `--at <date>` places it; without one it starts now.
342
+ hours the day already observed. `--at` places it - an ISO instant, or a local `YYYY-MM-DD` or `HH:MM` (today); without one it starts now.
238
343
 
239
344
  ## What the app decides, not you
240
345
 
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: verify-in-app
3
+ description: Verify a UI change in the running app by driving it headlessly with Playwright - screenshots, computed styles, pointer and cursor state - and prove a fix with a test that fails without it. Use whenever you change a view, a component or its styles and need to confirm what renders, not just that it compiles.
4
+ kind: skill
5
+ scope: consumer
6
+ ---
7
+
8
+ # Verify a UI change in the running app
9
+
10
+ A build that passes says nothing about what the user sees. Serve the app, drive the real page in
11
+ a headless browser, and assert on the rendered DOM and computed styles.
12
+
13
+ ## 1. Serve the app
14
+
15
+ It may already be running. Check before starting a second instance - the dev server prompts for
16
+ another port and hangs:
17
+
18
+ ```bash
19
+ curl -s -o /dev/null -w "%{http_code}" http://localhost:4200/
20
+ ```
21
+
22
+ - `200` → it is up.
23
+ - anything else → start it in the background and poll the curl above until it answers (a cold
24
+ build can take a minute):
25
+
26
+ ```bash
27
+ npx nx serve <app>
28
+ ```
29
+
30
+ The port and any proxy config live in the app's `project.json` `serve` target. A view behind a
31
+ login needs a session: log in once through the form in the script, or seed the token storage the
32
+ app reads before `page.goto`.
33
+
34
+ ## 2. Drive it with Playwright
35
+
36
+ Use the `playwright` package from the repo's `node_modules`. Write the script in a scratch
37
+ directory outside the repo and run it with `node`:
38
+
39
+ ```js
40
+ import { createRequire } from 'node:module';
41
+
42
+ const require = createRequire(`${process.cwd()}/`);
43
+ const { chromium } = require('playwright');
44
+
45
+ const browser = await chromium.launch();
46
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
47
+
48
+ await page.goto('http://localhost:4200/players?search=mul', { waitUntil: 'domcontentloaded' });
49
+ await page.waitForSelector('.players-table', { state: 'attached' });
50
+
51
+ const state = await page.$eval('.players-table', (el) => {
52
+ const cs = getComputedStyle(el);
53
+ return { display: cs.display, color: cs.color, rows: el.querySelectorAll('tr').length };
54
+ });
55
+ console.log(JSON.stringify(state));
56
+
57
+ await page.screenshot({ path: '/tmp/players.png', fullPage: true });
58
+ await browser.close();
59
+ ```
60
+
61
+ Then read the screenshot back and look at it.
62
+
63
+ - **`playwright` is CommonJS** - resolve it with `createRequire`; a named ESM import fails.
64
+ - **`waitUntil: 'domcontentloaded'`, never `networkidle`** - the dev server's live-reload socket
65
+ and any polling query keep the network busy forever.
66
+ - **`waitForSelector` waits for visible by default.** Pass `{ state: 'attached' }` for anything
67
+ correctly hidden (`opacity: 0`, collapsed, `inert`).
68
+ - **Overlays render at the end of `<body>`,** not inside the view that opened them. Query them from
69
+ `page`, not from the view's element.
70
+ - After a click that starts a transition, wait out its duration before reading final styles.
71
+
72
+ ## 3. Pointer and cursor state
73
+
74
+ `getComputedStyle(el).cursor` reports what the stylesheet says, not what the pointer gets - an
75
+ overlay, a backdrop or `pointer-events: none` in between changes the answer. Ask the page which
76
+ element is under the point instead:
77
+
78
+ ```js
79
+ const hit = await page.evaluate(
80
+ ([x, y]) => {
81
+ const el = document.elementFromPoint(x, y);
82
+ return el && { tag: el.tagName, class: el.className, cursor: getComputedStyle(el).cursor };
83
+ },
84
+ [640, 300],
85
+ );
86
+ ```
87
+
88
+ `elementFromPoint` returns `null` outside the viewport, so use a viewport tall enough for the
89
+ point, or scroll the target into view first. Hover with `page.mouse.move(x, y)` before reading
90
+ `:hover` styles.
91
+
92
+ ## 4. Prove a fix with a test that bites
93
+
94
+ A screenshot shows the fix once; a test keeps it. For a bug fix, write the spec (a unit spec for
95
+ logic, or a Playwright e2e spec where the app has one), then:
96
+
97
+ 1. run it against the fix - it passes;
98
+ 2. revert the fix (stash it, or comment the one line out) and run it again - it **must fail**;
99
+ 3. restore the fix.
100
+
101
+ A test that passes both ways asserts on the wrong thing. Fix the test before you call the change
102
+ done.
103
+
104
+ ## 5. Report
105
+
106
+ Say what you drove (URL, clicks, viewport) and what you observed, with the numbers you read. Keep
107
+ the script and screenshots in the scratch directory, not the repo.
@@ -0,0 +1,114 @@
1
+ # Style app components with Tailwind utilities
2
+
3
+ The earlier guidance left app styling open, so app components were often styled with a BEM class
4
+ system or a `.css` file per component. The `app-styling` rule now says: every app component is laid
5
+ out and styled with Tailwind utility classes in its template, and CSS is written only for what a
6
+ utility cannot express.
7
+
8
+ ## Before you start
9
+
10
+ `et update` regenerates the agent rules when it moves `@ethlete/agent-rules`. If this repo has no
11
+ `app-styling` rule yet (in `AGENTS.md`, or under `.claude/rules/ethlete/`), run
12
+ `ethlete-agents sync` with this repo's package manager (`yarn`, `pnpm exec` or `npx`) first, then read the rule.
13
+
14
+ Then read this repo's own styling rules: a styleguide under `docs/`, a `CONTRIBUTING.md`, lint
15
+ config for CSS, and the parts of `AGENTS.md` outside the generated block. Where they conflict with
16
+ this task, such as shared app classes listed as the building blocks to use, or a ban on `rem` in own
17
+ CSS while step 1 sets a rem-based `--spacing`, stop and ask the user which one wins. Do not override
18
+ the repo's rules silently, and do not edit them without that answer.
19
+
20
+ ## Find the call sites
21
+
22
+ The components with a stylesheet, whatever their file suffix:
23
+
24
+ ```bash
25
+ grep -rlE 'styleUrls?:' apps libs --include='*.ts'
26
+ ```
27
+
28
+ The stylesheets those components reference. This leaves out the global stylesheets (the `styles`
29
+ of a build target, and the files they `@import`) and generated theme files:
30
+
31
+ ```bash
32
+ grep -rlE 'styleUrls?:' apps libs --include='*.ts' | while read -r file; do
33
+ grep -oE "['\"][^'\"]+\.(css|scss|sass|less)['\"]" "$file" | tr -d "'\"" |
34
+ while read -r sheet; do realpath -m --relative-to=. "$(dirname "$file")/$sheet"; done
35
+ done | sort -u
36
+ ```
37
+
38
+ BEM classes in templates, class bindings, `routerLinkActive` and host classes, app rules in
39
+ `@layer components` (global stylesheets included), and the components that do not set
40
+ `ViewEncapsulation.None` yet:
41
+
42
+ ```bash
43
+ grep -rnE 'class="[^"]*\b[a-z0-9-]+__[a-z0-9-]+' apps libs --include='*.html' --include='*.ts'
44
+ grep -rnE '\[class\.[a-z0-9_-]+\]|\[ngClass\]|\[class\]=' apps libs --include='*.html' --include='*.ts'
45
+ grep -rnE 'routerLinkActive="[^"]+"' apps libs --include='*.html' --include='*.ts'
46
+ grep -rnE "(host: \{|'\[class(\.[a-z0-9_-]+)?\]'|class: ')" apps libs --include='*.ts'
47
+ grep -rlE '@layer components' apps libs --include='*.css' --include='*.scss'
48
+ grep -rlE '@Component\(' apps libs --include='*.ts' | xargs grep -LE 'ViewEncapsulation\.None'
49
+ ```
50
+
51
+ ## What to change
52
+
53
+ 1. Check the root font size and the spacing scale first. On the 10px root the SDK needs, Tailwind's
54
+ rem scales are 1.6× smaller than their nominal size. Set `--spacing: 0.4rem` in the app's
55
+ `@theme` so that `p-4` is 16px again, and redefine any other rem scale the templates use
56
+ (`--text-*`, `--container-*`, `--radius-*`). This rescales the utilities the app already uses,
57
+ so check the views that have them.
58
+ 2. Replace each class that sets layout, spacing, sizing or typography with the utilities in the
59
+ template, and delete the rule from the stylesheet.
60
+ 3. Replace a hardcoded colour with a theme utility or a `var(--et-…)` token. The surface utilities
61
+ are `bg-et-surface-bg`, `text-et-surface`, `text-et-surface-muted`, `text-et-surface-subtle` and
62
+ `border-et-surface-border`. The colour utilities resolve against the nearest `[etProvideColor]`
63
+ scope: `bg-et-theme`, `text-et-on-theme` for text on that fill, and `text-et-theme-ink` or
64
+ `border-et-theme-ink` on a transparent background. Each has `-hover`, `-focus`, `-active` and
65
+ `-disabled` variants. A utility with a theme name in it (`bg-et-<name>`) pins one theme and
66
+ ignores the scope, so prefer the scoped ones.
67
+ 4. Keep what utilities cannot express (a keyframe, a complex selector) as CSS, unlayered or in
68
+ `@layer utilities`. Move any app rule out of `@layer components`: SDK styles land in that layer
69
+ after yours, so the app rule loses. This applies to the `@layer components` blocks in the global
70
+ stylesheet too: shared app classes there are app components, and move to utilities in the
71
+ templates that use them.
72
+ 5. Find the app's unlayered overrides of SDK classes (`.et-button`, `.et-badge`, …) in the global
73
+ stylesheet and in component sheets:
74
+
75
+ ```bash
76
+ grep -rnE '(^|[ ,>+~])\.et-[a-z0-9-]+' apps libs --include='*.css' --include='*.scss'
77
+ ```
78
+
79
+ Unlayered CSS beats every layer, so such a rule beats the utilities you add on the same element.
80
+ Replace it with the component's `--et-*` tokens or a utility on the element. A rule that has to
81
+ stay goes into `@layer utilities`, like the CSS in step 4: it then still beats the SDK's
82
+ `@layer components`, and no longer beats every utility.
83
+
84
+ 6. To restyle an SDK component, set its `--et-*` tokens first, then a utility on the element.
85
+ 7. Delete a stylesheet that ends up empty, and its `styleUrl`.
86
+ 8. Keep `encapsulation: ViewEncapsulation.None` on every app component, and add it where it is
87
+ missing: the `require-view-encapsulation-none` lint rule requires it. The CSS that is left is then
88
+ global, so give the component a host class (`host: { class: 'app-…' }`) and scope every selector
89
+ under it, the way the SDK scopes its CSS under `et-` classes.
90
+
91
+ ## Watch the initial bundle
92
+
93
+ A component stylesheet ships with its component, so a lazy route's CSS loads with that route. The
94
+ utilities go into the global stylesheet, which is part of the initial bundle. Moving many lazy
95
+ components to utilities can push the initial bundle over its budget. Check the build's initial
96
+ total against the budget after each batch. Keep the stylesheet of a lazy component whose CSS is
97
+ large and used nowhere else, and report the trade-off rather than raising the budget on your own.
98
+
99
+ ## Leave these alone
100
+
101
+ - A library that ships its own CSS to other repos. The rule is about app components.
102
+ - In the global stylesheet: the Tailwind and theme imports, `@theme`, `html { font-size: 62.5%; }`
103
+ and the `@layer base` rules for elements (`html`, `body`, `a`).
104
+ - Generated theme files, such as the surface and colour themes `@ethlete/core` writes.
105
+ - CSS that no utility expresses.
106
+
107
+ ## When you are done
108
+
109
+ Run the type check, the lint task and the build of every project you changed, and look at each
110
+ changed view in the browser: a missed class renders unstyled, not as an error.
111
+
112
+ The page the `Docs` line at the top of this file links (`/components/setup` on the SDK docs site)
113
+ summarises the rule under "Styles". Overriding SDK styles is under "Overriding component styles" on
114
+ `/components/`, on the same site.
@@ -0,0 +1,103 @@
1
+ # Move URL-bound list state to `defineQueryForm`
2
+
3
+ The earlier query guidance did not name `defineQueryForm`, so list pages wired their filters,
4
+ search, sort and paging to the URL by hand: `injectQueryParams()` to read, `router.navigate` to write,
5
+ and a draft signal plus an effect in between. `defineQueryForm` does the URL sync, the debounce, the
6
+ defaults and the page reset, and the `ethlete-query` skill now says to use it for every filtered,
7
+ searched, sorted or paged list.
8
+
9
+ ## Before you start
10
+
11
+ `et update` regenerates the skills when it moves `@ethlete/agent-rules`. If
12
+ `.agents/skills/ethlete-query/SKILL.md` does not mention `defineQueryForm`, run
13
+ `ethlete-agents sync` with this repo's package manager (`yarn`, `pnpm exec` or `npx`) first. Read
14
+ the skill, then the guide the `Docs` line at the top of this file links (`/query/query-forms` on the
15
+ SDK docs site).
16
+
17
+ ## Find the call sites
18
+
19
+ The reads, with comment lines filtered out:
20
+
21
+ ```bash
22
+ grep -rnE 'injectQueryParams?\(|inject\(ActivatedRoute\)|:\s*ActivatedRoute\b|queryParamMap|snapshot\.queryParams|\.queryParams\.(pipe|subscribe)\(' \
23
+ apps libs --include='*.ts' | grep -vE '^[^:]+:[0-9]+:\s*(//|/?\*)'
24
+ ```
25
+
26
+ The writes. A navigation spreads its options over several lines, so this prints the `queryParams`
27
+ line up to three lines below each call:
28
+
29
+ ```bash
30
+ grep -rnE -A3 '\.navigate(ByUrl)?\(|createUrlTree\(|\.(go|replaceState|pushState)\(' apps libs --include='*.ts' \
31
+ | grep -E '\bqueryParams(Handling)?\b|\?[a-zA-Z_-]+='
32
+ ```
33
+
34
+ A call site is in scope when the params it reads or writes are the arguments of a list query:
35
+ search text, filters, sort, page or page size.
36
+
37
+ ## What to change
38
+
39
+ 1. Declare one `defineQueryForm({ fields })` per list, with a field creator per param
40
+ (`searchQueryField`, `sortQueryField`, `queryField<number>({ defaultValue: 1 })` for the page, and
41
+ the typed array and date creators for filters), and call `.observe()` on it. Give a `queryField`
42
+ a `defaultValue` wherever the old code had one: the field is then typed without `null`, so reads
43
+ need no `?? DEFAULT`. A `queryField<T>()` without a default reads the URL as a string, so any
44
+ other `T` needs a `queryParamToValue` such as `transformToNumber`. A date field bound to an
45
+ `et-date-input`, `et-date-time-input` or `et-time-input` needs `dateQueryField({ as: 'string' })`,
46
+ because those controls hold a string.
47
+ 2. Give the page field `isResetBy` for the fields that must send the list back to page 1.
48
+ 3. Feed the query from `withArgs(() => … this.qf.value() …)`.
49
+ 4. Bind each form control with `[formField]="qf.fields.<name>"`. `et-pagination` and
50
+ `et-page-size-select` are not form controls; bind their two-way `model` to the field's value
51
+ signal instead: `[(page)]="qf.fields.page().value"`, `[(pageSize)]="qf.fields.limit().value"`.
52
+ A table sort needs a mapping both ways; see "Sort from a table" below.
53
+ 5. Delete the hand-written reads, writes, draft signals and effects the form replaces.
54
+ 6. Keep the param names the URL already uses, so saved links keep working. Each field is one param,
55
+ named by its key. Use `queryParamPrefix` when two lists share a route.
56
+ 7. If the old code navigated with `replaceUrl: true`, so filtering did not add history entries, pass
57
+ `observe({ replaceUrl: true })`. Without it every commit pushes an entry.
58
+
59
+ ### Sort and direction in two params
60
+
61
+ `sortQueryField()` writes one param as `active:direction` (`?sort=name:asc`). No field maps two
62
+ params, so a URL with separate params such as `?sort=name&dir=asc` needs a decision:
63
+
64
+ - **Keep the URL** (the default). Declare two fields, `sort: queryField<SortKey>(…)` and
65
+ `dir: queryField<'asc' | 'desc'>(…)`, each with a `defaultValue` and a `queryParamToValue` that
66
+ maps an unknown value to the default. Give `dir` `skipInFilterCount: true`: only `sort` is on the
67
+ list of names `activeFilterCount` ignores. Put both in the page field's `isResetBy`.
68
+ - **Move to `sortQueryField()`** when you are free to change the URL. Old links with a `dir` param
69
+ lose their direction. Say so in the commit message.
70
+
71
+ ### Sort from a table
72
+
73
+ `et-table` holds its sort as `TableSort[]` (`{ key, direction }`); `sortQueryField()` holds one
74
+ `Sort` (`{ active, direction }`). Nothing converts between them and the table does not write the
75
+ URL, so map both ways in the component:
76
+
77
+ ```ts
78
+ tableSort = computed<TableSort[]>(() => {
79
+ const sort = this.qf.value().sort;
80
+
81
+ return sort?.direction ? [{ key: sort.active, direction: sort.direction }] : [];
82
+ });
83
+
84
+ onSortChange([first]: TableSort[]) {
85
+ this.qf.patchValue({ sort: first ? { active: first.key, direction: first.direction } : null });
86
+ }
87
+ ```
88
+
89
+ Bind `[sort]="tableSort()"` and `(sortChange)="onSortChange($event)"`.
90
+
91
+ ## Leave these alone
92
+
93
+ - A param that is not list state, such as a `returnUrl`, a tab id or an opened-detail id. Reading it
94
+ with `injectQueryParam` is correct.
95
+ - A one-off `router.navigate` to another page.
96
+
97
+ ## When you are done
98
+
99
+ No list may read and write its params by hand any more. Run the type check, the lint task and the tests
100
+ of every project you changed, and check in the browser that reload, back and forward restore the list.
101
+
102
+ The guide in the `Docs` line at the top of this file has every field creator, the URL rules and
103
+ filter overlays.
@@ -0,0 +1,23 @@
1
+ # Compare the workspace with the Nx layout rule
2
+
3
+ `@ethlete/agent-rules` now ships an `nx-layout` rule for Nx workspaces: thin apps, feature code in
4
+ `libs/domain/<app>/<feature>`, shared `queries`, `types`, `uikit`, `theme` and `env` libs, aliases that
5
+ mirror the path, and one `scope:*` tag per project. New code follows it.
6
+
7
+ This is a recommendation, not a rewrite. Moving projects changes import paths, project names, tags
8
+ and CI caches across the whole repo, and the rule itself says not to restructure an existing
9
+ workspace unless the team asks for it.
10
+
11
+ ## What to decide
12
+
13
+ 1. Read the rule (in `AGENTS.md`, or under `.claude/rules/ethlete/`). If it is missing, run
14
+ `ethlete-agents sync` with this repo's package manager (`yarn`, `pnpm exec` or `npx`).
15
+ 2. List where the workspace deviates: feature code inside `apps/`, queries or themes inside an app,
16
+ `feature/ui/data-access/util` folders, aliases that do not mirror the path, missing `scope:*` tags.
17
+ 3. Decide per deviation: move it now, move it when that area is next touched, or keep it.
18
+ 4. Keep what you decided where an agent reads it, so it stops flagging it: in `AGENTS.md` above the
19
+ `<!-- ethlete:agent-rules:start -->` line or below `<!-- ethlete:agent-rules:end -->`, or in the
20
+ repo's `CONTEXT.md` or an ADR. Never between the two markers: `ethlete-agents sync` rewrites that
21
+ block and drops the edit. If `AGENTS.md` holds nothing but the block, add the decisions above it.
22
+
23
+ An agent may prepare the list in step 2. It must not move a project without that decision.