@erclx/canon 5.68.0 → 5.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +5 -5
- package/claude/skills/app-drive/SKILL.md +17 -6
- package/claude/skills/app-drive/references/drive-record.md +61 -8
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +2 -2
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +4 -4
- package/claude/skills/design-identity/SKILL.md +7 -7
- package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
- package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +19 -10
- package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
- package/claude/skills/design-pick/references/design-config.md +33 -0
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/design-taste/SKILL.md +3 -3
- package/claude/skills/design-taste/references/preflight.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/references/issue.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +21 -6
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/pr-evidence/references/evidence.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-worker/SKILL.md +5 -6
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
- package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-craft/SKILL.md +1 -1
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
- package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
- package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
- package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
- package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
- package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
- package/docs/agents/audits.md +1 -1
- package/docs/agents/migrate.md +13 -13
- package/docs/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +3 -3
- package/docs/target/sync.md +23 -3
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -2
- package/docs/workflow/skill-map.md +9 -10
- package/docs/workflow/visual-design-workflow.md +6 -1
- package/governance/ledger/claude/background.md +26 -0
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/governance/rules/claude/background.md +19 -0
- package/governance/stacks/base.toml +1 -0
- package/package.json +6 -4
- package/scripts/ci/install-playwright-deps.sh +52 -0
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +0 -5
- package/src/claude/cases/workflow.ts +6 -6
- package/src/commands/fixtures/gh.sh +64 -0
- package/src/commands/gov.ts +33 -3
- package/src/commands/migrate.ts +12 -2
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/commands/tasks.ts +183 -0
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +8 -2
- package/src/gov/audit.ts +48 -11
- package/src/gov/detect.ts +1 -1
- package/src/init/steps.ts +2 -2
- package/src/markdown/scope.ts +9 -14
- package/src/migrate/record-layout.ts +5 -3
- package/src/migrate/skill-names.ts +21 -4
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +15 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/dispatch-shim.sh +15 -0
- package/src/sandbox/headless/dispatch.ts +12 -14
- package/src/sandbox/headless/run.ts +6 -3
- package/src/sandbox/stub-gh.sh +26 -0
- package/src/sandbox/stub-remote.ts +8 -29
- package/src/sync/engine.ts +1 -1
- package/src/take/camera.ts +100 -11
- package/src/take/film.ts +55 -4
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/pointer.ts +2 -1
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +141 -23
- package/src/take/zoom/NOTICE.md +1 -1
- package/src/tasks/launch-wait.ts +160 -0
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
- package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
- package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
- package/claude/skills/plan-ready/SKILL.md +0 -136
- package/claude/skills/plan-ready/references/assembly.md +0 -75
- package/claude/skills/plan-ready/references/ready.md +0 -128
- package/docs/target/migrations.md +0 -203
- /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Input</title>
|
|
6
|
+
<!--
|
|
7
|
+
One target per input a take performs, each posting what the page saw to
|
|
8
|
+
/report with a sequence number, so a test reads the inputs in the order
|
|
9
|
+
the page received them after the take has closed the browser.
|
|
10
|
+
|
|
11
|
+
- #handle follows a pointer-event drag and reports where it dropped and
|
|
12
|
+
how many of its moves carried the held button
|
|
13
|
+
- #frame reports each dblclick
|
|
14
|
+
- #layer reports pointerdown, pointerup, and click with the Shift flag
|
|
15
|
+
- #field takes focus for key steps, and every keyup reports its value
|
|
16
|
+
-->
|
|
17
|
+
<style>
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
background: #f4f4f4;
|
|
21
|
+
font: 24px sans-serif;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#handle {
|
|
25
|
+
position: absolute;
|
|
26
|
+
left: 200px;
|
|
27
|
+
top: 200px;
|
|
28
|
+
width: 80px;
|
|
29
|
+
height: 80px;
|
|
30
|
+
/* Neutral, so the reel's blue ring reads over it at any alpha. */
|
|
31
|
+
background: #444;
|
|
32
|
+
touch-action: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#lane {
|
|
36
|
+
position: absolute;
|
|
37
|
+
left: 700px;
|
|
38
|
+
top: 160px;
|
|
39
|
+
width: 300px;
|
|
40
|
+
height: 160px;
|
|
41
|
+
border: 2px dashed #555;
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#frame,
|
|
46
|
+
#layer {
|
|
47
|
+
position: absolute;
|
|
48
|
+
top: 500px;
|
|
49
|
+
width: 200px;
|
|
50
|
+
height: 100px;
|
|
51
|
+
background: #ddd;
|
|
52
|
+
user-select: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#frame {
|
|
56
|
+
left: 200px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#layer {
|
|
60
|
+
left: 700px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#field {
|
|
64
|
+
position: absolute;
|
|
65
|
+
left: 200px;
|
|
66
|
+
top: 750px;
|
|
67
|
+
width: 400px;
|
|
68
|
+
height: 48px;
|
|
69
|
+
font: inherit;
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<div id="lane"></div>
|
|
75
|
+
<div id="handle"></div>
|
|
76
|
+
<div id="frame">Frame</div>
|
|
77
|
+
<div id="layer">Layer</div>
|
|
78
|
+
<input id="field" />
|
|
79
|
+
<script>
|
|
80
|
+
let seq = 0
|
|
81
|
+
const report = (entry) =>
|
|
82
|
+
fetch('/report', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
body: JSON.stringify({ seq: (seq += 1), ...entry }),
|
|
85
|
+
keepalive: true,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const handle = document.getElementById('handle')
|
|
89
|
+
const lane = document.getElementById('lane')
|
|
90
|
+
let drag = null
|
|
91
|
+
handle.addEventListener('pointerdown', (event) => {
|
|
92
|
+
handle.setPointerCapture(event.pointerId)
|
|
93
|
+
drag = { held: 0, hover: 0 }
|
|
94
|
+
})
|
|
95
|
+
handle.addEventListener('pointermove', (event) => {
|
|
96
|
+
if (!drag) return
|
|
97
|
+
if (event.buttons === 1) drag.held += 1
|
|
98
|
+
else drag.hover += 1
|
|
99
|
+
handle.style.left = `${event.clientX - 40}px`
|
|
100
|
+
handle.style.top = `${event.clientY - 40}px`
|
|
101
|
+
})
|
|
102
|
+
handle.addEventListener('pointerup', (event) => {
|
|
103
|
+
if (!drag) return
|
|
104
|
+
const box = lane.getBoundingClientRect()
|
|
105
|
+
const onTarget =
|
|
106
|
+
event.clientX >= box.left &&
|
|
107
|
+
event.clientX <= box.right &&
|
|
108
|
+
event.clientY >= box.top &&
|
|
109
|
+
event.clientY <= box.bottom
|
|
110
|
+
report({ type: 'drop', ...drag, onTarget, altKey: event.altKey })
|
|
111
|
+
drag = null
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
document
|
|
115
|
+
.getElementById('frame')
|
|
116
|
+
.addEventListener('dblclick', () => report({ type: 'dblclick' }))
|
|
117
|
+
|
|
118
|
+
const layer = document.getElementById('layer')
|
|
119
|
+
for (const type of ['pointerdown', 'pointerup', 'click']) {
|
|
120
|
+
layer.addEventListener(type, (event) =>
|
|
121
|
+
report({ type, shiftKey: event.shiftKey }),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const field = document.getElementById('field')
|
|
126
|
+
document.addEventListener('keydown', (event) =>
|
|
127
|
+
report({
|
|
128
|
+
type: 'keydown',
|
|
129
|
+
key: event.key,
|
|
130
|
+
ctrlKey: event.ctrlKey,
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
document.addEventListener('keyup', (event) =>
|
|
134
|
+
report({ type: 'keyup', key: event.key, value: field.value }),
|
|
135
|
+
)
|
|
136
|
+
</script>
|
|
137
|
+
</body>
|
|
138
|
+
</html>
|
package/src/take/keys.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys a take plan may press, named as Playwright names them so the
|
|
3
|
+
* screencast passes a combination straight to `page.keyboard`, and each one's
|
|
4
|
+
* CDP descriptor so the stepper can send the same press as raw key events.
|
|
5
|
+
* A key outside this vocabulary refuses at plan read, since the stepper has no
|
|
6
|
+
* descriptor to send for it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MODIFIERS = ['Alt', 'Control', 'Meta', 'Shift'] as const
|
|
10
|
+
|
|
11
|
+
export type Modifier = (typeof MODIFIERS)[number]
|
|
12
|
+
|
|
13
|
+
/** A combination: the modifiers held, in order, around one key. */
|
|
14
|
+
export interface Chord {
|
|
15
|
+
readonly modifiers: readonly Modifier[]
|
|
16
|
+
readonly key: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The fields `Input.dispatchKeyEvent` needs to press one key. */
|
|
20
|
+
export interface KeyDescriptor {
|
|
21
|
+
readonly key: string
|
|
22
|
+
readonly code: string
|
|
23
|
+
readonly keyCode: number
|
|
24
|
+
/** What the key types, absent for a key that types nothing. */
|
|
25
|
+
readonly text?: string
|
|
26
|
+
/** 1 for a modifier, which sits on the left of the keyboard. */
|
|
27
|
+
readonly location: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** CDP's modifier bit field: "Alt=1, Ctrl=2, Meta/Command=4, Shift=8". */
|
|
31
|
+
const MODIFIER_BITS: Record<Modifier, number> = {
|
|
32
|
+
Alt: 1,
|
|
33
|
+
Control: 2,
|
|
34
|
+
Meta: 4,
|
|
35
|
+
Shift: 8,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MODIFIER_KEY_CODES: Record<Modifier, number> = {
|
|
39
|
+
Alt: 18,
|
|
40
|
+
Control: 17,
|
|
41
|
+
Meta: 91,
|
|
42
|
+
Shift: 16,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NAMED: Record<string, { code?: string; keyCode: number; text?: string }> =
|
|
46
|
+
{
|
|
47
|
+
Enter: { keyCode: 13, text: '\r' },
|
|
48
|
+
Tab: { keyCode: 9 },
|
|
49
|
+
Escape: { keyCode: 27 },
|
|
50
|
+
Backspace: { keyCode: 8 },
|
|
51
|
+
Delete: { keyCode: 46 },
|
|
52
|
+
Insert: { keyCode: 45 },
|
|
53
|
+
Space: { keyCode: 32, text: ' ' },
|
|
54
|
+
Home: { keyCode: 36 },
|
|
55
|
+
End: { keyCode: 35 },
|
|
56
|
+
PageUp: { keyCode: 33 },
|
|
57
|
+
PageDown: { keyCode: 34 },
|
|
58
|
+
ArrowLeft: { keyCode: 37 },
|
|
59
|
+
ArrowUp: { keyCode: 38 },
|
|
60
|
+
ArrowRight: { keyCode: 39 },
|
|
61
|
+
ArrowDown: { keyCode: 40 },
|
|
62
|
+
...Object.fromEntries(
|
|
63
|
+
Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, { keyCode: 112 + i }]),
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The US layout's punctuation, each with its shifted twin on the same key. */
|
|
68
|
+
const PUNCTUATION: readonly (readonly [string, string, string, number])[] = [
|
|
69
|
+
['`', '~', 'Backquote', 192],
|
|
70
|
+
['-', '_', 'Minus', 189],
|
|
71
|
+
['=', '+', 'Equal', 187],
|
|
72
|
+
['[', '{', 'BracketLeft', 219],
|
|
73
|
+
[']', '}', 'BracketRight', 221],
|
|
74
|
+
['\\', '|', 'Backslash', 220],
|
|
75
|
+
[';', ':', 'Semicolon', 186],
|
|
76
|
+
["'", '"', 'Quote', 222],
|
|
77
|
+
[',', '<', 'Comma', 188],
|
|
78
|
+
['.', '>', 'Period', 190],
|
|
79
|
+
['/', '?', 'Slash', 191],
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const SHIFTED_DIGITS = ')!@#$%^&*('
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* With `shift` set, a printable key types its shifted twin, as Playwright's
|
|
86
|
+
* keyboard swaps to it while Shift is held, so `Shift+a` types `A` on both
|
|
87
|
+
* routes.
|
|
88
|
+
*/
|
|
89
|
+
export function describeKey(
|
|
90
|
+
key: string,
|
|
91
|
+
options: { readonly shift?: boolean } = {},
|
|
92
|
+
): KeyDescriptor | undefined {
|
|
93
|
+
return describeTyped(options.shift ? shiftedTwin(key) : key)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function shiftedTwin(key: string): string {
|
|
97
|
+
if (/^[a-z]$/.test(key)) return key.toUpperCase()
|
|
98
|
+
if (/^[0-9]$/.test(key)) return SHIFTED_DIGITS[Number(key)] ?? key
|
|
99
|
+
const row = PUNCTUATION.find(([plain]) => key === plain)
|
|
100
|
+
return row ? row[1] : key
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describeTyped(key: string): KeyDescriptor | undefined {
|
|
104
|
+
if (isModifier(key)) {
|
|
105
|
+
return {
|
|
106
|
+
key,
|
|
107
|
+
code: `${key}Left`,
|
|
108
|
+
keyCode: MODIFIER_KEY_CODES[key],
|
|
109
|
+
location: 1,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const named = NAMED[key]
|
|
113
|
+
if (named) {
|
|
114
|
+
return {
|
|
115
|
+
key: key === 'Space' ? ' ' : key,
|
|
116
|
+
code: named.code ?? key,
|
|
117
|
+
keyCode: named.keyCode,
|
|
118
|
+
...(named.text ? { text: named.text } : {}),
|
|
119
|
+
location: 0,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (key.length !== 1) return undefined
|
|
123
|
+
|
|
124
|
+
if (/^[a-z]$/i.test(key)) {
|
|
125
|
+
const upper = key.toUpperCase()
|
|
126
|
+
return printable(key, `Key${upper}`, upper.charCodeAt(0))
|
|
127
|
+
}
|
|
128
|
+
if (/^[0-9]$/.test(key)) {
|
|
129
|
+
return printable(key, `Digit${key}`, key.charCodeAt(0))
|
|
130
|
+
}
|
|
131
|
+
const shiftedDigit = SHIFTED_DIGITS.indexOf(key)
|
|
132
|
+
if (shiftedDigit >= 0) {
|
|
133
|
+
return printable(key, `Digit${shiftedDigit}`, 48 + shiftedDigit)
|
|
134
|
+
}
|
|
135
|
+
if (key === ' ') return printable(key, 'Space', 32)
|
|
136
|
+
const row = PUNCTUATION.find(
|
|
137
|
+
([plain, shifted]) => key === plain || key === shifted,
|
|
138
|
+
)
|
|
139
|
+
return row ? printable(key, row[2], row[3]) : undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type KeysRead =
|
|
143
|
+
| { readonly kind: 'parsed'; readonly chord: Chord }
|
|
144
|
+
| { readonly kind: 'refused'; readonly detail: string }
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reads a combination such as `Control+K`, the modifiers first and the key
|
|
148
|
+
* last. A trailing `+` after a separator is the plus key itself.
|
|
149
|
+
*/
|
|
150
|
+
export function parseKeys(keys: string): KeysRead {
|
|
151
|
+
const parts =
|
|
152
|
+
keys === '+'
|
|
153
|
+
? ['+']
|
|
154
|
+
: keys.endsWith('++')
|
|
155
|
+
? [...keys.slice(0, -2).split('+'), '+']
|
|
156
|
+
: keys.split('+')
|
|
157
|
+
const key = parts.at(-1) ?? ''
|
|
158
|
+
const held = parts.slice(0, -1)
|
|
159
|
+
|
|
160
|
+
if (!key) return { kind: 'refused', detail: `${keys || 'it'} names no key` }
|
|
161
|
+
const modifiers: Modifier[] = []
|
|
162
|
+
for (const name of held) {
|
|
163
|
+
if (!isModifier(name)) {
|
|
164
|
+
return {
|
|
165
|
+
kind: 'refused',
|
|
166
|
+
detail: `${keys} holds ${name || 'nothing'}, which is not ${MODIFIERS.join(', ')}`,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (modifiers.includes(name)) {
|
|
170
|
+
return { kind: 'refused', detail: `${keys} holds ${name} twice` }
|
|
171
|
+
}
|
|
172
|
+
modifiers.push(name)
|
|
173
|
+
}
|
|
174
|
+
if (!describeKey(key)) {
|
|
175
|
+
return {
|
|
176
|
+
kind: 'refused',
|
|
177
|
+
detail: `${keys} presses ${key}, which is not a modifier, one character, or a named key`,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { kind: 'parsed', chord: { modifiers, key } }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function modifierBits(modifiers: readonly Modifier[]): number {
|
|
184
|
+
return modifiers.reduce((bits, name) => bits | MODIFIER_BITS[name], 0)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isModifier(name: unknown): name is Modifier {
|
|
188
|
+
return MODIFIERS.includes(name as Modifier)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function printable(key: string, code: string, keyCode: number): KeyDescriptor {
|
|
192
|
+
return { key, code, keyCode, text: key, location: 0 }
|
|
193
|
+
}
|
package/src/take/log.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Modifier } from '@/take/keys'
|
|
2
|
+
import { ROUTES, type Route } from '@/take/plan'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The click log a take writes beside its clip, and the pointer path both
|
|
@@ -43,6 +44,33 @@ export type LogEvent =
|
|
|
43
44
|
readonly kind: 'click'
|
|
44
45
|
readonly at: Point
|
|
45
46
|
readonly box: Box
|
|
47
|
+
/** The keys held through the press, absent when none is. */
|
|
48
|
+
readonly modifiers?: readonly Modifier[]
|
|
49
|
+
/** 2 for a double click, absent for a single one. */
|
|
50
|
+
readonly count?: number
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
readonly t: number
|
|
54
|
+
readonly kind: 'drag'
|
|
55
|
+
/** Where the button went down and where it came up. */
|
|
56
|
+
readonly from: Point
|
|
57
|
+
readonly to: Point
|
|
58
|
+
/** The box pressed and the box it was dropped on. */
|
|
59
|
+
readonly box: Box
|
|
60
|
+
readonly drop: Box
|
|
61
|
+
readonly modifiers?: readonly Modifier[]
|
|
62
|
+
/**
|
|
63
|
+
* From press to release. The travel between is logged as `move` events,
|
|
64
|
+
* which the cursor is read off.
|
|
65
|
+
*/
|
|
66
|
+
readonly ms: number
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
readonly t: number
|
|
70
|
+
readonly kind: 'key'
|
|
71
|
+
/** The combination as the plan wrote it, such as `Control+K`. */
|
|
72
|
+
readonly keys: string
|
|
73
|
+
readonly ms: number
|
|
46
74
|
}
|
|
47
75
|
| {
|
|
48
76
|
readonly t: number
|
|
@@ -68,10 +96,75 @@ export interface TakeLog {
|
|
|
68
96
|
readonly fps: number
|
|
69
97
|
/** The route that filmed the clip, which can differ from the one asked for. */
|
|
70
98
|
readonly route: Route
|
|
99
|
+
/**
|
|
100
|
+
* The clip's size in pixels, which sets how deep the camera may zoom before
|
|
101
|
+
* the clip goes soft. A log written before this field read it off `route`.
|
|
102
|
+
*/
|
|
103
|
+
readonly clip?: { readonly width: number; readonly height: number }
|
|
104
|
+
/** False when the beat turned the camera's zoom off. */
|
|
105
|
+
readonly push?: false
|
|
71
106
|
readonly frames: number
|
|
72
107
|
readonly events: readonly LogEvent[]
|
|
73
108
|
}
|
|
74
109
|
|
|
110
|
+
/** The size each route films at, the stepper at twice the viewport. */
|
|
111
|
+
export const CLIP_SIZES: Record<
|
|
112
|
+
Route,
|
|
113
|
+
{ readonly width: number; readonly height: number }
|
|
114
|
+
> = {
|
|
115
|
+
step: { width: VIEWPORT.width * 2, height: VIEWPORT.height * 2 },
|
|
116
|
+
screencast: VIEWPORT,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type TakeLogRead =
|
|
120
|
+
| { readonly kind: 'read'; readonly log: TakeLog }
|
|
121
|
+
| { readonly kind: 'refused'; readonly detail: string }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reads a log back off disk, checking the fields the camera builds on rather
|
|
125
|
+
* than every event, since a take wrote the events and a reader only ever
|
|
126
|
+
* points at the wrong file.
|
|
127
|
+
*/
|
|
128
|
+
export function readTakeLog(source: string): TakeLogRead {
|
|
129
|
+
let document: unknown
|
|
130
|
+
try {
|
|
131
|
+
document = JSON.parse(source)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
kind: 'refused',
|
|
135
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (typeof document !== 'object' || document === null) {
|
|
139
|
+
return { kind: 'refused', detail: 'the document is not an object' }
|
|
140
|
+
}
|
|
141
|
+
const log = document as Partial<Record<keyof TakeLog, unknown>>
|
|
142
|
+
const missing = [
|
|
143
|
+
isSize(log.viewport) ? '' : 'viewport',
|
|
144
|
+
isCount(log.fps) && log.fps > 0 ? '' : 'fps',
|
|
145
|
+
ROUTES.includes(log.route as Route) ? '' : 'route',
|
|
146
|
+
isCount(log.frames) ? '' : 'frames',
|
|
147
|
+
Array.isArray(log.events) ? '' : 'events',
|
|
148
|
+
].filter(Boolean)
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
return {
|
|
151
|
+
kind: 'refused',
|
|
152
|
+
detail: `the document carries no valid ${missing.join(', ')}, so it is not a take's log.json`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { kind: 'read', log: document as TakeLog }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isCount(value: unknown): value is number {
|
|
159
|
+
return Number.isInteger(value) && (value as number) >= 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isSize(value: unknown): boolean {
|
|
163
|
+
if (typeof value !== 'object' || value === null) return false
|
|
164
|
+
const { width, height } = value as Record<string, unknown>
|
|
165
|
+
return isCount(width) && isCount(height)
|
|
166
|
+
}
|
|
167
|
+
|
|
75
168
|
/** Where the pointer rests before a take's first move. */
|
|
76
169
|
export const START: Point = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 }
|
|
77
170
|
|
package/src/take/perform.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
along,
|
|
7
7
|
travelMs,
|
|
8
8
|
} from '@/take/log'
|
|
9
|
+
import { type Chord, type Modifier, isModifier, parseKeys } from '@/take/keys'
|
|
9
10
|
import type { TakeStep } from '@/take/plan'
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -23,11 +24,25 @@ const KEY_MS = 90
|
|
|
23
24
|
/** How long a scroll's wheel travel runs. */
|
|
24
25
|
const SCROLL_MS = 900
|
|
25
26
|
|
|
27
|
+
/** How long a drag holds still after its press, so the viewer sees the grab land. */
|
|
28
|
+
const GRAB_MS = 150
|
|
29
|
+
|
|
26
30
|
export type PointerInput =
|
|
27
31
|
| {
|
|
28
|
-
readonly type: 'move'
|
|
32
|
+
readonly type: 'move'
|
|
33
|
+
readonly x: number
|
|
34
|
+
readonly y: number
|
|
35
|
+
/** True while a drag holds the left button down. */
|
|
36
|
+
readonly held?: true
|
|
37
|
+
readonly modifiers?: readonly Modifier[]
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly type: 'down' | 'up'
|
|
29
41
|
readonly x: number
|
|
30
42
|
readonly y: number
|
|
43
|
+
/** Which press of a run this is, 2 being a double click's second. */
|
|
44
|
+
readonly clickCount: number
|
|
45
|
+
readonly modifiers?: readonly Modifier[]
|
|
31
46
|
}
|
|
32
47
|
| {
|
|
33
48
|
readonly type: 'wheel'
|
|
@@ -36,6 +51,14 @@ export type PointerInput =
|
|
|
36
51
|
readonly dy: number
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
/** One key going down or coming up. */
|
|
55
|
+
export interface KeyInput {
|
|
56
|
+
readonly type: 'down' | 'up'
|
|
57
|
+
readonly key: string
|
|
58
|
+
/** The modifiers held once this input lands, so a modifier's own down counts it. */
|
|
59
|
+
readonly modifiers: readonly Modifier[]
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
export interface Stage {
|
|
40
63
|
/** Milliseconds on the clip's own clock. */
|
|
41
64
|
now(): number
|
|
@@ -45,12 +68,17 @@ export interface Stage {
|
|
|
45
68
|
pointer(input: PointerInput): Promise<void>
|
|
46
69
|
/** Types one character into the focused field and lets one frame pass. */
|
|
47
70
|
key(text: string): Promise<void>
|
|
71
|
+
/** Sends one key down or up and lets one frame pass. */
|
|
72
|
+
press(input: KeyInput): Promise<void>
|
|
48
73
|
/** Evaluates an expression in the page. */
|
|
49
74
|
evaluate<T>(expression: string): Promise<T>
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
/** A target that resolved to nothing, which refuses rather than films a guess. */
|
|
53
78
|
export class TargetMissing extends Error {
|
|
79
|
+
/** The page as it stood when the lookup failed, as a JPEG, when the route caught one. */
|
|
80
|
+
still?: Buffer
|
|
81
|
+
|
|
54
82
|
constructor(readonly target: string) {
|
|
55
83
|
super(`nothing on the page matches ${target}`)
|
|
56
84
|
}
|
|
@@ -77,11 +105,40 @@ export async function performSteps(
|
|
|
77
105
|
)
|
|
78
106
|
break
|
|
79
107
|
case 'click':
|
|
80
|
-
|
|
108
|
+
case 'dblclick':
|
|
109
|
+
await click(
|
|
110
|
+
stage,
|
|
111
|
+
state,
|
|
112
|
+
await boxOf(stage, step.target),
|
|
113
|
+
step.modifiers ?? [],
|
|
114
|
+
step.kind === 'dblclick' ? 2 : 1,
|
|
115
|
+
)
|
|
81
116
|
break
|
|
117
|
+
case 'drag':
|
|
118
|
+
await drag(
|
|
119
|
+
stage,
|
|
120
|
+
state,
|
|
121
|
+
await boxOf(stage, step.from),
|
|
122
|
+
await boxOf(stage, step.to),
|
|
123
|
+
step.modifiers ?? [],
|
|
124
|
+
)
|
|
125
|
+
break
|
|
126
|
+
case 'key': {
|
|
127
|
+
const read = parseKeys(step.keys)
|
|
128
|
+
if (read.kind === 'refused') throw new Error(read.detail)
|
|
129
|
+
const start = stage.now()
|
|
130
|
+
await pressChord(stage, read.chord)
|
|
131
|
+
state.log?.push({
|
|
132
|
+
t: start,
|
|
133
|
+
kind: 'key',
|
|
134
|
+
keys: step.keys,
|
|
135
|
+
ms: stage.now() - start,
|
|
136
|
+
})
|
|
137
|
+
break
|
|
138
|
+
}
|
|
82
139
|
case 'fill': {
|
|
83
140
|
const box = await boxOf(stage, step.target)
|
|
84
|
-
await click(stage, state, box)
|
|
141
|
+
await click(stage, state, box, [], 1)
|
|
85
142
|
const start = stage.now()
|
|
86
143
|
for (const character of step.text) {
|
|
87
144
|
await stage.key(character)
|
|
@@ -132,6 +189,7 @@ async function moveTo(
|
|
|
132
189
|
stage: Stage,
|
|
133
190
|
state: Performance,
|
|
134
191
|
to: Point,
|
|
192
|
+
held?: { readonly modifiers: readonly Modifier[] },
|
|
135
193
|
): Promise<void> {
|
|
136
194
|
const from = state.pointer
|
|
137
195
|
if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
|
|
@@ -143,9 +201,12 @@ async function moveTo(
|
|
|
143
201
|
to,
|
|
144
202
|
ms: frames * FRAME_MS,
|
|
145
203
|
})
|
|
204
|
+
const pressed = held
|
|
205
|
+
? { held: true as const, ...withModifiers(held.modifiers) }
|
|
206
|
+
: {}
|
|
146
207
|
for (let frame = 1; frame <= frames; frame += 1) {
|
|
147
208
|
state.pointer = along(from, to, frame / frames)
|
|
148
|
-
await stage.pointer({ type: 'move', ...state.pointer })
|
|
209
|
+
await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
|
|
149
210
|
}
|
|
150
211
|
state.pointer = to
|
|
151
212
|
}
|
|
@@ -154,12 +215,100 @@ async function click(
|
|
|
154
215
|
stage: Stage,
|
|
155
216
|
state: Performance,
|
|
156
217
|
box: Box,
|
|
218
|
+
modifiers: readonly Modifier[],
|
|
219
|
+
count: number,
|
|
157
220
|
): Promise<void> {
|
|
158
221
|
const at = center(box)
|
|
159
222
|
await moveTo(stage, state, at)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
223
|
+
await holding(stage, modifiers, async () => {
|
|
224
|
+
state.log?.push({
|
|
225
|
+
t: stage.now(),
|
|
226
|
+
kind: 'click',
|
|
227
|
+
at,
|
|
228
|
+
box,
|
|
229
|
+
...withModifiers(modifiers),
|
|
230
|
+
...(count > 1 ? { count } : {}),
|
|
231
|
+
})
|
|
232
|
+
for (let clickCount = 1; clickCount <= count; clickCount += 1) {
|
|
233
|
+
const press = { ...at, clickCount, ...withModifiers(modifiers) }
|
|
234
|
+
await stage.pointer({ type: 'down', ...press })
|
|
235
|
+
await stage.pointer({ type: 'up', ...press })
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Presses on the source, holds a moment so the page reads a grab rather than
|
|
242
|
+
* a click, glides held to the target, and releases there. The drop target is
|
|
243
|
+
* read before the press, so a page that moves it once a drag starts is
|
|
244
|
+
* dropped on where it stood.
|
|
245
|
+
*/
|
|
246
|
+
async function drag(
|
|
247
|
+
stage: Stage,
|
|
248
|
+
state: Performance,
|
|
249
|
+
box: Box,
|
|
250
|
+
drop: Box,
|
|
251
|
+
modifiers: readonly Modifier[],
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const from = center(box)
|
|
254
|
+
const to = center(drop)
|
|
255
|
+
await moveTo(stage, state, from)
|
|
256
|
+
await holding(stage, modifiers, async () => {
|
|
257
|
+
const start = stage.now()
|
|
258
|
+
const press = { clickCount: 1, ...withModifiers(modifiers) }
|
|
259
|
+
await stage.pointer({ type: 'down', ...from, ...press })
|
|
260
|
+
await stage.hold(GRAB_MS)
|
|
261
|
+
await moveTo(stage, state, to, { modifiers })
|
|
262
|
+
await stage.pointer({ type: 'up', ...to, ...press })
|
|
263
|
+
state.log?.push({
|
|
264
|
+
t: start,
|
|
265
|
+
kind: 'drag',
|
|
266
|
+
from,
|
|
267
|
+
to,
|
|
268
|
+
box,
|
|
269
|
+
drop,
|
|
270
|
+
...withModifiers(modifiers),
|
|
271
|
+
ms: stage.now() - start,
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Presses each modifier in order, runs the inputs they hold, and releases them
|
|
278
|
+
* in reverse, so a page reads every input between as modified on both routes.
|
|
279
|
+
*/
|
|
280
|
+
async function holding(
|
|
281
|
+
stage: Stage,
|
|
282
|
+
modifiers: readonly Modifier[],
|
|
283
|
+
inputs: () => Promise<void>,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
for (const [i, key] of modifiers.entries()) {
|
|
286
|
+
await stage.press({
|
|
287
|
+
type: 'down',
|
|
288
|
+
key,
|
|
289
|
+
modifiers: modifiers.slice(0, i + 1),
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
await inputs()
|
|
293
|
+
for (const [i, key] of [...modifiers.entries()].reverse()) {
|
|
294
|
+
await stage.press({ type: 'up', key, modifiers: modifiers.slice(0, i) })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Presses a chord's key inside its modifiers, counting a modifier key as held while down. */
|
|
299
|
+
async function pressChord(stage: Stage, chord: Chord): Promise<void> {
|
|
300
|
+
const { key, modifiers } = chord
|
|
301
|
+
await holding(stage, modifiers, async () => {
|
|
302
|
+
const down = isModifier(key) ? [...modifiers, key] : modifiers
|
|
303
|
+
await stage.press({ type: 'down', key, modifiers: down })
|
|
304
|
+
await stage.press({ type: 'up', key, modifiers })
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function withModifiers(modifiers: readonly Modifier[]): {
|
|
309
|
+
modifiers?: readonly Modifier[]
|
|
310
|
+
} {
|
|
311
|
+
return modifiers.length > 0 ? { modifiers } : {}
|
|
163
312
|
}
|
|
164
313
|
|
|
165
314
|
function center(box: Box): Point {
|
|
@@ -173,8 +322,10 @@ async function boxOf(stage: Stage, target: string): Promise<Box> {
|
|
|
173
322
|
}
|
|
174
323
|
|
|
175
324
|
/**
|
|
176
|
-
* Finds a target's box in CSS pixels, reading `text=<words>` as the
|
|
177
|
-
* element whose whole text is those words
|
|
325
|
+
* Finds a target's box in CSS pixels, reading `text=<words>` as the first
|
|
326
|
+
* innermost element with a size whose whole text is those words, so a hidden
|
|
327
|
+
* copy earlier in the page cannot shadow a visible one, and anything else as a
|
|
328
|
+
* CSS selector.
|
|
178
329
|
* It runs inside the page so both routes resolve a target the same way, and a
|
|
179
330
|
* match with no size counts as missing, since a click on it would land on
|
|
180
331
|
* whatever sits behind.
|
|
@@ -188,7 +339,11 @@ export function boxExpression(target: string): string {
|
|
|
188
339
|
const matches = [...document.querySelectorAll('body *')].filter(
|
|
189
340
|
(node) => node.textContent.trim() === want,
|
|
190
341
|
)
|
|
191
|
-
|
|
342
|
+
const innermost = matches.filter((node) => !matches.some((other) => other !== node && node.contains(other)))
|
|
343
|
+
element = innermost.find((node) => {
|
|
344
|
+
const rect = node.getBoundingClientRect()
|
|
345
|
+
return rect.width > 0 && rect.height > 0
|
|
346
|
+
}) ?? null
|
|
192
347
|
} else {
|
|
193
348
|
element = document.querySelector(target)
|
|
194
349
|
}
|