@ethlete/agent-rules 0.1.0-next.3 → 0.1.0-next.30

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 (200) hide show
  1. package/AGENTS.md +28 -0
  2. package/CHANGELOG.md +463 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +362 -29
  5. package/THIRD-PARTY-LICENSES.md +35 -0
  6. package/content/defaults.json +1 -1
  7. package/content/git-hooks/post-checkout.sh +10 -0
  8. package/content/git-hooks/pre-push.sh +5 -0
  9. package/content/hooks/context-warning.py +871 -110
  10. package/content/hooks/subagent-model-policy.py +194 -0
  11. package/content/mods/.claude-plugin/marketplace.json +11 -0
  12. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
  13. package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
  14. package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
  15. package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
  16. package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
  17. package/content/output-styles/ste-clarity.md +131 -0
  18. package/content/rules/agent-sessions-wake-up.md +11 -0
  19. package/content/rules/app-styling.md +66 -0
  20. package/content/rules/comments.md +4 -1
  21. package/content/rules/lint-and-format.md +7 -5
  22. package/content/rules/nx-layout.md +50 -0
  23. package/content/rules/reactive-state.md +1 -1
  24. package/content/rules/styling.md +9 -7
  25. package/content/rules/subagent-models.md +39 -0
  26. package/content/skills/agent-sessions/SKILL.md +81 -0
  27. package/content/skills/agent-sessions/agent-sessions.mjs +492 -0
  28. package/content/skills/angular-patterns/SKILL.md +30 -7
  29. package/content/skills/api-source/SKILL.md +156 -0
  30. package/content/skills/app-testing/SKILL.md +141 -0
  31. package/content/skills/codex-subagent/SKILL.md +90 -0
  32. package/content/skills/codex-subagent/codex-agent.mjs +229 -0
  33. package/content/skills/design-exploration/SKILL.md +657 -0
  34. package/content/skills/design-exploration/check-story.mjs +139 -0
  35. package/content/skills/design-exploration/shoot-template.mjs +61 -0
  36. package/content/skills/domain-modeling/ADR-FORMAT.md +47 -0
  37. package/content/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  38. package/content/skills/domain-modeling/SKILL.md +80 -0
  39. package/content/skills/e2e-triage/SKILL.md +80 -0
  40. package/content/skills/figma-export/SKILL.md +191 -0
  41. package/content/skills/figma-export/dump-figma-layers.py +83 -0
  42. package/content/skills/figma-export/dump-figma-svg.py +235 -0
  43. package/content/skills/figma-export/measure-template.mjs +87 -0
  44. package/content/skills/git-commit/SKILL.md +11 -7
  45. package/content/skills/git-flow/SKILL.md +87 -0
  46. package/content/skills/grill-with-docs/SKILL.md +13 -0
  47. package/content/skills/grilling/SKILL.md +51 -0
  48. package/content/skills/handoff/SKILL.md +125 -5
  49. package/content/skills/query/SKILL.md +165 -64
  50. package/content/skills/rxjs-signals/SKILL.md +33 -4
  51. package/content/skills/sdk-docs/SKILL.md +50 -14
  52. package/content/skills/sdk-local-build/SKILL.md +26 -8
  53. package/content/skills/sdk-local-build/sdk-local-baseline.mjs +64 -0
  54. package/content/skills/sdk-source/SKILL.md +37 -34
  55. package/content/skills/sdk-update/SKILL.md +103 -0
  56. package/content/skills/story-styling/SKILL.md +10 -8
  57. package/content/skills/styleguide/SKILL.md +12 -7
  58. package/content/skills/styleguide/assets.md +54 -0
  59. package/content/skills/styleguide/changesets.md +17 -0
  60. package/content/skills/styleguide/file-structure.md +73 -0
  61. package/content/skills/styleguide/lint-rule-lookup.md +51 -0
  62. package/content/skills/styleguide/storybook-structure.md +25 -0
  63. package/content/skills/theming/SKILL.md +50 -28
  64. package/content/skills/timetrack/SKILL.md +397 -0
  65. package/content/skills/verify-in-app/SKILL.md +107 -0
  66. package/migrations/app-styling-utilities.md +63 -0
  67. package/migrations/list-state-query-form.md +20 -0
  68. package/migrations/nx-layout.md +29 -0
  69. package/migrations/sdk-components-over-hand-built-ui.md +27 -0
  70. package/migrations/search-query-field.md +52 -0
  71. package/migrations.json +48 -0
  72. package/package.json +19 -3
  73. package/src/index.js +85 -30
  74. package/src/index.js.map +1 -1
  75. package/src/lib/cli-args.d.ts +9 -0
  76. package/src/lib/cli-args.js +48 -0
  77. package/src/lib/cli-args.js.map +1 -0
  78. package/src/lib/commitlint.d.ts +10 -0
  79. package/src/lib/commitlint.js +51 -0
  80. package/src/lib/commitlint.js.map +1 -0
  81. package/src/lib/config.d.ts +41 -5
  82. package/src/lib/config.js +117 -10
  83. package/src/lib/config.js.map +1 -1
  84. package/src/lib/filter.d.ts +2 -0
  85. package/src/lib/filter.js +4 -4
  86. package/src/lib/filter.js.map +1 -1
  87. package/src/lib/frontmatter.d.ts +2 -0
  88. package/src/lib/frontmatter.js +33 -5
  89. package/src/lib/frontmatter.js.map +1 -1
  90. package/src/lib/git-flow/build.d.ts +35 -0
  91. package/src/lib/git-flow/build.js +24 -0
  92. package/src/lib/git-flow/build.js.map +1 -0
  93. package/src/lib/git-flow/config.d.ts +59 -0
  94. package/src/lib/git-flow/config.js +50 -0
  95. package/src/lib/git-flow/config.js.map +1 -0
  96. package/src/lib/git-flow/index.d.ts +6 -0
  97. package/src/lib/git-flow/index.js +23 -0
  98. package/src/lib/git-flow/index.js.map +1 -0
  99. package/src/lib/git-flow/parse.d.ts +49 -0
  100. package/src/lib/git-flow/parse.js +300 -0
  101. package/src/lib/git-flow/parse.js.map +1 -0
  102. package/src/lib/git-flow/rename.d.ts +24 -0
  103. package/src/lib/git-flow/rename.js +70 -0
  104. package/src/lib/git-flow/rename.js.map +1 -0
  105. package/src/lib/git-flow/start.d.ts +49 -0
  106. package/src/lib/git-flow/start.js +57 -0
  107. package/src/lib/git-flow/start.js.map +1 -0
  108. package/src/lib/git-flow/validate.d.ts +34 -0
  109. package/src/lib/git-flow/validate.js +72 -0
  110. package/src/lib/git-flow/validate.js.map +1 -0
  111. package/src/lib/git-flow-command.d.ts +4 -0
  112. package/src/lib/git-flow-command.js +165 -0
  113. package/src/lib/git-flow-command.js.map +1 -0
  114. package/src/lib/git-flow-repair.d.ts +17 -0
  115. package/src/lib/git-flow-repair.js +165 -0
  116. package/src/lib/git-flow-repair.js.map +1 -0
  117. package/src/lib/git-flow-start.d.ts +20 -0
  118. package/src/lib/git-flow-start.js +140 -0
  119. package/src/lib/git-flow-start.js.map +1 -0
  120. package/src/lib/git.d.ts +64 -0
  121. package/src/lib/git.js +131 -0
  122. package/src/lib/git.js.map +1 -0
  123. package/src/lib/gitlab.d.ts +35 -0
  124. package/src/lib/gitlab.js +116 -0
  125. package/src/lib/gitlab.js.map +1 -0
  126. package/src/lib/index.d.ts +2 -0
  127. package/src/lib/index.js +26 -11
  128. package/src/lib/index.js.map +1 -1
  129. package/src/lib/migrate.js +6 -1
  130. package/src/lib/migrate.js.map +1 -1
  131. package/src/lib/output-style-command.d.ts +3 -0
  132. package/src/lib/output-style-command.js +69 -0
  133. package/src/lib/output-style-command.js.map +1 -0
  134. package/src/lib/output-style.d.ts +38 -0
  135. package/src/lib/output-style.js +134 -0
  136. package/src/lib/output-style.js.map +1 -0
  137. package/src/lib/owned-paths.js +27 -3
  138. package/src/lib/owned-paths.js.map +1 -1
  139. package/src/lib/package-runner.d.ts +4 -0
  140. package/src/lib/package-runner.js +86 -0
  141. package/src/lib/package-runner.js.map +1 -0
  142. package/src/lib/plain-text.d.ts +9 -0
  143. package/src/lib/plain-text.js +22 -0
  144. package/src/lib/plain-text.js.map +1 -0
  145. package/src/lib/plan.d.ts +6 -6
  146. package/src/lib/plan.js +164 -40
  147. package/src/lib/plan.js.map +1 -1
  148. package/src/lib/prompt.d.ts +8 -0
  149. package/src/lib/prompt.js +35 -0
  150. package/src/lib/prompt.js.map +1 -0
  151. package/src/lib/render.d.ts +25 -2
  152. package/src/lib/render.js +43 -10
  153. package/src/lib/render.js.map +1 -1
  154. package/src/lib/sessions-command.d.ts +4 -0
  155. package/src/lib/sessions-command.js +16 -0
  156. package/src/lib/sessions-command.js.map +1 -0
  157. package/src/lib/sync.d.ts +0 -1
  158. package/src/lib/sync.js +30 -17
  159. package/src/lib/sync.js.map +1 -1
  160. package/src/lib/targets/agents-skills.js +1 -1
  161. package/src/lib/targets/agents-skills.js.map +1 -1
  162. package/src/lib/targets/claude-hooks.d.ts +3 -23
  163. package/src/lib/targets/claude-hooks.js +22 -86
  164. package/src/lib/targets/claude-hooks.js.map +1 -1
  165. package/src/lib/targets/claude-mods.d.ts +20 -0
  166. package/src/lib/targets/claude-mods.js +47 -0
  167. package/src/lib/targets/claude-mods.js.map +1 -0
  168. package/src/lib/targets/claude.js +2 -3
  169. package/src/lib/targets/claude.js.map +1 -1
  170. package/src/lib/targets/codex-hooks.d.ts +12 -0
  171. package/src/lib/targets/codex-hooks.js +35 -0
  172. package/src/lib/targets/codex-hooks.js.map +1 -0
  173. package/src/lib/targets/codex.js +4 -4
  174. package/src/lib/targets/codex.js.map +1 -1
  175. package/src/lib/targets/copilot.js +4 -4
  176. package/src/lib/targets/copilot.js.map +1 -1
  177. package/src/lib/targets/cursor.js +3 -3
  178. package/src/lib/targets/cursor.js.map +1 -1
  179. package/src/lib/targets/git-hooks.d.ts +25 -0
  180. package/src/lib/targets/git-hooks.js +71 -0
  181. package/src/lib/targets/git-hooks.js.map +1 -0
  182. package/src/lib/targets/hooks-shared.d.ts +60 -0
  183. package/src/lib/targets/hooks-shared.js +130 -0
  184. package/src/lib/targets/hooks-shared.js.map +1 -0
  185. package/src/lib/targets/mcp.d.ts +16 -0
  186. package/src/lib/targets/mcp.js +68 -0
  187. package/src/lib/targets/mcp.js.map +1 -0
  188. package/src/lib/targets/shared.d.ts +1 -9
  189. package/src/lib/targets/shared.js +4 -8
  190. package/src/lib/targets/shared.js.map +1 -1
  191. package/src/lib/timetrack-command.d.ts +11 -0
  192. package/src/lib/timetrack-command.js +1063 -0
  193. package/src/lib/timetrack-command.js.map +1 -0
  194. package/src/lib/timetrack-snapshot.d.ts +55 -0
  195. package/src/lib/timetrack-snapshot.js +120 -0
  196. package/src/lib/timetrack-snapshot.js.map +1 -0
  197. package/src/lib/timetrack.d.ts +561 -0
  198. package/src/lib/timetrack.js +279 -0
  199. package/src/lib/timetrack.js.map +1 -0
  200. package/content/skills/styleguide/STYLEGUIDE.md +0 -520
@@ -0,0 +1,25 @@
1
+ # Storybook structure
2
+
3
+ Read this file when placing a `*.stories.ts` file or code used only to render stories.
4
+
5
+ - Keep the `*.stories.ts` file beside the component it documents.
6
+ - Put private host components, dummy data, and story-only helpers in `storybook/`.
7
+ - Never export Storybook-only code from the component's public barrel.
8
+ - A private `storybook/index.ts` may simplify imports into the adjacent story, but no
9
+ production source may import it.
10
+
11
+ ```plaintext
12
+ settings-form/
13
+ ├── storybook/
14
+ │ ├── settings-form-storybook.component.ts
15
+ │ ├── settings-form-storybook.component.html
16
+ │ ├── settings-form-dummy-data.ts
17
+ │ └── index.ts
18
+ ├── settings-form.component.ts
19
+ ├── settings-form.component.html
20
+ ├── settings-form.component.stories.ts
21
+ └── index.ts
22
+ ```
23
+
24
+ The story file stays at the component root; only its private support code goes into
25
+ `storybook/`.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: theming
3
- description: The two runtime theming systems in @ethlete/core - surface theming (elevation-aware neutrals) and color theming (semantic accent palettes) - how to register them in an app and how components must consume them. Read BEFORE writing or reviewing CSS that involves any color, background, border, or interaction state, and when wiring theme context across overlay/portal boundaries.
3
+ description: The two runtime theming systems in @ethlete/core - surface theming (elevation-aware neutrals) and color theming (semantic accent palettes). Read BEFORE writing or reviewing CSS involving color, background, border, or interaction state, and when wiring theme context across overlay/portal boundaries.
4
4
  kind: skill
5
5
  scope: consumer
6
6
  requires: ['@ethlete/core']
@@ -37,16 +37,16 @@ The generated CSS defines the raw vars on `.et-surface--<name>` / `.et-color--<n
37
37
  classes (plus `:root` defaults) and derives the public tokens below on `:root` **and**
38
38
  every scope class - so the tokens are always resolvable and components just read them.
39
39
 
40
- **Theme names are yours.** `brand`, `danger`, `neutral`, `dark-elevated` are only
41
- examples. Never hardcode a theme-name union in a component type, a doc or an example.
42
- The portable handle is the theme **`type`**: `injectErrorTheme()` finds whichever theme
43
- the app registered with `type: 'error'`.
40
+ **Theme names are yours.** Never hardcode them as an SDK-defined union or reusable API
41
+ contract. App-specific docs may name their own themes when they label them as app values.
42
+ The portable handle is the theme **`type`**: `injectSemanticColorTheme('error')` finds
43
+ whichever theme the app registered with `type: 'error'`.
44
44
 
45
45
  ## Tokens components may consume
46
46
 
47
47
  Use these **derived** tokens - never the raw `--et-surface-background` /
48
- `--et-color-primary` channel triplets, and never a hardcoded color (a static fallback
49
- _after_ the token is fine).
48
+ `--et-color-primary` channel triplets. Never use a hardcoded color as the primary value.
49
+ A static fallback inside `var(--token, <fallback>)` is permitted, but not required.
50
50
 
51
51
  Surface (each exists as `-solid` = usable color, `-rgb` = `R G B` channels):
52
52
 
@@ -61,22 +61,26 @@ Surface (each exists as `-solid` = usable color, `-rgb` = `R G B` channels):
61
61
 
62
62
  Color (from the nearest `[etProvideColor]` scope):
63
63
 
64
- | Token | Use for |
65
- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
66
- | `--et-theme-color-primary` | filled backgrounds; **opacity-aware** - set `--et-theme-color-primary-opacity: 0.16` etc. per state to tint without changing the color |
67
- | `--et-theme-color-primary-solid` | accents at full strength: focus borders, selected marks, spinners |
68
- | `--et-theme-color-on-primary` | text/icons on a primary-filled background |
69
- | `--et-theme-color-ink-solid` | primary-tinted text/border on transparent/tonal fills |
64
+ | Token | Use for |
65
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | `--et-theme-color-primary` | filled backgrounds on an element you do **not** tint. To tint per state, set `--et-theme-color-primary-opacity: 0.16` and compose the color yourself: `rgb(var(--et-theme-color-primary-rgb) / var(--et-theme-color-primary-opacity))`. The alias is substituted at the `et-color--*` scope, so an inherited value carries the scope's opacity, never the element's |
67
+ | `--et-theme-color-primary-solid` | accents at full strength: focus borders, selected marks, spinners |
68
+ | `--et-theme-color-on-primary` | text/icons on a primary-filled background |
69
+ | `--et-theme-color-ink-solid` | primary-tinted text/border on transparent/tonal fills |
70
70
 
71
71
  Interaction-state variants (`--et-surface-interaction-{hover,focus,active,disabled}-solid`)
72
72
  resolve automatically per CSS state when the element has `[etSurfaceInteractive]`;
73
73
  similarly `[etColorInteractive]` re-resolves the color tokens per state. Put either on
74
74
  the interactive element itself, never on a wrapper.
75
75
 
76
+ **Never use `hover:bg-et-surface-interaction-hover` for a hover row.** The token is a
77
+ full-strength neutral tint, so in dark mode it gives a light row with near-white text. Use
78
+ `[etSurfaceInteractive]` on the row for hover feedback.
79
+
76
80
  ## Providing context
77
81
 
78
- - `[etProvideSurface]="'dark-elevated'"` / `[etAutoSurface]` (auto-picks the next
79
- elevation) set the surface scope; `[etProvideColor]="'brand'"` sets the color scope.
82
+ - `[etProvideSurface]="surfaceThemeName"` / `[etAutoSurface]` (auto-picks the next
83
+ elevation) set the surface scope; `[etProvideColor]="colorThemeName"` sets the color scope.
80
84
  A component that owns a themed region usually adds `ProvideColorDirective` /
81
85
  `ProvideSurfaceDirective` as `hostDirectives`.
82
86
  - **Detached overlay panes (menu, dialog, tooltip…) do not inherit DOM context.**
@@ -85,10 +89,22 @@ the interactive element itself, never on a wrapper.
85
89
  color and `resolveSurfaceByElevation(themes, type, elevation + 1)` for the surface.
86
90
  - **Semantic colors come through DI, not CSS.** There is no global "error color"
87
91
  variable - error/warning/success are just color themes. To render something in the
88
- error color, `injectErrorTheme()` (it throws if the app registered no `type: 'error'`
89
- theme) and either bind it (`[etProvideColor]="errorColorTheme"`) or force it
90
- programmatically (`provideColor.forceColor(theme)` / `clearForcedColor()`). Inside
91
- that scope, `--et-theme-color-primary-*` _is_ the error color.
92
+ error color, `injectSemanticColorTheme('error')` (reading it throws if the app
93
+ registered no `type: 'error'` theme) and either bind it
94
+ (`[etProvideColor]="errorColorTheme()"`) or force it programmatically
95
+ (`provideColor.forceColor(theme)` / `clearForcedColor()`). Inside that scope,
96
+ `--et-theme-color-primary-*` _is_ the error color.
97
+ - **A surface swaps a chosen accent; it never picks one.** The chosen accent is the
98
+ nearest `[etProvideColor]`, else the `isDefault` theme, or for
99
+ `injectSemanticColorTheme(type)` the theme of that `type`. The nearest surface's
100
+ `SurfaceTheme.colorThemeVariants` (e.g. `{ brand: 'brand-on-light', success: 'success-on-light' }`,
101
+ variants registered without a `type`) swaps it; an accent without an entry stays the
102
+ same on every surface. With no surface above, the root surface swaps, and
103
+ `provideColorScheme()` paints its swap of the default accent on `<html>`. A neutral that follows the surface is `color="surface"`
104
+ (`interactionColor`), not a variant. `colorTheme` and `semanticColorThemes` are
105
+ deprecated. Never add per-surface logic to a color theme or new CSS vars for it.
106
+ `injectErrorTheme()`, `injectWarningTheme()` and `injectSuccessTheme()` are
107
+ deprecated: they ignore the surface.
92
108
 
93
109
  ## Pitfalls
94
110
 
@@ -96,15 +112,21 @@ the interactive element itself, never on a wrapper.
96
112
  declared via `@property` can never default to a theme token. If the default should
97
113
  come from the theme, don't declare an `@property` - consume the theme token directly
98
114
  (optionally behind a `--_et-*` indirection var).
115
+ - `@property` `initial-value` also cannot use a **font-relative or container unit**
116
+ (`em`, `rem`, `ex`, `ch`, `lh`, `cap`, `ic`, `cq*`). The value must be computationally
117
+ independent, so the browser drops the whole rule and leaves the token unregistered -
118
+ with no error. Percentages and viewport units (`vh`, `vw`, `dvh`) are fine. When the
119
+ default has to be relative, use `syntax: '*'` with no `initial-value` and put the
120
+ default in a fallback at each use site: `var(--et-skeleton-size, 1em)`.
99
121
  - `--et-theme-color-primary-*` always resolves to the **nearest color scope**. A
100
122
  hardcoded semantic color in CSS can't be replaced by it unless the right theme is
101
123
  provided on that element.
102
- - Keep a static fallback (`var(--et-surface-border-solid, rgb(255 255 255 / 0.1))`) so
103
- components degrade in theme-less setups. `injectErrorTheme()` is the exception: it is
104
- a hard requirement wherever it is used.
105
- - **Cascade layers, not `:where()`, are what let a Tailwind utility override component
106
- CSS.** Wrap every component CSS file in `@layer components { … }`; `:where()` only
107
- flattens a component's own modifiers to single-class weight. See the `styling` rule.
108
-
109
- Styling a **story** file rather than a component? The colour rule is the same, but the
110
- Tailwind side has its own traps - {%skill:story-styling%}.
124
+ - A static fallback (`var(--et-surface-border-solid, rgb(255 255 255 / 0.1))`) is
125
+ permitted for theme-less setups, but themes make it unnecessary. `injectSemanticColorTheme()`
126
+ is a hard requirement wherever it is read.
127
+ - **Cascade layers, not `:where()`, decide who wins against SDK component CSS.** SDK
128
+ styles live in `@layer components`, so a utility or unlayered app CSS overrides them.
129
+ An app rule inside `@layer components` loses. See the `app-styling` rule.
130
+
131
+ Styling a **story** file rather than a component? The colour rule is the same. Check the
132
+ repository's Storybook guidance before assuming a Tailwind utility exists.
@@ -0,0 +1,397 @@
1
+ ---
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 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
+ kind: skill
5
+ scope: both
6
+ ---
7
+
8
+ # Reaching Jira through Timetrack
9
+
10
+ **No repository holds a Jira token.** The Timetrack desktop app holds one set of credentials
11
+ in this machine's keychain, and every repository asks it:
12
+
13
+ ```bash
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
+ ```
21
+
22
+ Add `--json` to any of them when you need to read a field rather than a line.
23
+
24
+ **Every write waits for the user's approval in the app.** `create`, `log`, `edit`, `standins
25
+ --remove | --rename | --merge | --resolve | --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
+ {%packageRunner%} ethlete-agents timetrack approvals # every write that waits now: id, asker, op, asked at, what it writes
32
+ {%packageRunner%} ethlete-agents timetrack approval <id> --reject # reject one that waits, as the app's Reject button does
33
+ ```
34
+
35
+ A reject answers at once and writes nothing to Jira or Tempo; an item that no longer waits is
36
+ refused and stays as it is. Reject only what the user asked you to, or a write you queued by
37
+ mistake. **There is no approve command and never will be: only the user approves, in the app.**
38
+
39
+ `ask` does what the app's "Ask auto mode again" does, on any day, without moving the day on
40
+ screen. It answers at once with what it queued; the model runs on the user's machine, and its new
41
+ answer replaces the stored one. It writes nothing to Jira or Tempo itself: a ticket or a name the
42
+ answer proposes waits for the user's approval as auto mode's own does.
43
+
44
+ ```bash
45
+ {%packageRunner%} ethlete-agents timetrack ask --stand-in <id> [--day YYYY-MM-DD] # an open stand-in, ids from `standins`
46
+ {%packageRunner%} ethlete-agents timetrack ask --context <context-id> [--day YYYY-MM-DD] # an unnamed band, ids from `rows`
47
+ ```
48
+
49
+ A subject the app does not offer the press on is refused: an unknown or resolved stand-in, one
50
+ the day holds no time of, a context a rule names or the user named by hand.
51
+
52
+ Tell the user a request waits in Timetrack, then read `approval` once they say they decided -
53
+ do not poll in a loop. A request that is still waiting at the end of the day it was asked on
54
+ expires; ask again rather than assuming it landed. Set `TIMETRACK_CLIENT` to name yourself in the
55
+ queue (Claude Code is named without it).
56
+
57
+ **Never ask the user for a Jira token, and never write one into a file.** If a command reports
58
+ that the app is not running, say so and ask the user to start it. That is the whole fix -
59
+ there is no per-repo fallback, by design: a secret copied into every checkout is a secret
60
+ nobody can rotate.
61
+
62
+ ## What each command is for
63
+
64
+ | Command | Use it when |
65
+ | ------------------------------------------ | --------------------------------------------------------------------------- |
66
+ | `status` | Before anything else, when a Jira command failed and you need the cause |
67
+ | `instance` | A setup step needs the instance's levels or its branch-subject field |
68
+ | `issue <KEY>` | The user names a key and you need its summary, type or parent |
69
+ | `search [text]` | The user describes work but names no key |
70
+ | `project [path]` | You need the project a repository files into |
71
+ | `create --summary "…"` | The work has no ticket and the user asked for one |
72
+ | `log --issue <KEY> --minutes <n>` | The user asks to record time that nothing observed |
73
+ | `day [YYYY-MM-DD]` | You need the evidence a day holds, not a screenshot of it |
74
+ | `rows [YYYY-MM-DD]` | You need the rows the day drew, and the ids an edit names them by |
75
+ | `edit <row-id> …` | The user asks you to correct one row of a day |
76
+ | `rules` | You need to know why a band was named, or why it was not |
77
+ | `standins` | You need to know which work still waits for a ticket, and for how long |
78
+ | `standins --remove <id>` | A placeholder is wrong or too wide, and the user asked you to delete it |
79
+ | `standins --rename <id> --name <text>` | The name a placeholder carries is wrong, and the user asked you to fix it |
80
+ | `standins --merge <id> --into <id>` | Two placeholders are one piece of work, and the user asked you to join them |
81
+ | `standins --resolve <id> --issue <KEY>` | The user named the issue a placeholder is, or the one a resolved one is now |
82
+ | `naming [YYYY-MM-DD]` | A checkout was never offered a name and you need the step that stopped |
83
+ | `worklogs [from] [to]` | You need what the user already booked in Tempo over a span of days |
84
+ | `calendar [from] [to]` | You need the user's meetings and calendar entries over a span of days |
85
+ | `sync <YYYY-MM-DD>` | The user asks what a Tempo sync of a day would write, or asks to sync it |
86
+ | `worklog --delete <id> --day <YYYY-MM-DD>` | The user asked you to delete one of their own Tempo worklogs |
87
+ | `approval <id>` | A write you queued: whether the user approved it, and what it answered |
88
+ | `approvals` | You need what waits in the queue, from every caller |
89
+ | `approval <id> --reject` | The user asked you to drop a waiting write, or you queued one by mistake |
90
+ | `ask --stand-in <id>` / `--context <id>` | The user asks auto mode to look at a stand-in or an unnamed band again |
91
+
92
+ `git-flow start` uses the same channel, so a branch is named from the real issue rather than
93
+ from a key you typed. Follow the repository's branch workflow when creating a branch.
94
+
95
+ ## Reading a day
96
+
97
+ The app's store is encrypted, so no shell reads a day off disk. `day` is the only way in:
98
+
99
+ ```bash
100
+ {%packageRunner%} ethlete-agents timetrack day # today: how many events, and of which kind
101
+ {%packageRunner%} ethlete-agents timetrack day 2026-09-10 --out /tmp/day.json
102
+ ```
103
+
104
+ A real day holds thousands of events, so **never print them**. Write them to a file with
105
+ `--out`, then read that file from a test or a script. Without `--out` the command reports
106
+ only the counts, which is what tells you whether a day holds the source you are looking for.
107
+
108
+ `--out` writes a new file, readable by its owner alone. A destination that already exists is
109
+ refused until you pass `--overwrite`, and a symlink is never written through. The file holds the
110
+ day as it was observed - window titles, paths and messages - so delete it when you are done.
111
+
112
+ `transcript` prints what the app transcribed of the user's own microphone during the day's calls,
113
+ one 30-second chunk per line. It exists only where the user turned call transcription on, and the
114
+ app deletes a chunk after seven days. Read it only when the task needs what was said.
115
+
116
+ ```bash
117
+ npx ethlete-agents timetrack transcript [YYYY-MM-DD]
118
+ ```
119
+
120
+ ## Reading and correcting the rows a day drew
121
+
122
+ `day` answers what the collectors saw. `rows` answers what the app made of it - the bands on the
123
+ timeline, with the id every edit names a row by, the day's totals and its warnings.
124
+
125
+ ```bash
126
+ {%packageRunner%} ethlete-agents timetrack rows # today
127
+ {%packageRunner%} ethlete-agents timetrack rows 2026-09-10 --json
128
+ ```
129
+
130
+ `edit` changes one row. Pass exactly one change per call:
131
+
132
+ ```bash
133
+ {%packageRunner%} ethlete-agents timetrack edit 'ABC-1@2026-09-10T11:00:00.000Z' --day 2026-09-10 \
134
+ --from 2026-09-10T13:15:00Z --to 2026-09-10T17:00:00Z
135
+ {%packageRunner%} ethlete-agents timetrack edit '<row-id>' --issue ABC-2
136
+ {%packageRunner%} ethlete-agents timetrack edit '<row-id>' --state rejected
137
+ ```
138
+
139
+ `rows` moves the app's own review to that day, so the user sees what you read. An edit is queued
140
+ like every write; once approved, its result's `applied` says how many edits found their row. A
141
+ running day is re-cut on every collector tick, so read `rows` again rather than reusing an id from
142
+ minutes ago.
143
+
144
+ An edit may correct a row's times, its name, its note and whether it syncs. It may not split, merge
145
+ or delete one - restructuring a day is the user's own decision, made on the screen.
146
+
147
+ ## Why a band carries the name it does
148
+
149
+ `rules` reads the settings that name a day's work - the attribution rules, the project links,
150
+ the background projects and the applications the user has ruled in or out. It holds no host, no
151
+ account and no token, so it is safe to quote back to the user.
152
+
153
+ ```bash
154
+ {%packageRunner%} ethlete-agents timetrack rules # a summary of the rules
155
+ {%packageRunner%} ethlete-agents timetrack rules --json # the whole answer
156
+ ```
157
+
158
+ Read it before claiming a band should have been named something: a rule that donates its time
159
+ carries no issue key, and a project in `backgroundProjects` keeps only the minutes no other
160
+ band claims.
161
+
162
+ ## Work with no ticket yet
163
+
164
+ A **stand-in** is a name the user gave work that Jira does not hold yet. It takes bands across
165
+ days and across checkouts, and it books nothing. `standins` lists them:
166
+
167
+ ```bash
168
+ {%packageRunner%} ethlete-agents timetrack standins # the open ones, oldest first
169
+ {%packageRunner%} ethlete-agents timetrack standins --json # the whole answer, resolved ones included
170
+ ```
171
+
172
+ **Never open or resolve a stand-in.** The name is the user's own word for their work, and the
173
+ app is the only place they give it. If the user asks for a ticket, use `create` and tell them
174
+ to resolve the stand-in in Timetrack.
175
+
176
+ A placeholder is drawn per branch, and per directory where the branch names no piece of work.
177
+ A base branch, or no branch at all, says nothing about what was worked on, so the directory the
178
+ commits touched answers instead. That directory is the project the repository declares - the one
179
+ carrying the `project.json` or `package.json` above the changed files. A repository that declares
180
+ none lets its own commits pick the grain between them. A directory named with a leading dot is
181
+ the checkout's own tooling, so it never answers: a commit in `.changeset` or `.github` is
182
+ bookkeeping for another piece of work.
183
+
184
+ Where the directories cannot answer either, the branch the checkout was next cut onto does. A
185
+ branch is very often cut once the work on it has started, so the minutes before the cut sit on
186
+ the base branch although they are that branch's work.
187
+
188
+ One placeholder written before any of that was true covers a whole checkout, and it blocks a
189
+ per-branch placeholder for every branch of it. The list says so:
190
+
191
+ ```
192
+ Competition journey spec frontend in FIFAGG 3d old, 3 day(s) of work
193
+ covers all of /home/tom/dev/fifagg/specs, so no branch of it gets one of its own
194
+ ```
195
+
196
+ Two separate things cause that, and either alone is enough: the record names no branch, or the
197
+ rule naming it names no branch. The line above reports both, so take it at its word rather than
198
+ reading `--json` yourself.
199
+
200
+ ### Give one another name
201
+
202
+ The name is only wrong, and the work behind it is right:
203
+
204
+ ```bash
205
+ {%packageRunner%} ethlete-agents timetrack standins --rename <id> --name '20260921 competition navigation rework'
206
+ ```
207
+
208
+ The days it holds and the rules that name it stay, which is what a delete and a fresh record would
209
+ lose. Rename one only when the user asks. The name is their own word for their work, so a name you
210
+ find unclear is not a fault to correct.
211
+
212
+ ### Resolve one to its issue
213
+
214
+ The user told you which Jira issue a placeholder is:
215
+
216
+ ```bash
217
+ {%packageRunner%} ethlete-agents timetrack standins --resolve <id> --issue <KEY>
218
+ ```
219
+
220
+ It resolves the placeholder as the user's own pick in the app does, so every band on every day it
221
+ held takes the key. One that is resolved already moves to the new key, as the app's Undo and a new
222
+ pick would. It writes nothing to Tempo, so a day of it already booked keeps the old key there. The
223
+ app reads the issue from Jira before it queues anything, so an unknown key or placeholder answers an
224
+ error and nothing waits. A done issue is fine. The approval card shows the name, the old key and the
225
+ new one with its summary.
226
+
227
+ ### Re-cut one that named two pieces of work
228
+
229
+ A record covering a whole checkout is repaired rather than deleted, because a delete strands its
230
+ days. `--split` re-cuts it into one record per directory, and moves each day onto the right one:
231
+
232
+ ```bash
233
+ {%packageRunner%} ethlete-agents timetrack standins --split <id> # the plan, writes nothing
234
+ {%packageRunner%} ethlete-agents timetrack standins --split <id> --force # queue it for approval
235
+ ```
236
+
237
+ Without `--force` it prints every directory the commits name, with its commit count and its days,
238
+ then says which of them it would write. A directory under three commits is an edit made in
239
+ passing rather than a piece of work, so it is listed but not written. Pass
240
+ `--paths <dir>,<dir>` to pick the pieces yourself.
241
+
242
+ The commits are read across every branch, and only the ones the checkout's own `user.email`
243
+ authored. That is the history the collector that opened the record read, so work left on an
244
+ unmerged branch still counts and a colleague's commits never shape a piece. `--author <email>`
245
+ overrides the address.
246
+
247
+ A day the record covers that holds no commit of yours goes back to unnamed, and the split names
248
+ it. That is the honest answer: nothing says which directory it was. The day review is where it
249
+ gets named, and `--claim <dir>` gives every such day to one directory instead.
250
+
251
+ The record and its checkout-wide rule always go, even when a day is left unnamed. Both are read
252
+ as covering every branch of the checkout, so keeping either would block any later branch from
253
+ getting a record of its own — which is the fault the repair exists to cure.
254
+
255
+ The record must name the checkout it was opened for. One the user opened by hand names none, and
256
+ the split refuses it.
257
+
258
+ You may delete one, because that takes a name away rather than putting one on the day:
259
+
260
+ ```bash
261
+ {%packageRunner%} ethlete-agents timetrack standins --remove <id>
262
+ ```
263
+
264
+ The rule that named it goes with it, and what happens next is worth knowing before you ask:
265
+
266
+ - The record names a branch. The delete refuses that branch, and no second one opens for it.
267
+ - It names none. The delete refuses nothing, so the next pass opens one per branch instead.
268
+
269
+ **Deleting one strands every day but today.** Only the day the app next reviews gets a new
270
+ placeholder; every earlier day the record covered then reads as unnamed, with nothing left to
271
+ say what it was. The command refuses such a delete and lists the days, and `--force` makes it
272
+ anyway. Ask the user before you use `--force`, and tell them which days they will lose.
273
+
274
+ ## Why a checkout was never offered a name
275
+
276
+ The day screen offers a whole checkout one issue when the user's own record already answers it. The
277
+ card is either drawn or it is not, and every step that can stop it is invisible from the screen.
278
+ `naming` names the step:
279
+
280
+ ```bash
281
+ {%packageRunner%} ethlete-agents timetrack naming # today
282
+ {%packageRunner%} ethlete-agents timetrack naming 2026-09-14
283
+ ```
284
+
285
+ It reports whether a Tempo token is stored, how far the read of the worklog history got, and for each
286
+ checkout the day saw either the offer or the reason there is none - `already-named`,
287
+ `no-project-link`, `no-history`, `project-too-small`, `too-few-days` or `share-too-low`. A `history`
288
+ of `failed` or `no-token` explains every checkout at once, so read that line first.
289
+
290
+ ## What Tempo already holds
291
+
292
+ `worklogs` reads the user's own Tempo worklogs straight from Tempo, whoever wrote them. It is
293
+ read-only, and the app keeps the Tempo token:
294
+
295
+ ```bash
296
+ {%packageRunner%} ethlete-agents timetrack worklogs # the last 7 days
297
+ {%packageRunner%} ethlete-agents timetrack worklogs 2026-09-01 2026-09-24 # both days included
298
+ {%packageRunner%} ethlete-agents timetrack worklogs 2026-09-01 2026-09-24 --json
299
+ ```
300
+
301
+ Without `--json` it prints one line per booked day: the total, then minutes per issue. `--json`
302
+ answers each worklog with its day, `startTime` as `HH:MM`, `durationMs`, `issueKey` and
303
+ description. A span is at most 92 days. The descriptions are the user's own words, so quote them
304
+ only when the task needs them.
305
+
306
+ ## What the calendar holds
307
+
308
+ `calendar` reads the Google calendars the app watches, straight from Google and read-only. The app
309
+ keeps the Google token:
310
+
311
+ ```bash
312
+ {%packageRunner%} ethlete-agents timetrack calendar # the last 7 days
313
+ {%packageRunner%} ethlete-agents timetrack calendar 2026-09-01 2026-09-24 # both days included
314
+ {%packageRunner%} ethlete-agents timetrack calendar 2026-09-01 2026-09-24 --json
315
+ ```
316
+
317
+ Without `--json` it prints each day with its meeting hours, then one line per entry: start, minutes,
318
+ title, and the user's answer where it is not a yes. All-day and declined entries are listed but not
319
+ counted. `--json` answers each entry with `calendarId`, `day`, `startMs`, `endMs`, `allDay`, `title`,
320
+ `attendeeCount` and `response`. A span is at most 92 days. Titles come from whoever sent the
321
+ invitation, so quote them only when the task needs them.
322
+
323
+ ## Syncing a day to Tempo
324
+
325
+ `sync` plans a day exactly as the app's Sync page does, and writes nothing:
326
+
327
+ ```bash
328
+ {%packageRunner%} ethlete-agents timetrack sync 2026-09-07
329
+ {%packageRunner%} ethlete-agents timetrack sync 2026-09-07 --json
330
+ ```
331
+
332
+ It prints every create, update and delete with its issue, local time, minutes, description and
333
+ reason; the rows it holds back (Tempo refuses an empty description); how many are unchanged or still
334
+ awaiting review; keys Jira does not know; and the foreign worklogs it never touches. The first line
335
+ carries a short **plan hash** over the write set.
336
+
337
+ Writing is a Tempo write, so it follows one rule without exception: **show the user the rows, and
338
+ write only after they confirmed those rows in this conversation.** Then pass the hash the read
339
+ printed:
340
+
341
+ ```bash
342
+ {%packageRunner%} ethlete-agents timetrack sync 2026-09-07 --write --plan 1a2b3c4d
343
+ ```
344
+
345
+ The write is queued, and the user approves it in the app as well - "Approve all" never includes
346
+ it. On approval the app plans the day again and refuses, writing nothing, when the hash differs -
347
+ the day changed since the user saw it, so read it again and ask again. `--write` without `--plan`
348
+ is refused at once. Once approved, `approval <id>` prints each row as `written`, `blocked`,
349
+ `skipped` or `failed`, with the worklog id or the error, and exits non-zero while a row did not
350
+ land. Never re-run the write to retry: a plan read
351
+ straight after a write can miss what Tempo just took and log it twice. The Sync page shows the run
352
+ and retries from its own plan. A "written, but not recorded" line means Tempo holds worklogs the
353
+ app no longer owns; tell the user to delete them in Tempo before this day is written again.
354
+
355
+ Both reads and writes move the app's Day screen to that day, as `rows` and `edit` do.
356
+
357
+ ## Deleting a Tempo worklog
358
+
359
+ `worklog --delete` deletes one of the user's own Tempo worklogs. Both flags are required; read the
360
+ id and its day off `worklogs --json`:
361
+
362
+ ```bash
363
+ {%packageRunner%} ethlete-agents timetrack worklog --delete 98765 --day 2026-09-07
364
+ ```
365
+
366
+ It is a Tempo write, so the same rule holds: **name the worklog to the user, and delete only after
367
+ they confirmed that one in this conversation.** The app refuses, deleting nothing, an id that is not
368
+ among the account's own worklogs on that day. The delete is queued for the user's approval like
369
+ `sync --write`; once approved, `approval <id>` answers what went, and the app drops its own record
370
+ of the worklog, so the next `sync` of the day plans from what Tempo holds.
371
+
372
+ ## Writes
373
+
374
+ Four commands write to Jira or Tempo, so all need the user to have asked for them in this
375
+ conversation - `sync --write` and `worklog --delete` are described above. Each is queued for the
376
+ user's approval in the app:
377
+
378
+ ```bash
379
+ {%packageRunner%} ethlete-agents timetrack create --summary "Reset password mail is not sent" --project FIP
380
+ {%packageRunner%} ethlete-agents timetrack log --issue FIP-2177 --minutes 45 --description "pairing call"
381
+ ```
382
+
383
+ - **`create`** files the issue, once approved, with the instance's own ticket settings - its type, its parent
384
+ rule and its subject field all come from the app, so the ticket is shaped like every other.
385
+ `--project` is needed unless the app holds exactly one picked project.
386
+ - **`log`** adds a row to the day in Timetrack. It is **not** a Tempo entry: the user reviews
387
+ the day and syncs it, which is what keeps an agent's row from double-booking against the
388
+ 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.
389
+
390
+ ## What the app decides, not you
391
+
392
+ - **The projects.** The user picked them in Timetrack. `search` is scoped to them, and
393
+ `--project <KEY>` is how you look outside.
394
+ - **The branch subject.** `issue` returns `subject` when the instance's subject field is set on
395
+ that issue. Prefer it over the summary; the summary is a paraphrase.
396
+ - **The instance.** Its host, its issue types and its hierarchy are settings in the app. Do not
397
+ hardcode any of them in this repo.
@@ -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.