@openora/create 0.4.1-canary.35 → 0.4.1-canary.351

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 (98) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/generated/versions.d.ts +11 -0
  3. package/dist/generated/versions.d.ts.map +1 -0
  4. package/dist/generated/versions.js +11 -0
  5. package/dist/generated/versions.js.map +1 -0
  6. package/dist/index.js +48 -93
  7. package/dist/index.js.map +1 -1
  8. package/dist/render.d.ts +15 -0
  9. package/dist/render.d.ts.map +1 -0
  10. package/dist/render.js +125 -0
  11. package/dist/render.js.map +1 -0
  12. package/package.json +8 -6
  13. package/template/README.md.tpl +12 -7
  14. package/template/__dot__claude/settings.json.tpl +22 -12
  15. package/template/__dot__env.example +10 -0
  16. package/template/__dot__gitignore +5 -0
  17. package/template/__dot__rulesync/commands/check.md +11 -10
  18. package/template/__dot__rulesync/commands/doctor.md.tpl +17 -0
  19. package/template/__dot__rulesync/commands/scaffold-module.md +3 -2
  20. package/template/__dot__rulesync/commands/scaffold-plugin.md +19 -3
  21. package/template/__dot__rulesync/commands/scaffold-route.md +1 -0
  22. package/template/__dot__rulesync/commands/start.md +2 -2
  23. package/template/__dot__rulesync/hooks/_shared.mjs +26 -13
  24. package/template/__dot__rulesync/hooks/guard-core.mjs.tpl +76 -28
  25. package/template/__dot__rulesync/hooks/guard-generated.mjs +15 -13
  26. package/template/__dot__rulesync/hooks/guard-mr.mjs +81 -0
  27. package/template/__dot__rulesync/hooks/guard-subagent.mjs +22 -6
  28. package/template/__dot__rulesync/hooks/post-edit.mjs.tpl +116 -0
  29. package/template/__dot__rulesync/hooks.json +27 -4
  30. package/template/__dot__rulesync/rules/conventions.md +99 -119
  31. package/template/__dot__rulesync/rules/db-conventions.md +6 -72
  32. package/template/__dot__rulesync/rules/e2e-conventions.md +33 -7
  33. package/template/__dot__rulesync/rules/frontend-conventions.md.tpl +35 -0
  34. package/template/__dot__rulesync/rules/oss-boundaries.md.tpl +78 -0
  35. package/template/__dot__rulesync/rules/overview.md +18 -82
  36. package/template/__dot__rulesync/skills/create-plugin/{SKILL.md → SKILL.md.tpl} +12 -23
  37. package/template/__dot__rulesync/skills/create-pr/SKILL.md.tpl +38 -0
  38. package/template/__dot__rulesync/skills/create-task/{SKILL.md → SKILL.md.tpl} +14 -22
  39. package/template/__dot__rulesync/skills/create-ui-module/{SKILL.md → SKILL.md.tpl} +12 -10
  40. package/template/__dot__rulesync/skills/enhance-prompt/{SKILL.md → SKILL.md.tpl} +4 -4
  41. package/template/__dot__rulesync/skills/handoff/SKILL.md +95 -0
  42. package/template/__dot__rulesync/skills/review/SKILL.md.tpl +277 -0
  43. package/template/__dot__rulesync/skills/ship/SKILL.md.tpl +157 -0
  44. package/template/__dot__rulesync/skills/ship/handoff.md.tpl +48 -0
  45. package/template/__dot__rulesync/skills/ship/watch-pr.mjs +172 -0
  46. package/template/__dot__rulesync/skills/ui-verify/SKILL.md.tpl +120 -0
  47. package/template/__dot__rulesync/skills/ui-verify/layout-check.check.sh +58 -0
  48. package/template/__dot__rulesync/skills/ui-verify/layout-check.js +163 -0
  49. package/template/__dot__rulesync/subagents/builder.md.tpl +115 -0
  50. package/template/__dot__rulesync/subagents/compliance-reviewer.md +78 -0
  51. package/template/__dot__rulesync/subagents/debugger.md.tpl +86 -0
  52. package/template/__dot__rulesync/subagents/deployer.md.tpl +54 -0
  53. package/template/__dot__rulesync/subagents/expert.md +28 -34
  54. package/template/__dot__rulesync/subagents/planner.md.tpl +39 -0
  55. package/template/__dot__rulesync/subagents/quality-reviewer.md.tpl +112 -0
  56. package/template/__dot__rulesync/subagents/security-reviewer.md.tpl +85 -0
  57. package/template/__dot__rulesync/subagents/tester.md.tpl +102 -0
  58. package/template/__dot__rulesync/sync.json.tpl +26 -0
  59. package/template/apps/api/package.json.tpl +2 -2
  60. package/template/apps/api/src/extensions.config.ts.tpl +12 -0
  61. package/template/apps/api/src/main.ts.tpl +1 -4
  62. package/template/apps/api/src/seed.ts.tpl +8 -1
  63. package/template/docker-compose.yml +13 -0
  64. package/template/docs/agents/cross-repo.md.tpl +37 -0
  65. package/template/docs/agents/forge.md.tpl +54 -0
  66. package/template/docs/agents/issue-tracker.md.tpl +42 -0
  67. package/template/docs/standards/comments.md +16 -0
  68. package/template/docs/standards/database.md +77 -0
  69. package/template/docs/standards/enforcement.md +12 -0
  70. package/template/docs/standards/errors.md +30 -0
  71. package/template/docs/standards/frontend.md +60 -0
  72. package/template/docs/standards/functions.md +38 -0
  73. package/template/docs/standards/git-delivery.md +20 -0
  74. package/template/docs/standards/module-structure.md +37 -0
  75. package/template/docs/standards/testing.md +88 -0
  76. package/template/docs/standards/types.md +31 -0
  77. package/template/package.json.tpl +15 -9
  78. package/template/rulesync.jsonc +3 -3
  79. package/template/tools/oss-worktree.mjs.tpl +105 -0
  80. package/template/tools/review-precheck.mjs +531 -0
  81. package/template/tools/sync-agents.mjs +257 -0
  82. package/dist/generated/core-version.d.ts +0 -2
  83. package/dist/generated/core-version.d.ts.map +0 -1
  84. package/dist/generated/core-version.js +0 -2
  85. package/dist/generated/core-version.js.map +0 -1
  86. package/template/__dot__rulesync/commands/doctor.md +0 -16
  87. package/template/__dot__rulesync/hooks/post-edit.mjs +0 -57
  88. package/template/__dot__rulesync/rules/oss-boundaries.md +0 -31
  89. package/template/__dot__rulesync/skills/add-feature/SKILL.md +0 -113
  90. package/template/__dot__rulesync/skills/add-feature/handoff.md +0 -58
  91. package/template/__dot__rulesync/skills/create-pr/SKILL.md +0 -55
  92. package/template/__dot__rulesync/skills/review/SKILL.md +0 -110
  93. package/template/__dot__rulesync/subagents/builder.md +0 -93
  94. package/template/__dot__rulesync/subagents/debugger.md +0 -82
  95. package/template/__dot__rulesync/subagents/deployer.md +0 -66
  96. package/template/__dot__rulesync/subagents/qa.md +0 -88
  97. package/template/__dot__rulesync/subagents/quality-reviewer.md +0 -67
  98. package/template/__dot__rulesync/subagents/security-reviewer.md +0 -54
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ // Watches the MR or PR of a branch and prints one line, then exits, when ship has work on it:
3
+ // failed | conflicts | threads <ids> | green (only with --until-green) | merged | closed | error <message>
4
+ // A thread waits for an answer until its last comment is an "Agent:" reply from the account this runs as
5
+ // (anyone can type the prefix); it is reported once it stayed the same for two checks, so a review with
6
+ // ten comments is one line. Run it in the background:
7
+ // node watch-pr.mjs <branch> [--until-green] WATCH_DEBUG=1 prints every check to stderr
8
+ // --once checks one time, for a caller that polls on its own (a scheduler): threads are reported at
9
+ // once, and `none` means nothing to do.
10
+ import { execFileSync } from 'node:child_process';
11
+
12
+ const EVERY_MS = 120_000;
13
+ // ponytail: a repo with no CI on the last commit reads green after this; a head-SHA match on checks if it matters
14
+ const NO_CI_MS = 10 * 60_000;
15
+ const MAX_ERRORS = 5;
16
+ const AGENT_REPLY = 'Agent:';
17
+ const FAILED = [
18
+ 'FAILURE',
19
+ 'ERROR',
20
+ 'CANCELLED',
21
+ 'TIMED_OUT',
22
+ 'ACTION_REQUIRED',
23
+ 'STARTUP_FAILURE',
24
+ 'failed',
25
+ 'canceled',
26
+ ];
27
+
28
+ const [branch, ...flags] = process.argv.slice(2);
29
+ if (!branch) {
30
+ console.error('usage: watch-pr.mjs <branch> [--until-green | --once]');
31
+ process.exit(2);
32
+ }
33
+ const untilGreen = flags.includes('--until-green');
34
+ const once = flags.includes('--once');
35
+
36
+ const run = (cmd, args) =>
37
+ execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
38
+ const json = (cmd, args) => JSON.parse(run(cmd, args));
39
+ const unansweredIds = (lastComments, me) =>
40
+ lastComments
41
+ .filter(
42
+ (comment) => !(comment.author === me && comment.body.trimStart().startsWith(AGENT_REPLY)),
43
+ )
44
+ .map((comment) => String(comment.id))
45
+ .sort();
46
+
47
+ const THREADS = `query($owner: String!, $name: String!, $number: Int!, $endCursor: String) {
48
+ viewer { login }
49
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) {
50
+ reviewThreads(first: 100, after: $endCursor) {
51
+ nodes { isResolved comments(last: 1) { nodes { id body author { login } } } }
52
+ pageInfo { hasNextPage endCursor }
53
+ }
54
+ comments(last: 1) { nodes { id body author { login } } }
55
+ } }
56
+ }`;
57
+
58
+ function github() {
59
+ const pr = json('gh', [
60
+ 'pr',
61
+ 'view',
62
+ branch,
63
+ '--json',
64
+ 'number,url,state,mergeable,statusCheckRollup',
65
+ ]);
66
+ const [, owner, name] = new URL(pr.url).pathname.split('/');
67
+ const args = [
68
+ 'api',
69
+ 'graphql',
70
+ '--paginate',
71
+ '--slurp',
72
+ '-f',
73
+ `query=${THREADS}`,
74
+ '-f',
75
+ `owner=${owner}`,
76
+ '-f',
77
+ `name=${name}`,
78
+ '-F',
79
+ `number=${pr.number}`,
80
+ ];
81
+ const pages = json('gh', args).map((page) => page.data);
82
+ const open = pages
83
+ .flatMap(({ repository }) => repository.pullRequest.reviewThreads.nodes)
84
+ .filter((thread) => !thread.isResolved)
85
+ .flatMap((thread) => thread.comments.nodes)
86
+ .concat(pages[0].repository.pullRequest.comments.nodes)
87
+ .map((comment) => ({ ...comment, author: comment.author?.login }));
88
+ const checks = pr.statusCheckRollup ?? [];
89
+ const ci = !checks.length
90
+ ? null
91
+ : checks.some((check) => FAILED.includes(check.conclusion || check.state))
92
+ ? 'failed'
93
+ : checks.some((check) =>
94
+ check.status
95
+ ? check.status !== 'COMPLETED'
96
+ : check.state === 'PENDING' || check.state === 'EXPECTED',
97
+ )
98
+ ? 'running'
99
+ : 'passed';
100
+ return {
101
+ state: pr.state.toLowerCase(),
102
+ conflicts: pr.mergeable === 'CONFLICTING',
103
+ ci,
104
+ unanswered: unansweredIds(open, pages[0].viewer.login),
105
+ };
106
+ }
107
+
108
+ function gitlab() {
109
+ const mr = json('glab', ['mr', 'view', branch, '-F', 'json']);
110
+ const discussions = run('glab', [
111
+ 'api',
112
+ '--paginate',
113
+ '--output',
114
+ 'ndjson',
115
+ `projects/${mr.project_id}/merge_requests/${mr.iid}/discussions?per_page=100`,
116
+ ])
117
+ .split('\n')
118
+ .filter(Boolean)
119
+ .map((line) => JSON.parse(line));
120
+ const open = discussions
121
+ .map((discussion) => discussion.notes.filter((note) => !note.system))
122
+ .filter(
123
+ (notes) =>
124
+ notes.length &&
125
+ !(
126
+ notes.some((note) => note.resolvable) &&
127
+ notes.every((note) => !note.resolvable || note.resolved)
128
+ ),
129
+ )
130
+ .map((notes) => notes.at(-1))
131
+ .map((note) => ({ ...note, author: note.author?.username }));
132
+ const pipeline = mr.head_pipeline?.sha === mr.sha ? mr.head_pipeline.status : null;
133
+ const ci = !pipeline
134
+ ? null
135
+ : FAILED.includes(pipeline)
136
+ ? 'failed'
137
+ : ['success', 'skipped', 'manual'].includes(pipeline)
138
+ ? 'passed'
139
+ : 'running';
140
+ const state = mr.state === 'opened' ? 'open' : mr.state === 'merged' ? 'merged' : 'closed';
141
+ const me = json('glab', ['api', 'user']).username;
142
+ return { state, conflicts: mr.has_conflicts === true, ci, unanswered: unansweredIds(open, me) };
143
+ }
144
+
145
+ const say = (line) => {
146
+ console.log(line);
147
+ process.exit(0);
148
+ };
149
+ const status = /github\.com/.test(run('git', ['remote', 'get-url', 'origin'])) ? github : gitlab;
150
+ const started = Date.now();
151
+ let seen = null;
152
+ let errors = 0;
153
+ for (;;) {
154
+ try {
155
+ const now = status();
156
+ errors = 0;
157
+ if (process.env.WATCH_DEBUG) console.error(JSON.stringify(now));
158
+ if (now.state !== 'open') say(now.state);
159
+ if (now.conflicts) say('conflicts');
160
+ if (now.ci === 'failed') say('failed');
161
+ const ids = now.unanswered.join(' ');
162
+ if (ids && (once || ids === seen)) say(`threads ${ids}`);
163
+ seen = ids;
164
+ const green = now.ci === 'passed' || (now.ci === null && Date.now() - started >= NO_CI_MS);
165
+ if (untilGreen && green && !ids) say('green');
166
+ if (once) say('none');
167
+ } catch (reason) {
168
+ if (once || ++errors >= MAX_ERRORS)
169
+ say(`error ${reason instanceof Error ? reason.message.split('\n')[0] : reason}`);
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, EVERY_MS));
172
+ }
@@ -0,0 +1,120 @@
1
+ ---
2
+ name: ui-verify
3
+ targets: ['*']
4
+ description: >
5
+ Verify a UI change in a real browser with the global `playwright-cli`: cheap checks first,
6
+ then four screenshots per changed screen read for spacing, overlap, cut text and contrast,
7
+ then the per-AC videos for the PR. Use after any change to a page, component, style or
8
+ player/operator flow, on "check it looks right", "verify the UI", "does it render", "take a
9
+ screenshot", "record a video", and to reproduce a UI bug before fixing it.
10
+ claudecode:
11
+ context: fork
12
+ model: sonnet
13
+ effort: medium
14
+ ---
15
+
16
+ # ui-verify ({{name}})
17
+
18
+ Global `playwright-cli` (`npm i -g @playwright/cli` if missing), never a browser MCP: it costs a
19
+ fraction of the tokens. Do not spawn subagents. Return text only. A subagent (`tester`, `builder`) does not
20
+ invoke this skill, which forks one of its own: it reads this file and follows it itself.
21
+
22
+ ## Stack
23
+
24
+ - Player app :3000, backoffice :3002, API :3001, unless the repo's rules name other ports (a
25
+ worktree slot, say). Ports held by another checkout: never kill it, ask.
26
+ - Accounts: the seed's (`admin@oss.dev` / `password1234` after `pnpm db:seed`). Save a login once
27
+ with `state-save /tmp/ui-verify/<app>.json`, `state-load` it after.
28
+ - Theme: switch with the app's own toggle in a video; for a still, set what the toggle sets
29
+ (a cookie or `localStorage`) with `eval`, then reload.
30
+
31
+ ## Cost ladder
32
+
33
+ Deterministic checks first, reading pixels last. Write each check as an assertion with an
34
+ expected value (`[data-testid=balance]` text matches `/\d+(\.\d+)? USDT/`) and stop at the
35
+ first rung that answers it.
36
+
37
+ 1. **Shell.** The route serves: `curl -s -o /dev/null -w '%{http_code}' <url>`.
38
+ 2. **Browser, one value.** `eval` for existence, text, count, computed style; `console error`
39
+ and `requests` grepped for `4\d\d|5\d\d`; `find "<text>"` for one node.
40
+ 3. **Structure.** `snapshot <target>` scoped to the changed component; a full-page `snapshot`
41
+ only with `--depth` or `--filename` + `grep`.
42
+ 4. **Pixels.** The screenshot review below.
43
+
44
+ Batch commands in one bash call. Output over ~50 lines goes to `/tmp/ui-verify/` and gets
45
+ `grep`, not read whole. One named session per run (`-s=<name>`), `close` at the end.
46
+
47
+ ## Screenshot review
48
+
49
+ Screenshots are for you, videos for the reviewer: nobody reads a video back, so layout defects
50
+ are caught here. Every changed screen, after the checks pass and before the videos. Four shots:
51
+ mobile and desktop, light and dark (backoffice: desktop only).
52
+
53
+ - Sizes: never shrink a shot, halving it halves every padding you judge. Mobile 390x844 at 2x
54
+ device pixels (780x1688), desktop 1440x900 at 1x: both under the 2000 px per-image cap a
55
+ session with over 20 images gets, about 1.7k tokens each.
56
+ - Tall screens: one shot per viewport height (`eval "() => scrollTo(0, <y>)"`, then shoot),
57
+ never `--full-page`, which downscales a long page until its text is unreadable.
58
+ - Before reading, run `layout-check.js` in each of the four. It lists overlapping, clipped,
59
+ truncated and off-screen text and a page that scrolls sideways; every line it returns is a
60
+ check to settle on the shot. `sh .claude/skills/ui-verify/layout-check.check.sh` is its self-test.
61
+ - Read each shot for: spacing consistent with its neighbours, alignment, overlapping or cut text,
62
+ anything off-screen or out of its container, contrast in dark, and the ticket's designs. Every
63
+ shot gets a report line, a clean one too. A defect is a FAIL with the png path; it gets fixed
64
+ and shot again.
65
+
66
+ ```bash
67
+ mkdir -p /tmp/ui-verify
68
+ echo '{"browser":{"contextOptions":{"deviceScaleFactor":2}}}' > /tmp/ui-verify/2x.json
69
+ playwright-cli -s=m open <url> --config=/tmp/ui-verify/2x.json && playwright-cli -s=m resize 390 844
70
+ playwright-cli -s=m eval "$(cat .claude/skills/ui-verify/layout-check.js)"
71
+ playwright-cli -s=m screenshot --hires --filename=/tmp/ui-verify/<screen>-mobile-dark.png
72
+ # switch theme, reload, repeat as -light; desktop in its own session:
73
+ # no --config, resize 1440 900, no --hires
74
+ ```
75
+
76
+ In the PR, after the Videos section, collapsed per screen (upload as in `docs/agents/forge.md`):
77
+
78
+ ```markdown
79
+ <details><summary>Screenshots: <screen></summary>
80
+
81
+ | | Light | Dark |
82
+ | ------- | ------------------------ | ----------------------- |
83
+ | Mobile | ![mobile light](upload) | ![mobile dark](upload) |
84
+ | Desktop | ![desktop light](upload) | ![desktop dark](upload) |
85
+
86
+ </details>
87
+ ```
88
+
89
+ ## Video evidence
90
+
91
+ Last, once, on the final commit after the tests are green and review is clean (`ship` step 4b):
92
+ a clip taken earlier is thrown away by the next fix round. One clip per acceptance criterion
93
+ (`docs/standards/testing.md`), recorded on the local stack. The one early clip is a fix's
94
+ "before", taken when the bug is reproduced; its "after" twin comes here. Never read a video back.
95
+
96
+ ACs a spec drives come first: every spec that drives one, new or existing, in one run with video
97
+ on (the repo's record switch, or `test.use({ video: 'on' })`); copy each
98
+ `test-results/<test>/video.webm` to `<ac>-<slug>-{desktop,mobile}.webm`. Only an AC no spec
99
+ drives gets `playwright-cli`, one at mobile size (`resize 390 844`) and one at desktop
100
+ (`resize 1440 900`), each switching theme with the app's toggle partway through:
101
+
102
+ ```bash
103
+ playwright-cli -s=v video-start /tmp/ui-verify/ac1-<slug>-mobile.webm
104
+ playwright-cli -s=v video-show-actions
105
+ playwright-cli -s=v video-chapter "AC1: <criterion in a few words>" --duration=1500
106
+ # drive the AC ... toggle the theme ... until its outcome is on screen
107
+ playwright-cli -s=v video-stop
108
+ ```
109
+
110
+ ## Report
111
+
112
+ One line per check: `PASS|FAIL - <check> - rung <n> - expected <x>, got <y> - <artifact path>`,
113
+ plus the failure detail for each FAIL. A pixel claim neither a measurement nor the shot can
114
+ settle is `UNSURE - <check> - <what is ambiguous> - <png path>`, never a guess. Return every shot
115
+ and clip path.
116
+
117
+ ## Bugs
118
+
119
+ Reproduce here first, as close to real use as possible, then fix, then rerun the same checks.
120
+ A fix without a before and after run is unverified.
@@ -0,0 +1,58 @@
1
+ #!/bin/sh
2
+ # Self-check for layout-check.js: the page below plants 5 defects next to 5 look-alikes that are fine.
3
+ set -e
4
+ dir=$(dirname "$0")
5
+ fixture=$(cat <<'HTML'
6
+ <!doctype html>
7
+ <html>
8
+ <body style="margin: 0; font: 16px sans-serif; width: 390px">
9
+ <p>
10
+ Clean wrapped paragraph that wraps across lines with a <a href="#">link inside</a> and more
11
+ text after it.
12
+ </p>
13
+ <div data-testid="overlap-card" style="position: relative; height: 40px">
14
+ <span>Balance</span><span style="position: absolute; left: 20px; top: 0">USDT 12.50</span>
15
+ </div>
16
+ <div data-testid="clip-box" style="height: 18px; overflow: hidden">
17
+ <p style="margin: 0">Line one<br />Line two cut off</p>
18
+ </div>
19
+ <div
20
+ data-testid="ellipsis"
21
+ style="width: 80px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
22
+ >
23
+ A very long game title
24
+ </div>
25
+ <span style="position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0)"
26
+ >Screen reader only</span
27
+ >
28
+ <div style="overflow: hidden; width: 200px; white-space: nowrap">
29
+ <div style="display: inline-block; width: 200px">Slide one</div>
30
+ <div style="display: inline-block; width: 200px">Slide two parked</div>
31
+ </div>
32
+ <div data-testid="wide" style="white-space: nowrap">
33
+ This label is far too wide for a mobile viewport and runs out
34
+ </div>
35
+ <div style="overflow-x: auto; white-space: nowrap; width: 200px">
36
+ Scrollable chips row that is wider than its box on purpose
37
+ </div>
38
+ <p style="position: relative">Under the modal text</p>
39
+ <div style="position: fixed; top: 0; left: 0; width: 390px; height: 60px; background: #fff">
40
+ <p style="margin: 4px">Modal title over page text</p>
41
+ </div>
42
+ </body>
43
+ </html>
44
+ HTML
45
+ )
46
+ url="data:text/html;base64,$(printf '%s' "$fixture" | base64 | tr -d '\n')"
47
+ playwright-cli -s=layout-check open "$url" >/dev/null 2>&1
48
+ playwright-cli -s=layout-check resize 390 844 >/dev/null
49
+ out=$(playwright-cli -s=layout-check eval "$(cat "$dir/layout-check.js")")
50
+ playwright-cli -s=layout-check close >/dev/null
51
+ for expected in PAGE-SCROLLS-X 'CLIPPED.*clip-box' 'TRUNCATED.*ellipsis' 'OFFSCREEN.*wide' 'OVERLAP.*overlap-card.*with.*overlap-card'; do
52
+ echo "$out" | grep -q "$expected" || { echo "FAIL: missing $expected"; echo "$out"; exit 1; }
53
+ done
54
+ # Findings never carry page text: a real screen can show player data.
55
+ ! echo "$out" | grep -qE 'Balance|USDT|Line two|game title|too wide' || { echo "FAIL: page text in findings"; echo "$out"; exit 1; }
56
+ count=$(echo "$out" | grep -cE '^ "(PAGE|CLIPPED|TRUNCATED|OFFSCREEN|OVERLAP)')
57
+ [ "$count" -eq 5 ] || { echo "FAIL: $count findings, expected 5"; echo "$out"; exit 1; }
58
+ echo PASS
@@ -0,0 +1,163 @@
1
+ // Layout defects a screenshot read can miss: overlapping text, text cut by an
2
+ // `overflow: hidden|clip` box, text sticking out of the viewport sideways.
3
+ // Run: playwright-cli -s=v eval "$(cat .claude/skills/ui-verify/layout-check.js)"
4
+ // Returns one line per finding; [] is clean. Findings are leads for the screenshot
5
+ // review, not verdicts: a carousel peek or a deliberate ellipsis can show up here.
6
+ // A finding names a selector and a position, never the text: a screen can show player data.
7
+ // playwright-cli eval takes a bare function expression.
8
+ // oxlint-disable-next-line no-unused-expressions
9
+ () => {
10
+ const EDGE_PX = 1;
11
+ const OVERLAP_PX = 2;
12
+ const MAX_TEXTS = 800;
13
+ const MAX_FINDINGS = 40;
14
+
15
+ const describe = (el) => {
16
+ const tagged = el.closest('[data-testid]');
17
+ const testId = tagged ? `[data-testid="${tagged.getAttribute('data-testid')}"] ` : '';
18
+ const classes = [...el.classList]
19
+ .slice(0, 2)
20
+ .map((name) => `.${name}`)
21
+ .join('');
22
+ return `${testId}${el.tagName.toLowerCase()}${classes}`;
23
+ };
24
+ const clips = (style) => /hidden|clip/.test(style.overflowX + style.overflowY);
25
+ const scrolls = (style) => /auto|scroll/.test(style.overflowX + style.overflowY);
26
+ const overflowBy = (inner, outer) =>
27
+ [
28
+ ['top', outer.top - inner.top],
29
+ ['right', inner.right - outer.right],
30
+ ['bottom', inner.bottom - outer.bottom],
31
+ ['left', outer.left - inner.left],
32
+ ].filter(([, px]) => px > EDGE_PX);
33
+ const intersects = (a, b) =>
34
+ a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
35
+ const isOpaque = (el) => {
36
+ const style = getComputedStyle(el);
37
+ const alpha = style.backgroundColor.match(/[\d.]+/g)?.[3] ?? '1';
38
+ return (
39
+ style.backgroundImage !== 'none' ||
40
+ style.backdropFilter !== 'none' ||
41
+ (style.backgroundColor !== 'transparent' && Number(alpha) >= 0.5)
42
+ );
43
+ };
44
+ // A modal or sheet over the page puts an opaque layer between the two texts in the paint stack.
45
+ const isLayerBetween = (a, b, x, y) => {
46
+ const stack = document.elementsFromPoint(x, y);
47
+ const [upper, lower] = [stack.indexOf(a), stack.indexOf(b)].sort((p, q) => p - q);
48
+ if (upper < 0) {return false;}
49
+ const below = stack[lower];
50
+ return stack.slice(upper + 1, lower).some((el) => !el.contains(below) && isOpaque(el));
51
+ };
52
+
53
+ const findings = [];
54
+ const at = (r) =>
55
+ `at ${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}`;
56
+ const report = (kind, el, box, detail) =>
57
+ findings.push(`${kind} - ${describe(el)} ${at(box)} - ${detail}`);
58
+
59
+ const texts = [];
60
+ const range = document.createRange();
61
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
62
+ for (let node = walker.nextNode(); node && texts.length < MAX_TEXTS; node = walker.nextNode()) {
63
+ const el = node.parentElement;
64
+ if (!node.textContent.trim() || !el || el.closest('script,style,noscript,template')) {continue;}
65
+ if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {continue;}
66
+ range.selectNodeContents(node);
67
+ const lines = [...range.getClientRects()].filter((r) => r.width >= 1 && r.height >= 1);
68
+ if (!lines.length) {continue;}
69
+ const box = range.getBoundingClientRect();
70
+
71
+ let hiddenByDesign = false;
72
+ let clippedBy = null;
73
+ let insideScroller = false;
74
+ for (
75
+ let a = el, child = null;
76
+ a && a !== document.body && !insideScroller;
77
+ child = a, a = a.parentElement
78
+ ) {
79
+ const style = getComputedStyle(a);
80
+ const outer = a.getBoundingClientRect();
81
+ if (!clips(style)) {
82
+ insideScroller = scrolls(style);
83
+ continue;
84
+ }
85
+ // sr-only boxes and slides parked fully outside their track are meant to be unseen;
86
+ // a block only partly inside its clipper has lines cut off.
87
+ const shown = child ? child.getBoundingClientRect() : box;
88
+ if (outer.width <= 1 || outer.height <= 1 || !intersects(shown, outer)) {
89
+ hiddenByDesign = true;
90
+ break;
91
+ }
92
+ // Glyphs taller than a tight line-height poke past the box without any layout overflow.
93
+ const overflows =
94
+ a.scrollWidth > a.clientWidth + EDGE_PX || a.scrollHeight > a.clientHeight + EDGE_PX;
95
+ if (!clippedBy && overflows && overflowBy(box, outer).length) {clippedBy = { a, style, outer };}
96
+ }
97
+ if (hiddenByDesign) {continue;}
98
+
99
+ if (clippedBy) {
100
+ const edges = overflowBy(box, clippedBy.outer).map(
101
+ ([edge, px]) => `${edge} ${Math.round(px)}px`,
102
+ );
103
+ const isEllipsis =
104
+ clippedBy.style.textOverflow === 'ellipsis' || clippedBy.style.webkitLineClamp !== 'none';
105
+ const kind = isEllipsis ? 'TRUNCATED' : 'CLIPPED';
106
+ report(kind, el, box, `cut by ${describe(clippedBy.a)} at ${edges.join(', ')}`);
107
+ }
108
+ if (
109
+ !clippedBy &&
110
+ !insideScroller &&
111
+ (box.right > innerWidth + EDGE_PX || box.left < -EDGE_PX)
112
+ ) {
113
+ report(
114
+ 'OFFSCREEN',
115
+ el,
116
+ box,
117
+ `x ${Math.round(box.left)}..${Math.round(box.right)}, viewport ${innerWidth}`,
118
+ );
119
+ }
120
+ // Only the part left visible can overlap anything.
121
+ const shownLines = clippedBy
122
+ ? lines
123
+ .map((r) => ({
124
+ left: Math.max(r.left, clippedBy.outer.left),
125
+ top: Math.max(r.top, clippedBy.outer.top),
126
+ right: Math.min(r.right, clippedBy.outer.right),
127
+ bottom: Math.min(r.bottom, clippedBy.outer.bottom),
128
+ }))
129
+ .filter((r) => r.right > r.left && r.bottom > r.top)
130
+ : lines;
131
+ texts.push({ el, box, lines: shownLines });
132
+ }
133
+
134
+ const overlapOf = (ra, rb) => {
135
+ const left = Math.max(ra.left, rb.left);
136
+ const top = Math.max(ra.top, rb.top);
137
+ const width = Math.min(ra.right, rb.right) - left;
138
+ const height = Math.min(ra.bottom, rb.bottom) - top;
139
+ return width > OVERLAP_PX && height > OVERLAP_PX
140
+ ? { x: left + width / 2, y: top + height / 2 }
141
+ : null;
142
+ };
143
+ // ponytail: O(n^2) over line boxes, fine under MAX_TEXTS; bucket by row if pages outgrow it.
144
+ for (let i = 0; i < texts.length; i++) {
145
+ for (let j = i + 1; j < texts.length; j++) {
146
+ const a = texts[i];
147
+ const b = texts[j];
148
+ const point = a.lines.flatMap((ra) => b.lines.map((rb) => overlapOf(ra, rb))).find(Boolean);
149
+ if (point && !isLayerBetween(a.el, b.el, point.x, point.y)) {
150
+ report('OVERLAP', a.el, a.box, `with ${describe(b.el)} ${at(b.box)}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ const page = document.documentElement;
156
+ if (page.scrollWidth > page.clientWidth + EDGE_PX) {
157
+ findings.unshift(
158
+ `PAGE-SCROLLS-X - html - scrollWidth ${page.scrollWidth} > ${page.clientWidth}`,
159
+ );
160
+ }
161
+ const extra = findings.length - MAX_FINDINGS;
162
+ return extra > 0 ? [...findings.slice(0, MAX_FINDINGS), `+${extra} more`] : findings;
163
+ }
@@ -0,0 +1,115 @@
1
+ ---
2
+ targets:
3
+ - '*'
4
+ name: builder
5
+ description: >-
6
+ Senior fullstack engineer for a downstream igaming built on @openora/*. Configures
7
+ extensions.config.ts, authors overlay plugins, swaps vendor adapters (KYC,
8
+ PSP, notifications), and builds UI in apps/web, apps/backoffice, and packages/ui.
9
+ Use this agent to build or extend features in a consumer igaming repo that wraps
10
+ the OSS platform.
11
+ claudecode:
12
+ model: sonnet
13
+ effort: high
14
+ ---
15
+
16
+ You are a senior fullstack engineer building a downstream igaming on top of the OSS platform (`@openora/*`). You never modify OSS core - you extend it from the outside via the plugin system. The repo rule files (conventions, oss-boundaries, db-conventions) apply to everything you write.
17
+
18
+ ## Ground first
19
+
20
+ 1. `catalog-overview` (oss MCP) - what the platform already ships; don't rebuild it.
21
+ 2. `list-adapters` - which vendor seams exist to override (KYC, PSP, notifications, ...).
22
+ 3. Read `apps/api/src/extensions.config.ts` - everything active is registered there.
23
+ 4. Read `AGENTS.md` at the repo root - it is the canonical brief for this consumer repo.
24
+ 5. Library API in doubt (Next, React, Drizzle, Zod, `@openora/*`)? Check current docs via context7/web search - don't code from memory.
25
+
26
+ ## Local platform checkout
27
+
28
+ `@openora/*` resolves to the published package by default. When the work depends on a core
29
+ change that is not released yet, or when you are iterating against core, link the local
30
+ checkout instead of waiting for a canary: `pnpm link:oss` (points at `{{ossDir}}`),
31
+ `pnpm unlink:oss` to go back.
32
+
33
+ Prefer linking whenever a core-side fix and a consumer-side fix are likely to bounce off each
34
+ other. Waiting on a publish per round trip is the slow path.
35
+
36
+ While linked:
37
+
38
+ - The lockfile is hidden, so do NOT add, remove or change a dependency and do NOT run
39
+ `pnpm install`. Run `pnpm unlink:oss` first if you must.
40
+ - Core source is not what you import - `dist` is. A core edit is invisible here until core is
41
+ rebuilt: `pnpm -F @openora/core build` in the checkout. For hot reload, run
42
+ `pnpm -F @openora/core watch` there once and leave it running.
43
+ - Linking lets you CONSUME an unreleased core. To change core, link an OSS worktree instead
44
+ of the main checkout: `pnpm oss:worktree <branch> --link` (see Rules below).
45
+
46
+ ## Consumer repo structure
47
+
48
+ ```
49
+ {{name}}/
50
+ apps/
51
+ api/ # thin wrapper: createApp from '@openora/core/server'
52
+ src/extensions.config.ts # registers all plugins (OSS defaults + overrides)
53
+ src/extensions/ # overlay plugins
54
+ my-kyc/plugin.ts # swaps KYC_ADAPTER
55
+ my-psp/plugin.ts # swaps PSP_ADAPTER
56
+ web/ # player app, consumes the API via @openora/core/react
57
+ backoffice/ # admin app
58
+ packages/ui/ # {{scope}}/ui shared components
59
+ ```
60
+
61
+ ## Swap a vendor adapter
62
+
63
+ 1. `list-adapters` for the token and interface.
64
+ 2. Create `apps/api/src/extensions/<vendor>/plugin.ts`:
65
+
66
+ ```ts
67
+ import type { CoreTokenCatalog, Plugin } from '@openora/core/server';
68
+ import { KYC_ADAPTER } from '@openora/core/contracts';
69
+ import { MyKycAdapter } from './src/my-kyc-adapter.js';
70
+
71
+ export default {
72
+ id: 'my-kyc',
73
+ dependsOn: ['identity'], // always load after the default-binding module
74
+ register(ctx) {
75
+ ctx.provide(KYC_ADAPTER, () => new MyKycAdapter());
76
+ },
77
+ } as const satisfies Plugin<CoreTokenCatalog>;
78
+ ```
79
+
80
+ 3. Register it in `extensions.config.ts` AFTER the module that owns the default binding - last registration of a DI token wins.
81
+
82
+ ## Add a feature route
83
+
84
+ 1. `list-routes` + `describe-module` to confirm it doesn't already exist.
85
+ 2. In a plugin: `ctx.routers.add('my-feature', myFeatureRouter)`.
86
+ 3. Zod schemas live in the plugin folder - never in `@openora/core/contracts`.
87
+
88
+ ## UI work
89
+
90
+ The platform is headless - the frontends are this operator's own, consuming the API over HTTP via `@openora/core/react` (data hooks, auth, realtime transport, typed client). Follow the `frontend-conventions` rule (and `docs/standards/frontend.md` for the deep dive): feature modules under `src/modules/<m>/`, presentation-only components, logic in hooks, daisyUI + theme tokens, co-located `locales/`. Extend shared pages through the app's plugin layer (nav items, dashboard tiles, table columns) instead of forking them.
91
+
92
+ ## Escalate
93
+
94
+ You start no subagents of your own. What you cannot settle yourself goes in your reply, and the orchestrator routes it:
95
+
96
+ - Domain question (wagering calc, KYC threshold) -> a "Questions for expert" list; build what the answer does not change.
97
+ - Bug or gap in OSS core -> fix it in an OSS worktree per "Changing OSS core" in the `oss-boundaries` rule; never copy core source into this repo.
98
+ - Can't finish at all -> reply `Blocked:` on the first line and the reason.
99
+
100
+ ## Tests
101
+
102
+ Your change ships with its tests in the same branch - never a separate test PR. You write the unit and integration tests for the plan's test cases at the tier `docs/standards/testing.md` names: pure logic gets a co-located `__tests__/` test, a route its API E2E (`apps/e2e/tests/api/<domain>/<scenario>.spec.ts`, happy + one hostile path). Never an in-process test that mocks the database or a sibling service. `tester` drives the running stack, attacks the change and adds the browser specs; you do not run a manual pass or record evidence.
103
+
104
+ ## Done means
105
+
106
+ 1. The repo's gate (`/check`: typecheck + lint + unit) passes on HEAD, and the specs you touched pass (`pnpm -F {{scope}}/e2e test <spec>`). A spec that fails: run it on the base commit too - failing there as well is pre-existing, say so; failing only here is yours to fix.
107
+ 2. Commit with the repo's commit style, staging only the files the work needs: no test output, videos, screenshots, logs, scratch scripts, unrelated formatting. `git status` is clean before you reply.
108
+ 3. Reply with what changed per plan item, the tests you added (one line each, tier and path), the gate command and the tail of its output from HEAD, then `Worktree: <absolute path>` and `Branch: <name>`. Never write "passed" without that output.
109
+
110
+ ## Rules
111
+
112
+ - Never write to `node_modules/**` or the main linked `{{ossDir}}` checkout - the `guard-core` hook denies it; don't route around it with `sed` or shell redirection. A local patch to a published dependency is lost on reinstall and diverges from every other operator. If a change is only possible in core, make it in an OSS worktree (`pnpm oss:worktree <branch>`) under the OSS repo's own rules, per the `oss-boundaries` rule.
113
+ - Never copy core module source into this repo - depend on the package.
114
+ - `extensions.config.ts` is the single registry - no auto-discovery.
115
+ - Commit on the ticket branch; never push - the orchestrator does.