@dailephd/my-dev-kit-lab 0.4.7 → 0.4.8

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to my-dev-kit-lab are documented here.
4
4
 
5
+ ## [0.4.8] - 2026-09-19
6
+
7
+ Locator-anchored pointer gestures for declarative browser tutorials.
8
+
9
+ - Added `pointer-click` and `pointer-drag` declarative tutorial actions anchored to a single `TutorialLocatorV1` interaction surface.
10
+ - Added normalized fraction coordinates (`coordinateSpace: "fraction"`) with inclusive `[0, 1]` bounds and zero-length pointer-drag rejection.
11
+ - Used real Playwright mouse input (`mouse.move`, `mouse.down`, `mouse.up`) with a fixed 8-step drag movement (`POINTER_DRAG_MOVE_STEPS = 8`) and robust `mouse.up` error cleanup.
12
+ - Preserved existing element-to-element `drag` (`source` and `target` locators via Playwright `dragTo`) and all existing scenario action/assertion behaviors without change.
13
+ - Preserved closed security boundaries: no arbitrary JavaScript, no `page.evaluate` callbacks, no generic DOM event dispatch, no shell actions, and no unanchored page/screen coordinates.
14
+ - Extended the generic real-browser fixture (`examples/tutorial-browser/`) with an interactive SVG pointer surface, verifying pointer clicks, pointer drags, and intermediate pointermove tracking through genuine Chromium.
15
+ - Extended packed-package verification (`npm run verify:packed-package`) to validate and execute the new pointer actions against a clean installed-tarball consumer while maintaining package, target, and example immutability.
16
+ - Validated downstream `my-frontend-observer` compatibility evidence as a read-only compatibility consumer (rectangle, line, arrow, point, and note gestures confirmed expressible without Observer code modifications or runtime dependencies).
17
+ - Kept `TutorialScenarioV1`, `TutorialTargetContractV1`, `TutorialRunResultV1`, and `TutorialManifestV1` schemas at version `1.0.0`.
18
+
5
19
  ## [0.4.7] - 2026-09-18
6
20
 
7
21
  The v0.4.7 implementation adds generic declarative browser tutorial automation while preserving product-specific demo ownership outside this repository.
package/README.md CHANGED
@@ -4,7 +4,7 @@ my-dev-kit-lab is the experiment, audit, and evidence companion for [my-dev-kit]
4
4
 
5
5
  my-dev-kit provides local repository indexing and graph-guided retrieval. my-dev-kit-lab supplies the controlled benchmarks, agent adapters, metrics, security checks, and reports needed to evaluate when that retrieval is useful. Results are evidence for a specific target and configuration; they do not guarantee token savings or security.
6
6
 
7
- The latest published release is v0.4.7 (declarative browser tutorial and video automation). It adds bounded browser scenarios, persistent tutorial sessions, synchronized runtime artifacts, and a packaged generic example while preserving product-specific demo ownership outside this repository. The v0.4.5 context-integrity evaluation remains intentionally frozen against the published `@dailephd/my-dev-kit@1.10.4` and `@dailephd/my-dev-kit-orchestrator@1.2.3` contracts; those versions are historical validation baselines, not a statement that they are the ecosystem's current releases. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md).
7
+ The latest release is v0.4.8 (locator-anchored pointer gestures for declarative browser tutorials, adding `pointer-click` and `pointer-drag` using normalized locator-relative fraction positions while existing `drag` remains element-to-element). The v0.4.5 context-integrity evaluation remains intentionally frozen against the published `@dailephd/my-dev-kit@1.10.4` and `@dailephd/my-dev-kit-orchestrator@1.2.3` contracts; those versions are historical validation baselines, not a statement that they are the ecosystem's current releases. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md).
8
8
 
9
9
  v0.4.6 adds a supported `my-dev-kit-lab` installed CLI router (`--help`, `--version`, `security validate`, `audit`, the `experiment` family, `report render`, `plots generate`, `gallery build`, `demo final`, and the historical direct final-demo invocation form), a writable lab workspace model kept separate from the installed package and the inspected target, and a permanent packed-tarball installation/execution acceptance gate (`npm run verify:packed-package`). See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for status detail and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the runtime path model. The "Installed CLI" section below documents the shipped command surface; the source-checkout `npm run` workflow in Quickstart remains available for contributors.
10
10
 
@@ -15,7 +15,7 @@ v0.4.6 adds a supported `my-dev-kit-lab` installed CLI router (`--help`, `--vers
15
15
  - **Validate CLI/package security:** inspect dependencies, package contents, path and subprocess boundaries, malformed inputs, optional static scanners, and bounded fuzz targets.
16
16
  - **Validate Android projects:** run nineteen static checks by default, with Gradle operations, external tools, and network access available only through explicit opt-in flags.
17
17
  - **Review evidence:** generate JSON and HTML reports, SVG plots, optional screenshots, visualization demos, and a static gallery.
18
- - **Generate declarative browser tutorials:** validate and execute bounded local browser scenarios with WebM recording, step screenshots, SRT/VTT subtitles, Markdown, and a tutorial manifest. Tutorial execution requires a compatible locally installed Chromium; see [docs/COMMANDS.md](docs/COMMANDS.md) and [docs/TUTORIAL.md](docs/TUTORIAL.md) for details.
18
+ - **Generate declarative browser tutorials:** validate and execute bounded local browser scenarios with WebM recording, step screenshots, SRT/VTT subtitles, Markdown, and a tutorial manifest. Locator-anchored pointer gestures (`pointer-click` and `pointer-drag`) operate using normalized fraction coordinates inside a single located surface, while existing `drag` remains element-to-element. Tutorial execution requires a compatible locally installed Chromium; see [docs/COMMANDS.md](docs/COMMANDS.md) and [docs/TUTORIAL.md](docs/TUTORIAL.md) for details.
19
19
  - **Evaluate stage-context strategies:** compare the two legacy strategies against six additional bounded stage-context strategies — `architecture-context-only`, `architecture-plus-implementation-refresh`, `architecture-plus-implementation-and-test-refresh`, `full-workflow-library`, `bounded-workflow-instruction-packet`, and `combined-bounded-stage-context` — selected through programmatic configuration, not CLI flags. Each strategy's evidence is reported through bounded `report.json`, `report.html`, and `report.txt` output with an explicit `available`/`unavailable`/`not-applicable` metric-availability model and no composite score, grade, ranking, or winning strategy.
20
20
  - **Evaluate the producer-readiness bridge (v0.4.4):** optionally extend `combined-bounded-stage-context` with the frozen my-dev-kit-orchestrator supplemental implementation/test-context packet and retrieval-report documents plus an observed readiness result, all supplied programmatically (there is no CLI flag), to measure owner, allocation, truncation-cause, supplemental/raw agreement, readiness-agreement, and criticality-overlay evidence without reimplementing upstream owner-selection, allocation, producer-parity, or readiness policy.
21
21
  - **Evaluate context integrity (v0.4.5):** compare condition-aware producer evidence from my-dev-kit v1.10.4 (role condition coverage, allocation/spillover, required-evidence-loss) against my-dev-kit-orchestrator v1.2.3 run-integrity evidence (run-integrity gate, judge integrity, final-report eligibility, artifact lifecycle state), reporting agreement or contradiction between them rather than re-deriving a verdict. Evaluation runs against a frozen, hash-verified regression fixture pair — a byte-exact real historical failed run and a hand-distilled corrected-replay counterpart representing the same validated contracts — and is programmatic/test-driven only. `npm run report:context-integrity-smoke` renders both fixtures' reports for manual inspection; it takes no arguments and is a developer convenience, not a configurable evaluation CLI.
@@ -208,7 +208,8 @@ See [docs/METRICS.md](docs/METRICS.md) for full metric definitions.
208
208
  - The published upstream artifacts the stage-context strategies read do not expose considered-but-unselected reads or unnecessary-read evidence; those metrics report `unavailable` rather than zero
209
209
  - The v0.4.4 producer-readiness bridge is released. All bridge inputs are programmatic — there is no CLI flag. The coordinated upstream releases my-dev-kit@1.10.3 and orchestrator@1.2.2 were verified published before lab publication.
210
210
  - The v0.4.5 context-integrity evaluation is released. It has no CLI flags, no plots/screenshot/gallery integration, and produces no composite score, grade, ranking, or winner. Its corrected-replay fixture is a hand-distilled representation of the validated my-dev-kit v1.10.4 and my-dev-kit-orchestrator v1.2.3 contracts, not a live capture of a complete ten-stage workflow. The orchestrator agreement evidence does not read a literal upstream `promptMode` field; `stageMayRenderNormalPrompt`, derived from structured blocked-stage evidence, is the bounded substitute used instead.
211
- - v0.4.7 adds declarative browser tutorial validation and execution with bounded actions/assertions, persistent Chromium sessions, WebM, screenshots, SRT/VTT, Markdown, and `tutorial-manifest.json`. Chromium is a separate local runtime prerequisite; the package does not download browser binaries automatically. Gallery consumption remains future scope.
211
+ - v0.4.7 added declarative browser tutorial validation and execution with bounded actions/assertions, persistent Chromium sessions, WebM, screenshots, SRT/VTT, Markdown, and `tutorial-manifest.json`. Chromium is a separate local runtime prerequisite; the package does not download browser binaries automatically. Gallery consumption remains future scope.
212
+ - v0.4.8 added `pointer-click` and `pointer-drag` for normalized fraction positions inside a single located element. Existing `drag` remains element-to-element between source and target locators.
212
213
 
213
214
  ---
214
215
 
@@ -5,6 +5,7 @@ export * from "./loadTutorialContracts.js";
5
5
  export * from "./tutorialPaths.js";
6
6
  export * from "./tutorialLocators.js";
7
7
  export * from "./tutorialActions.js";
8
+ export * from "./tutorialPointerGeometry.js";
8
9
  export * from "./tutorialAssertions.js";
9
10
  export * from "./tutorialCursor.js";
10
11
  export * from "./tutorialOverlay.js";
@@ -1,4 +1,4 @@
1
- import { MAX_STEP_PAUSE_MS, MAX_TUTORIAL_STEPS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, MIN_VIEWPORT_HEIGHT, MIN_VIEWPORT_WIDTH, TUTORIAL_ACTION_TYPES, TUTORIAL_ASSERTION_TYPES, TUTORIAL_CALLOUT_PLACEMENTS, TUTORIAL_GOTO_WAIT_UNTIL, TUTORIAL_LOCATOR_KINDS, TUTORIAL_SCHEMA_VERSION, TUTORIAL_WAIT_FOR_STATES } from "./types.js";
1
+ import { MAX_STEP_PAUSE_MS, MAX_TUTORIAL_STEPS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, MIN_VIEWPORT_HEIGHT, MIN_VIEWPORT_WIDTH, TUTORIAL_ACTION_TYPES, TUTORIAL_ASSERTION_TYPES, TUTORIAL_CALLOUT_PLACEMENTS, TUTORIAL_GOTO_WAIT_UNTIL, TUTORIAL_LOCATOR_KINDS, TUTORIAL_POINTER_COORDINATE_SPACES, TUTORIAL_SCHEMA_VERSION, TUTORIAL_WAIT_FOR_STATES } from "./types.js";
2
2
  import { createErrorCollector, describeValue, isJsonScalar, isNonEmptyString, isNonNegativeInteger, isPlainObject, rejectUnknownKeys, validateOptionalTimeout, validateSchemaVersion, validateStableId } from "./validationPrimitives.js";
3
3
  const SCENARIO_KEYS = ["schemaVersion", "id", "title", "description", "targetId", "browser", "steps"];
4
4
  const STEP_KEYS = [
@@ -266,6 +266,29 @@ export function validateAction(errors, location, value) {
266
266
  validateLocator(errors, `${location}.target`, value.target);
267
267
  validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
268
268
  break;
269
+ case "pointer-click":
270
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "position", "coordinateSpace", "timeoutMs"]);
271
+ validateLocator(errors, `${location}.locator`, value.locator);
272
+ validateFractionPoint(errors, `${location}.position`, value.position);
273
+ validatePointerCoordinateSpace(errors, `${location}.coordinateSpace`, value.coordinateSpace);
274
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
275
+ break;
276
+ case "pointer-drag": {
277
+ rejectUnknownKeys(errors, location, value, ["type", "locator", "from", "to", "coordinateSpace", "timeoutMs"]);
278
+ validateLocator(errors, `${location}.locator`, value.locator);
279
+ const fromValid = validateFractionPoint(errors, `${location}.from`, value.from);
280
+ const toValid = validateFractionPoint(errors, `${location}.to`, value.to);
281
+ validatePointerCoordinateSpace(errors, `${location}.coordinateSpace`, value.coordinateSpace);
282
+ validateOptionalTimeout(errors, `${location}.timeoutMs`, value.timeoutMs);
283
+ if (fromValid && toValid) {
284
+ const from = value.from;
285
+ const to = value.to;
286
+ if (from.x === to.x && from.y === to.y) {
287
+ errors.add(`${location}.to`, "pointer-drag endpoints must be distinct; zero-length drags are not allowed.");
288
+ }
289
+ }
290
+ break;
291
+ }
269
292
  case "wait-for":
270
293
  rejectUnknownKeys(errors, location, value, ["type", "locator", "state", "timeoutMs"]);
271
294
  validateLocator(errors, `${location}.locator`, value.locator);
@@ -276,6 +299,27 @@ export function validateAction(errors, location, value) {
276
299
  break;
277
300
  }
278
301
  }
302
+ function validateFractionPoint(errors, location, value) {
303
+ if (!isPlainObject(value)) {
304
+ errors.add(location, `expected a fraction point object, received ${describeValue(value)}.`);
305
+ return false;
306
+ }
307
+ const startLength = errors.length;
308
+ rejectUnknownKeys(errors, location, value, ["x", "y"]);
309
+ validateFractionCoordinate(errors, `${location}.x`, value.x);
310
+ validateFractionCoordinate(errors, `${location}.y`, value.y);
311
+ return errors.length === startLength;
312
+ }
313
+ function validateFractionCoordinate(errors, location, value) {
314
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
315
+ errors.add(location, `expected a finite number within inclusive bounds [0, 1], received ${describeValue(value)}.`);
316
+ }
317
+ }
318
+ function validatePointerCoordinateSpace(errors, location, value) {
319
+ if (!TUTORIAL_POINTER_COORDINATE_SPACES.includes(value)) {
320
+ errors.add(location, `expected "fraction", received ${describeValue(value)}.`);
321
+ }
322
+ }
279
323
  /**
280
324
  * A scenario may only navigate inside the target application's own origin, so a
281
325
  * goto path is a root-relative path, never a URL. Rejecting the URL forms here
@@ -1,5 +1,7 @@
1
1
  import { describeTutorialLocator, resolveTutorialLocator } from "./tutorialLocators.js";
2
+ import { resolveTutorialFractionPoint } from "./tutorialPointerGeometry.js";
2
3
  import { DEFAULT_TUTORIAL_ACTION_TIMEOUT_MS } from "./types.js";
4
+ export const POINTER_DRAG_MOVE_STEPS = 8;
3
5
  /**
4
6
  * Resolves a scenario `goto` path against the target application's URL and
5
7
  * proves the result never leaves that origin.
@@ -72,6 +74,31 @@ async function performAction(page, action, context) {
72
74
  await source.dragTo(target, { timeout: timeoutFor(action.timeoutMs) });
73
75
  return;
74
76
  }
77
+ case "pointer-click": {
78
+ const locator = resolveTutorialLocator(page, action.locator);
79
+ await locator.waitFor({ state: "visible", timeout: timeoutFor(action.timeoutMs) });
80
+ const box = await locator.boundingBox();
81
+ if (!box) {
82
+ throw new Error("locator has no bounding box");
83
+ }
84
+ const point = resolveTutorialFractionPoint(box, action.position);
85
+ await page.mouse.move(point.x, point.y);
86
+ await runWithMouseDown(page.mouse, async () => { });
87
+ return;
88
+ }
89
+ case "pointer-drag": {
90
+ const locator = resolveTutorialLocator(page, action.locator);
91
+ await locator.waitFor({ state: "visible", timeout: timeoutFor(action.timeoutMs) });
92
+ const box = await locator.boundingBox();
93
+ if (!box) {
94
+ throw new Error("locator has no bounding box");
95
+ }
96
+ const start = resolveTutorialFractionPoint(box, action.from);
97
+ const end = resolveTutorialFractionPoint(box, action.to);
98
+ await page.mouse.move(start.x, start.y);
99
+ await runWithMouseDown(page.mouse, () => page.mouse.move(end.x, end.y, { steps: POINTER_DRAG_MOVE_STEPS }));
100
+ return;
101
+ }
75
102
  case "wait-for":
76
103
  await resolveTutorialLocator(page, action.locator).waitFor({
77
104
  ...(action.state !== undefined ? { state: action.state } : {}),
@@ -80,6 +107,34 @@ async function performAction(page, action, context) {
80
107
  return;
81
108
  }
82
109
  }
110
+ async function runWithMouseDown(mouse, operation) {
111
+ await mouse.down();
112
+ let primaryError;
113
+ let primaryFailed = false;
114
+ try {
115
+ await operation();
116
+ }
117
+ catch (error) {
118
+ primaryFailed = true;
119
+ primaryError = error;
120
+ }
121
+ try {
122
+ await mouse.up();
123
+ }
124
+ catch (cleanupError) {
125
+ if (primaryFailed) {
126
+ const primaryMessage = messageOf(primaryError);
127
+ const cleanupMessage = messageOf(cleanupError);
128
+ throw new Error(`${primaryMessage} (mouse.up cleanup also failed: ${cleanupMessage})`, {
129
+ cause: primaryError
130
+ });
131
+ }
132
+ throw cleanupError;
133
+ }
134
+ if (primaryFailed) {
135
+ throw primaryError;
136
+ }
137
+ }
83
138
  export function timeoutFor(timeoutMs) {
84
139
  return timeoutMs ?? DEFAULT_TUTORIAL_ACTION_TIMEOUT_MS;
85
140
  }
@@ -100,6 +155,10 @@ function describeActionError(action, error) {
100
155
  return `goto ${JSON.stringify(action.path)} failed: ${message}`;
101
156
  case "drag":
102
157
  return `drag from ${describeTutorialLocator(action.source)} to ${describeTutorialLocator(action.target)} failed: ${message}`;
158
+ case "pointer-click":
159
+ return `pointer-click on ${describeTutorialLocator(action.locator)} at fraction ${describeFractionPoint(action.position)} failed: ${message}`;
160
+ case "pointer-drag":
161
+ return `pointer-drag on ${describeTutorialLocator(action.locator)} from fraction ${describeFractionPoint(action.from)} to ${describeFractionPoint(action.to)} failed: ${message}`;
103
162
  case "click":
104
163
  case "fill":
105
164
  case "press":
@@ -108,3 +167,9 @@ function describeActionError(action, error) {
108
167
  return `${action.type} on ${describeTutorialLocator(action.locator)} failed: ${message}`;
109
168
  }
110
169
  }
170
+ function describeFractionPoint(point) {
171
+ return `(${String(point.x)}, ${String(point.y)})`;
172
+ }
173
+ function messageOf(error) {
174
+ return error instanceof Error ? error.message : String(error);
175
+ }
@@ -0,0 +1,16 @@
1
+ /** Converts a validated locator-relative fraction into page coordinates. */
2
+ export function resolveTutorialFractionPoint(box, point) {
3
+ if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) {
4
+ throw new Error("Tutorial pointer bounding box values must all be finite.");
5
+ }
6
+ if (box.width <= 0 || box.height <= 0) {
7
+ throw new Error("Tutorial pointer bounding box must have positive width and height.");
8
+ }
9
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1) {
10
+ throw new Error("Tutorial pointer fractions must be finite numbers within inclusive bounds [0, 1].");
11
+ }
12
+ return {
13
+ x: box.x + box.width * point.x,
14
+ y: box.y + box.height * point.y
15
+ };
16
+ }
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { executeTutorialAction } from "./tutorialActions.js";
3
3
  import { executeTutorialAssertion } from "./tutorialAssertions.js";
4
4
  import { resolveTutorialLocator, describeTutorialLocator } from "./tutorialLocators.js";
5
+ import { resolveTutorialFractionPoint } from "./tutorialPointerGeometry.js";
5
6
  import { buildScreenshotPath, toRunRelativePosixPath, verifyArtifactFile } from "./tutorialArtifacts.js";
6
7
  import { CLICK_FEEDBACK_DURATION_MS, CURSOR_MOVE_DURATION_MS, centerOfBox, installTutorialCursor, moveTutorialCursor, removeTutorialVisuals, showTutorialClickFeedback } from "./tutorialCursor.js";
7
8
  import { applyTutorialCallout, applyTutorialHighlight, clearTutorialStepOverlays, resolveCalloutPlacement } from "./tutorialOverlay.js";
@@ -209,9 +210,27 @@ async function moveCursorForAction(options, action, warnings, stepId, sleep) {
209
210
  if (!box) {
210
211
  return undefined;
211
212
  }
212
- await runVisual(warnings, stepId, "move cursor", () => moveTutorialCursor(options.page, centerOfBox(box)));
213
+ let point;
214
+ let pointerDragEnd;
215
+ try {
216
+ if (action.type === "pointer-click") {
217
+ point = resolveTutorialFractionPoint(box, action.position);
218
+ }
219
+ else if (action.type === "pointer-drag") {
220
+ point = resolveTutorialFractionPoint(box, action.from);
221
+ pointerDragEnd = resolveTutorialFractionPoint(box, action.to);
222
+ }
223
+ else {
224
+ point = centerOfBox(box);
225
+ }
226
+ }
227
+ catch (error) {
228
+ warnings.push(`Step ${JSON.stringify(stepId)}: pointer visual for ${describeTutorialLocator(locator)} could not be planned: ${messageOf(error)}`);
229
+ return undefined;
230
+ }
231
+ await runVisual(warnings, stepId, "move cursor", () => moveTutorialCursor(options.page, point));
213
232
  await sleep(CURSOR_MOVE_DURATION_MS);
214
- return box;
233
+ return { point, ...(pointerDragEnd ? { pointerDragEnd } : {}) };
215
234
  }
216
235
  /** The locator whose center the cursor points at before each action type. */
217
236
  export function cursorLocatorForAction(action) {
@@ -220,6 +239,8 @@ export function cursorLocatorForAction(action) {
220
239
  case "fill":
221
240
  case "press":
222
241
  case "hover":
242
+ case "pointer-click":
243
+ case "pointer-drag":
223
244
  return action.locator;
224
245
  case "drag":
225
246
  return action.source;
@@ -234,8 +255,8 @@ async function applyPostActionVisuals(options, step, cursorTarget, warnings, sle
234
255
  if (!action) {
235
256
  return;
236
257
  }
237
- if (action.type === "click" && cursorTarget) {
238
- await runVisual(warnings, step.id, "click feedback", () => showTutorialClickFeedback(options.page, centerOfBox(cursorTarget)));
258
+ if ((action.type === "click" || action.type === "pointer-click") && cursorTarget) {
259
+ await runVisual(warnings, step.id, "click feedback", () => showTutorialClickFeedback(options.page, cursorTarget.point));
239
260
  // Held only long enough for the ripple to be visible in the recording.
240
261
  await sleep(CLICK_FEEDBACK_DURATION_MS);
241
262
  return;
@@ -247,6 +268,11 @@ async function applyPostActionVisuals(options, step, cursorTarget, warnings, sle
247
268
  await runVisual(warnings, step.id, "move cursor to drag target", () => moveTutorialCursor(options.page, centerOfBox(box)));
248
269
  await sleep(CURSOR_MOVE_DURATION_MS);
249
270
  }
271
+ return;
272
+ }
273
+ if (action.type === "pointer-drag" && cursorTarget?.pointerDragEnd) {
274
+ await runVisual(warnings, step.id, "move cursor to pointer-drag endpoint", () => moveTutorialCursor(options.page, cursorTarget.pointerDragEnd));
275
+ await sleep(CURSOR_MOVE_DURATION_MS);
250
276
  }
251
277
  }
252
278
  async function applyStepOverlays(options, step, warnings) {
@@ -23,6 +23,7 @@ export const TUTORIAL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
23
23
  /** The only placeholder a trusted target contract may use. */
24
24
  export const TARGET_ROOT_PLACEHOLDER = "{{targetRoot}}";
25
25
  export const TUTORIAL_LOCATOR_KINDS = ["role", "text", "css", "test-id"];
26
+ export const TUTORIAL_POINTER_COORDINATE_SPACES = ["fraction"];
26
27
  export const TUTORIAL_ACTION_TYPES = [
27
28
  "goto",
28
29
  "click",
@@ -30,7 +31,9 @@ export const TUTORIAL_ACTION_TYPES = [
30
31
  "press",
31
32
  "hover",
32
33
  "drag",
33
- "wait-for"
34
+ "wait-for",
35
+ "pointer-click",
36
+ "pointer-drag"
34
37
  ];
35
38
  export const TUTORIAL_GOTO_WAIT_UNTIL = ["load", "domcontentloaded", "networkidle"];
36
39
  export const TUTORIAL_WAIT_FOR_STATES = ["visible", "hidden", "attached", "detached"];
@@ -1,47 +1,109 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <title>Tutorial Fixture</title>
6
- <style>
7
- body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 24px; background: #f6f7f9; color: #111; }
8
- h1 { margin: 0 0 16px; font-size: 28px; }
9
- .panel { background: #fff; border: 1px solid #d8dbe0; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
10
- .row { display: flex; gap: 16px; align-items: flex-start; }
11
- .col { flex: 1; }
12
- button { font: inherit; padding: 8px 16px; border-radius: 6px; border: 1px solid #5a6270; background: #fff; cursor: pointer; }
13
- button:hover { background: #eef1f5; }
14
- input { font: inherit; padding: 8px; border: 1px solid #b8bec8; border-radius: 6px; width: 100%; box-sizing: border-box; }
15
- .dropzone { min-height: 72px; border: 2px dashed #9aa3af; border-radius: 8px; padding: 12px; }
16
- .card { background: #dde7ff; border: 1px solid #5f7fd6; border-radius: 6px; padding: 10px; width: 140px; }
17
- .hidden { display: none; }
18
- #status-banner { padding: 10px; border-radius: 6px; background: #e8f5e9; border: 1px solid #66bb6a; }
19
- </style>
20
- </head>
21
- <body>
22
- <h1 id="page-title">Tutorial Fixture</h1>
23
- <div class="panel"><p id="intro-text">Welcome to the generic tutorial fixture.</p><button id="primary-action" data-testid="primary-action" data-state="idle">Activate</button> <span id="activation-count" data-testid="activation-count">0</span></div>
24
- <div class="panel"><label for="name-field">Your name</label><input id="name-field" data-testid="name-field" aria-label="Your name" /><p id="greeting" data-testid="greeting"></p></div>
25
- <div class="panel"><button id="hover-target" data-testid="hover-target">Hover me</button><p id="hover-readout" data-testid="hover-readout">not hovered</p></div>
26
- <div class="panel row"><div class="col"><div id="drag-card" data-testid="drag-card" class="card" draggable="true">Draggable card</div></div><div class="col"><div id="drop-zone" data-testid="drop-zone" class="dropzone">Drop target</div></div></div>
27
- <div class="panel"><div id="status-banner" data-testid="status-banner" class="hidden">Delayed banner is now visible</div></div>
28
- <script>
29
- (() => {
30
- let activations = 0;
31
- const primary = document.getElementById("primary-action");
32
- primary.addEventListener("click", () => { activations += 1; document.getElementById("activation-count").textContent = String(activations); primary.setAttribute("data-state", "activated"); });
33
- const nameField = document.getElementById("name-field");
34
- const greeting = document.getElementById("greeting");
35
- nameField.addEventListener("input", () => { greeting.textContent = nameField.value ? `Hello, ${nameField.value}` : ""; });
36
- nameField.addEventListener("keydown", event => { if (event.key === "Enter") greeting.textContent = `Submitted: ${nameField.value}`; });
37
- document.getElementById("hover-target").addEventListener("mouseenter", () => { document.getElementById("hover-readout").textContent = "hovered"; });
38
- const dragCard = document.getElementById("drag-card");
39
- const dropZone = document.getElementById("drop-zone");
40
- dragCard.addEventListener("dragstart", event => event.dataTransfer.setData("text/plain", "drag-card"));
41
- dropZone.addEventListener("dragover", event => event.preventDefault());
42
- dropZone.addEventListener("drop", event => { event.preventDefault(); dropZone.textContent = "Dropped"; dropZone.setAttribute("data-dropped", "true"); });
43
- window.setTimeout(() => document.getElementById("status-banner").classList.remove("hidden"), 300);
44
- })();
45
- </script>
46
- </body>
47
- </html>
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Tutorial Fixture</title>
6
+ <style>
7
+ body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 24px; background: #f6f7f9; color: #111; }
8
+ h1 { margin: 0 0 16px; font-size: 28px; }
9
+ .panel { background: #fff; border: 1px solid #d8dbe0; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
10
+ .row { display: flex; gap: 16px; align-items: flex-start; }
11
+ .col { flex: 1; }
12
+ button { font: inherit; padding: 8px 16px; border-radius: 6px; border: 1px solid #5a6270; background: #fff; cursor: pointer; }
13
+ button:hover { background: #eef1f5; }
14
+ input { font: inherit; padding: 8px; border: 1px solid #b8bec8; border-radius: 6px; width: 100%; box-sizing: border-box; }
15
+ .dropzone { min-height: 72px; border: 2px dashed #9aa3af; border-radius: 8px; padding: 12px; }
16
+ .card { background: #dde7ff; border: 1px solid #5f7fd6; border-radius: 6px; padding: 10px; width: 140px; }
17
+ #pointer-surface { display: block; width: 400px; height: 240px; background: #eef4ff; box-shadow: inset 0 0 0 2px #7890c2; touch-action: none; user-select: none; }
18
+ .hidden { display: none; }
19
+ #status-banner { padding: 10px; border-radius: 6px; background: #e8f5e9; border: 1px solid #66bb6a; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <h1 id="page-title">Tutorial Fixture</h1>
24
+ <div class="panel row"><div class="col"><div id="drag-card" data-testid="drag-card" class="card" draggable="true">Draggable card</div></div><div class="col"><div id="drop-zone" data-testid="drop-zone" class="dropzone">Drop target</div></div></div>
25
+ <div class="panel"><svg id="pointer-surface" data-testid="pointer-surface" width="400" height="240" viewBox="0 0 400 240" aria-label="Pointer gesture surface" data-pointer-click-count="0" data-last-pointer-click-zone="none" data-pointer-drag-count="0" data-pointer-drag-start-zone="none" data-pointer-drag-end-zone="none" data-pointer-drag-intermediate="false"><line id="pointer-drag-evidence" x1="0" y1="0" x2="0" y2="0" stroke="#3659a2" stroke-width="4" visibility="hidden" pointer-events="none"></line><circle id="pointer-click-evidence" cx="0" cy="0" r="8" fill="#d94f04" visibility="hidden" pointer-events="none"></circle></svg></div>
26
+ <div class="panel"><p id="intro-text">Welcome to the generic tutorial fixture.</p><button id="primary-action" data-testid="primary-action" data-state="idle">Activate</button> <span id="activation-count" data-testid="activation-count">0</span></div>
27
+ <div class="panel"><label for="name-field">Your name</label><input id="name-field" data-testid="name-field" aria-label="Your name" /><p id="greeting" data-testid="greeting"></p></div>
28
+ <div class="panel"><button id="hover-target" data-testid="hover-target">Hover me</button><p id="hover-readout" data-testid="hover-readout">not hovered</p></div>
29
+ <div class="panel"><div id="status-banner" data-testid="status-banner" class="hidden">Delayed banner is now visible</div></div>
30
+ <script>
31
+ (() => {
32
+ let activations = 0;
33
+ const primary = document.getElementById("primary-action");
34
+ primary.addEventListener("click", () => { activations += 1; document.getElementById("activation-count").textContent = String(activations); primary.setAttribute("data-state", "activated"); });
35
+ const nameField = document.getElementById("name-field");
36
+ const greeting = document.getElementById("greeting");
37
+ nameField.addEventListener("input", () => { greeting.textContent = nameField.value ? `Hello, ${nameField.value}` : ""; });
38
+ nameField.addEventListener("keydown", event => { if (event.key === "Enter") greeting.textContent = `Submitted: ${nameField.value}`; });
39
+ document.getElementById("hover-target").addEventListener("mouseenter", () => { document.getElementById("hover-readout").textContent = "hovered"; });
40
+ const dragCard = document.getElementById("drag-card");
41
+ const dropZone = document.getElementById("drop-zone");
42
+ dragCard.addEventListener("dragstart", event => event.dataTransfer.setData("text/plain", "drag-card"));
43
+ dropZone.addEventListener("dragover", event => event.preventDefault());
44
+ dropZone.addEventListener("drop", event => { event.preventDefault(); dropZone.textContent = "Dropped"; dropZone.setAttribute("data-dropped", "true"); });
45
+ const pointerSurface = document.getElementById("pointer-surface");
46
+ const clickEvidence = document.getElementById("pointer-click-evidence");
47
+ const dragEvidence = document.getElementById("pointer-drag-evidence");
48
+ const POINTER_CLICK_THRESHOLD = 0.01;
49
+ let activePointerId = null;
50
+ let pointerStart = null;
51
+ let pointerLatest = null;
52
+ let activeMoveCount = 0;
53
+ let pointerClickCount = 0;
54
+ let pointerDragCount = 0;
55
+ const fractionPoint = event => {
56
+ const box = pointerSurface.getBoundingClientRect();
57
+ return {
58
+ x: Math.max(0, Math.min(1, (event.clientX - box.left) / box.width)),
59
+ y: Math.max(0, Math.min(1, (event.clientY - box.top) / box.height))
60
+ };
61
+ };
62
+ const zoneFor = point => point.x < 0.5
63
+ ? (point.y < 0.5 ? "upper-left" : "lower-left")
64
+ : (point.y < 0.5 ? "upper-right" : "lower-right");
65
+ pointerSurface.addEventListener("pointerdown", event => {
66
+ activePointerId = event.pointerId;
67
+ pointerStart = fractionPoint(event);
68
+ pointerLatest = pointerStart;
69
+ activeMoveCount = 0;
70
+ pointerSurface.setPointerCapture(event.pointerId);
71
+ });
72
+ pointerSurface.addEventListener("pointermove", event => {
73
+ if (event.pointerId !== activePointerId || !pointerStart) return;
74
+ activeMoveCount += 1;
75
+ pointerLatest = fractionPoint(event);
76
+ });
77
+ pointerSurface.addEventListener("pointerup", event => {
78
+ if (event.pointerId !== activePointerId || !pointerStart) return;
79
+ const pointerEnd = fractionPoint(event);
80
+ const distance = Math.hypot(pointerEnd.x - pointerStart.x, pointerEnd.y - pointerStart.y);
81
+ if (distance <= POINTER_CLICK_THRESHOLD) {
82
+ pointerClickCount += 1;
83
+ pointerSurface.setAttribute("data-pointer-click-count", String(pointerClickCount));
84
+ pointerSurface.setAttribute("data-last-pointer-click-zone", zoneFor(pointerEnd));
85
+ clickEvidence.setAttribute("cx", String(pointerEnd.x * 400));
86
+ clickEvidence.setAttribute("cy", String(pointerEnd.y * 240));
87
+ clickEvidence.setAttribute("visibility", "visible");
88
+ } else {
89
+ pointerDragCount += 1;
90
+ pointerSurface.setAttribute("data-pointer-drag-count", String(pointerDragCount));
91
+ pointerSurface.setAttribute("data-pointer-drag-start-zone", zoneFor(pointerStart));
92
+ pointerSurface.setAttribute("data-pointer-drag-end-zone", zoneFor(pointerEnd));
93
+ pointerSurface.setAttribute("data-pointer-drag-intermediate", String(activeMoveCount >= 2));
94
+ dragEvidence.setAttribute("x1", String(pointerStart.x * 400));
95
+ dragEvidence.setAttribute("y1", String(pointerStart.y * 240));
96
+ dragEvidence.setAttribute("x2", String(pointerEnd.x * 400));
97
+ dragEvidence.setAttribute("y2", String(pointerEnd.y * 240));
98
+ dragEvidence.setAttribute("visibility", "visible");
99
+ }
100
+ pointerLatest = pointerEnd;
101
+ activePointerId = null;
102
+ pointerStart = null;
103
+ void pointerLatest;
104
+ });
105
+ window.setTimeout(() => document.getElementById("status-banner").classList.remove("hidden"), 300);
106
+ })();
107
+ </script>
108
+ </body>
109
+ </html>
@@ -1,17 +1,19 @@
1
- {
2
- "schemaVersion": "1.0.0",
3
- "id": "lab-browser-fixture",
4
- "title": "Lab browser fixture tutorial",
5
- "description": "A generic, lab-owned tutorial that exercises browser actions, locators, assertions and visual declarations against a local static fixture app.",
6
- "targetId": "lab-browser-fixture",
7
- "browser": { "viewport": { "width": 1024, "height": 768 } },
8
- "steps": [
9
- { "id": "open-app", "narration": "Open the fixture application in the browser.", "action": { "type": "goto", "path": "/", "waitUntil": "load" }, "highlight": { "kind": "css", "selector": "#page-title" }, "callout": { "text": "This is the fixture application.", "locator": { "kind": "css", "selector": "#page-title" }, "placement": "bottom" }, "screenshot": { "id": "app-open" }, "assertions": [ { "type": "url-path-equals", "expected": "/" }, { "type": "element-visible", "locator": { "kind": "role", "role": "heading", "name": "Tutorial Fixture" } }, { "type": "text-equals", "locator": { "kind": "css", "selector": "#page-title" }, "expected": "Tutorial Fixture" }, { "type": "file-exists", "path": "out/report.json" }, { "type": "json-file-equals", "path": "out/report.json", "pointer": "/counts/widgets", "expected": 3 }, { "type": "http-json-equals", "path": "/api/status", "pointer": "/ok", "expected": true } ] },
10
- { "id": "activate", "narration": "Click the primary action button to activate the panel.", "action": { "type": "click", "locator": { "kind": "test-id", "testId": "primary-action" } }, "highlight": { "kind": "test-id", "testId": "primary-action" }, "callout": { "text": "Clicking here activates the panel.", "placement": "auto", "locator": { "kind": "test-id", "testId": "primary-action" } }, "screenshot": { "id": "activated" }, "assertions": [ { "type": "text-equals", "locator": { "kind": "test-id", "testId": "activation-count" }, "expected": "1" }, { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "primary-action" }, "name": "data-state", "expected": "activated" } ] },
11
- { "id": "fill-name", "narration": "Type a name into the form field.", "action": { "type": "fill", "locator": { "kind": "test-id", "testId": "name-field" }, "value": "Ada" }, "assertions": [ { "type": "text-contains", "locator": { "kind": "test-id", "testId": "greeting" }, "expected": "Hello, Ada" } ] },
12
- { "id": "submit-name", "narration": "Press Enter to submit the name.", "action": { "type": "press", "locator": { "kind": "test-id", "testId": "name-field" }, "key": "Enter" }, "screenshot": { "id": "submitted" }, "assertions": [ { "type": "text-contains", "locator": { "kind": "test-id", "testId": "greeting" }, "expected": "Submitted: Ada" } ] },
13
- { "id": "hover-button", "narration": "Hover the secondary button to reveal its state.", "action": { "type": "hover", "locator": { "kind": "test-id", "testId": "hover-target" } }, "assertions": [ { "type": "text-equals", "locator": { "kind": "test-id", "testId": "hover-readout" }, "expected": "hovered" } ] },
14
- { "id": "drag-card", "narration": "Drag the card onto the drop target.", "action": { "type": "drag", "source": { "kind": "test-id", "testId": "drag-card" }, "target": { "kind": "test-id", "testId": "drop-zone" } }, "highlight": { "kind": "test-id", "testId": "drop-zone" }, "screenshot": { "id": "dropped" }, "assertions": [ { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "drop-zone" }, "name": "data-dropped", "expected": "true" } ] },
15
- { "id": "wait-banner", "narration": "Wait for the delayed status banner to appear.", "action": { "type": "wait-for", "locator": { "kind": "text", "text": "Delayed banner is now visible" }, "state": "visible", "timeoutMs": 5000 }, "callout": { "text": "The banner appears after a short delay." }, "screenshot": { "id": "banner-visible" }, "assertions": [ { "type": "element-visible", "locator": { "kind": "test-id", "testId": "status-banner" } } ] }
16
- ]
17
- }
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "id": "lab-browser-fixture",
4
+ "title": "Lab browser fixture tutorial",
5
+ "description": "A generic, lab-owned tutorial that exercises browser actions, locators, assertions and visual declarations against a local static fixture app.",
6
+ "targetId": "lab-browser-fixture",
7
+ "browser": { "viewport": { "width": 1024, "height": 768 } },
8
+ "steps": [
9
+ { "id": "open-app", "narration": "Open the fixture application in the browser.", "action": { "type": "goto", "path": "/", "waitUntil": "load" }, "highlight": { "kind": "css", "selector": "#page-title" }, "callout": { "text": "This is the fixture application.", "locator": { "kind": "css", "selector": "#page-title" }, "placement": "bottom" }, "screenshot": { "id": "app-open" }, "assertions": [ { "type": "url-path-equals", "expected": "/" }, { "type": "element-visible", "locator": { "kind": "role", "role": "heading", "name": "Tutorial Fixture" } }, { "type": "text-equals", "locator": { "kind": "css", "selector": "#page-title" }, "expected": "Tutorial Fixture" }, { "type": "file-exists", "path": "out/report.json" }, { "type": "json-file-equals", "path": "out/report.json", "pointer": "/counts/widgets", "expected": 3 }, { "type": "http-json-equals", "path": "/api/status", "pointer": "/ok", "expected": true } ] },
10
+ { "id": "activate", "narration": "Click the primary action button to activate the panel.", "action": { "type": "click", "locator": { "kind": "test-id", "testId": "primary-action" } }, "highlight": { "kind": "test-id", "testId": "primary-action" }, "callout": { "text": "Clicking here activates the panel.", "placement": "auto", "locator": { "kind": "test-id", "testId": "primary-action" } }, "screenshot": { "id": "activated" }, "assertions": [ { "type": "text-equals", "locator": { "kind": "test-id", "testId": "activation-count" }, "expected": "1" }, { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "primary-action" }, "name": "data-state", "expected": "activated" } ] },
11
+ { "id": "fill-name", "narration": "Type a name into the form field.", "action": { "type": "fill", "locator": { "kind": "test-id", "testId": "name-field" }, "value": "Ada" }, "assertions": [ { "type": "text-contains", "locator": { "kind": "test-id", "testId": "greeting" }, "expected": "Hello, Ada" } ] },
12
+ { "id": "submit-name", "narration": "Press Enter to submit the name.", "action": { "type": "press", "locator": { "kind": "test-id", "testId": "name-field" }, "key": "Enter" }, "screenshot": { "id": "submitted" }, "assertions": [ { "type": "text-contains", "locator": { "kind": "test-id", "testId": "greeting" }, "expected": "Submitted: Ada" } ] },
13
+ { "id": "hover-button", "narration": "Hover the secondary button to reveal its state.", "action": { "type": "hover", "locator": { "kind": "test-id", "testId": "hover-target" } }, "assertions": [ { "type": "text-equals", "locator": { "kind": "test-id", "testId": "hover-readout" }, "expected": "hovered" } ] },
14
+ { "id": "drag-card", "narration": "Drag the card onto the drop target.", "action": { "type": "drag", "source": { "kind": "test-id", "testId": "drag-card" }, "target": { "kind": "test-id", "testId": "drop-zone" } }, "highlight": { "kind": "test-id", "testId": "drop-zone" }, "screenshot": { "id": "dropped" }, "assertions": [ { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "drop-zone" }, "name": "data-dropped", "expected": "true" } ] },
15
+ { "id": "pointer-click-surface", "narration": "Click a deliberate position inside the pointer gesture surface.", "action": { "type": "pointer-click", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "position": { "x": 0.25, "y": 0.25 }, "coordinateSpace": "fraction" }, "highlight": { "kind": "test-id", "testId": "pointer-surface" }, "screenshot": { "id": "pointer-clicked" }, "assertions": [ { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "name": "data-pointer-click-count", "expected": "1" }, { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "name": "data-last-pointer-click-zone", "expected": "upper-left" } ] },
16
+ { "id": "pointer-drag-surface", "narration": "Drag across two distinct positions inside the same pointer gesture surface.", "action": { "type": "pointer-drag", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "from": { "x": 0.20, "y": 0.25 }, "to": { "x": 0.80, "y": 0.75 }, "coordinateSpace": "fraction" }, "screenshot": { "id": "pointer-dragged" }, "assertions": [ { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "name": "data-pointer-drag-count", "expected": "1" }, { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "name": "data-pointer-drag-start-zone", "expected": "upper-left" }, { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "name": "data-pointer-drag-end-zone", "expected": "lower-right" }, { "type": "attribute-equals", "locator": { "kind": "test-id", "testId": "pointer-surface" }, "name": "data-pointer-drag-intermediate", "expected": "true" } ] },
17
+ { "id": "wait-banner", "narration": "Wait for the delayed status banner to appear.", "action": { "type": "wait-for", "locator": { "kind": "text", "text": "Delayed banner is now visible" }, "state": "visible", "timeoutMs": 5000 }, "callout": { "text": "The banner appears after a short delay." }, "screenshot": { "id": "banner-visible" }, "assertions": [ { "type": "element-visible", "locator": { "kind": "test-id", "testId": "status-banner" } } ] }
18
+ ]
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dailephd/my-dev-kit-lab",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "description": "Evidence, benchmark, and evaluation companion for my-dev-kit.",
6
6
  "bin": {