@olomonhelpdesk2/helpdesk 0.1.14 → 0.1.15

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 (2) hide show
  1. package/helpdesk-capture.mjs +196 -62
  2. package/package.json +1 -1
@@ -427,7 +427,7 @@ function preflight(facts) {
427
427
  name: "posture",
428
428
  ok: true,
429
429
  blocking: false,
430
- detail: facts.clips ? "read-only for the screenshots; after them, the clips the seed command named press the controls their steps name, in the tenant it built" : "read-only: this navigates and photographs, and activates no control"
430
+ detail: facts.clips ? "read-only for the screenshots; after them, the clips the seed command named and the articles ask for press the controls their steps name, in the tenant it built" : "read-only: this navigates and photographs, and activates no control"
431
431
  });
432
432
  if (facts.rebaselineReason) {
433
433
  checks.push({
@@ -1522,13 +1522,21 @@ function layoutManifestText(manifest) {
1522
1522
  // core/capture/clips.ts
1523
1523
  var CLIP_VIEWPORT = { width: 1280, height: 800 };
1524
1524
  var CLIP_FPS = 10;
1525
- var CLIP_MAX_MS = 3e4;
1525
+ var CLIP_MAX_MS = 2e4;
1526
1526
  var CLIP_MAX_STEPS = 12;
1527
1527
  var CLIP_MAX_MP4_BYTES = 3 * 1024 * 1024;
1528
1528
  var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
1529
1529
  var CLIP_GIF_WIDTH = 960;
1530
- var CLIP_ACTIONS = ["click", "fill", "select", "check"];
1531
- var MAX_SCRIPTS = 50;
1530
+ var CLIP_ACTIONS = ["click", "fill", "select", "check", "auto"];
1531
+ function actionForRole(role, value) {
1532
+ if (role === "textbox" || role === "searchbox") return "fill";
1533
+ if ((role === "combobox" || role === "listbox") && value) return "select";
1534
+ if (role === "checkbox" || role === "radio" || role === "switch") return "check";
1535
+ return "click";
1536
+ }
1537
+ var MAX_SEEDED_SCRIPTS = 50;
1538
+ var CLIP_MAX_ARTICLE_SCRIPTS = 400;
1539
+ var CLIP_MAX_LEAD_STEPS = 2 * CLIP_MAX_STEPS;
1532
1540
  var MAX_TEXT2 = 200;
1533
1541
  var MAX_TITLE = 120;
1534
1542
  var MAX_ID = 80;
@@ -1551,21 +1559,40 @@ function readStep(value, at) {
1551
1559
  const says = cleanText(step["says"]);
1552
1560
  const given = step["value"];
1553
1561
  if (!route || !isPathLike(route)) return `step ${at} names no screen`;
1554
- if (typeof role !== "string" || !CONTROL_ROLES.includes(role)) return `step ${at} names no control role a screen lists`;
1562
+ if (role !== void 0 && (typeof role !== "string" || !CONTROL_ROLES.includes(role))) return `step ${at} names no control role a screen lists`;
1555
1563
  if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
1556
- if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select nor check`;
1564
+ if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select, check nor auto`;
1557
1565
  if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
1566
+ if (role === void 0 && action !== "auto") return `step ${at} says how to act on a control without saying what kind it is`;
1558
1567
  const typed = action === "fill" || action === "select";
1559
- if (typed && (typeof given !== "string" || !given.trim() || given.length > MAX_TEXT2)) return `step ${at} has nothing to ${action}`;
1560
- if (!typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1561
- return { route, control: { role, name }, action, ...typed ? { value: given } : {}, says };
1568
+ const valued = typeof given === "string" && given.trim() !== "" && given.length <= MAX_TEXT2;
1569
+ if (typed && !valued) return `step ${at} has nothing to ${action}`;
1570
+ if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1571
+ if (action === "auto" && given !== void 0 && !valued) return `step ${at} has a value nothing can type`;
1572
+ return {
1573
+ route,
1574
+ control: { ...role === void 0 ? {} : { role }, name },
1575
+ action,
1576
+ ...valued && (typed || action === "auto") ? { value: given } : {},
1577
+ says
1578
+ };
1562
1579
  }
1563
- function readClipScripts(raw) {
1580
+ function readSteps(raw) {
1581
+ const steps = [];
1582
+ for (const [index, value] of raw.entries()) {
1583
+ const step = readStep(value, index + 1);
1584
+ if (typeof step === "string") return step;
1585
+ steps.push(step);
1586
+ }
1587
+ return steps;
1588
+ }
1589
+ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1564
1590
  const scripts = [];
1565
1591
  const refused = [];
1566
1592
  if (raw === void 0) return { scripts, refused };
1567
1593
  if (!Array.isArray(raw)) return { scripts, refused: [{ id: "clips", because: "clips is not a list" }] };
1568
- for (const value of raw.slice(0, MAX_SCRIPTS)) {
1594
+ if (raw.length > limit) refused.push({ id: "clips", because: `${raw.length - limit} clip(s) past the first ${limit} were not read` });
1595
+ for (const value of raw.slice(0, limit)) {
1569
1596
  const script = recordOf2(value);
1570
1597
  const id = typeof script["id"] === "string" ? script["id"] : "";
1571
1598
  const label = id || "a clip";
@@ -1590,21 +1617,18 @@ function readClipScripts(raw) {
1590
1617
  refuse(`it has ${rawSteps.length} steps, and a clip has between 1 and ${CLIP_MAX_STEPS}`);
1591
1618
  continue;
1592
1619
  }
1593
- const steps = [];
1594
- let problem = "";
1595
- for (const [index, rawStep] of rawSteps.entries()) {
1596
- const step = readStep(rawStep, index + 1);
1597
- if (typeof step === "string") {
1598
- problem = step;
1599
- break;
1600
- }
1601
- steps.push(step);
1620
+ const rawBefore = script["before"];
1621
+ if (rawBefore !== void 0 && (!Array.isArray(rawBefore) || rawBefore.length > CLIP_MAX_LEAD_STEPS)) {
1622
+ refuse(`its steps before the recording are not a list of at most ${CLIP_MAX_LEAD_STEPS}`);
1623
+ continue;
1602
1624
  }
1603
- if (problem) {
1604
- refuse(problem);
1625
+ const steps = readSteps(rawSteps);
1626
+ const before = readSteps(rawBefore ?? []);
1627
+ if (typeof steps === "string" || typeof before === "string") {
1628
+ refuse(typeof before === "string" ? `before the recording, ${before}` : steps);
1605
1629
  continue;
1606
1630
  }
1607
- const first = steps[0];
1631
+ const first = before[0] ?? steps[0];
1608
1632
  if (!isPathLike(visit) || !fillsPattern(first.route, visit.split("?")[0])) {
1609
1633
  refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
1610
1634
  continue;
@@ -1613,17 +1637,19 @@ function readClipScripts(raw) {
1613
1637
  refuse("it names no screen to land on");
1614
1638
  continue;
1615
1639
  }
1616
- scripts.push({ id, title, visit, steps, ...lands ? { lands } : {} });
1640
+ scripts.push({ id, title, visit, ...before.length ? { before } : {}, steps, ...lands ? { lands } : {} });
1617
1641
  }
1618
1642
  return { scripts, refused };
1619
1643
  }
1620
1644
  function resolveStep(manifest, step) {
1621
1645
  const name = cleanText(step.control.name).toLowerCase();
1622
1646
  const where = manifest.screen.path;
1623
- const matches = manifest.controls.filter((control) => control.role === step.control.role && control.name.toLowerCase() === name);
1624
- if (matches.length === 0) return { ok: false, because: `no ${step.control.role} "${step.control.name}" on ${where}` };
1625
- if (matches.length > 1) return { ok: false, because: `${matches.length} of ${step.control.role} "${step.control.name}" on ${where}, so the step does not say which` };
1626
- if (matches[0].state?.disabled) return { ok: false, because: `${step.control.role} "${step.control.name}" on ${where} is disabled` };
1647
+ const role = step.control.role;
1648
+ const what = role ?? "control";
1649
+ const matches = manifest.controls.filter((control) => (!role || control.role === role) && control.name.toLowerCase() === name);
1650
+ if (matches.length === 0) return { ok: false, because: `no ${what} "${step.control.name}" on ${where}` };
1651
+ if (matches.length > 1) return { ok: false, because: `${matches.length} of ${what} "${step.control.name}" on ${where}, so the step does not say which` };
1652
+ if (matches[0].state?.disabled) return { ok: false, because: `${what} "${step.control.name}" on ${where} is disabled` };
1627
1653
  return { ok: true, control: matches[0] };
1628
1654
  }
1629
1655
  function onScreen(route, location2) {
@@ -1633,19 +1659,22 @@ function clipRoutes(script) {
1633
1659
  return [.../* @__PURE__ */ new Set([...script.steps.map((step) => step.route), ...script.lands ? [script.lands] : []])];
1634
1660
  }
1635
1661
  function clipFingerprint(script, touched) {
1662
+ const stepsOf = (steps) => steps.map((step) => ({
1663
+ route: step.route,
1664
+ role: step.control.role ?? null,
1665
+ name: step.control.name,
1666
+ action: step.action,
1667
+ value: step.value ?? null,
1668
+ says: step.says
1669
+ }));
1636
1670
  const canonical = {
1637
1671
  version: 1,
1638
1672
  id: script.id,
1639
1673
  title: script.title,
1640
1674
  lands: script.lands ?? null,
1641
- steps: script.steps.map((step) => ({
1642
- route: step.route,
1643
- role: step.control.role,
1644
- name: step.control.name,
1645
- action: step.action,
1646
- value: step.value ?? null,
1647
- says: step.says
1648
- })),
1675
+ // Only when there are some, so a clip with none keeps the fingerprint it had before steps could come first.
1676
+ ...script.before?.length ? { before: stepsOf(script.before) } : {},
1677
+ steps: stepsOf(script.steps),
1649
1678
  touched: [...touched].map((screen) => [screen.route, screen.shotId, screen.sha256]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))
1650
1679
  };
1651
1680
  return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
@@ -3356,6 +3385,7 @@ var MOVE_FRAMES = 6;
3356
3385
  var DWELL_MS = 500;
3357
3386
  var PRESS_MS = 200;
3358
3387
  var CHARS_PER_FRAME = 2;
3388
+ var TYPING_FRAMES = 12;
3359
3389
  var SETTLE_MS = 3e3;
3360
3390
  var STEP_WAIT_MS = 4e3;
3361
3391
  var CLOSING_MS = 1200;
@@ -3422,11 +3452,75 @@ async function recordClip(options, script, touched) {
3422
3452
  await keep(next, FRAME_MS);
3423
3453
  return true;
3424
3454
  };
3455
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3456
+ const measureOn = async (step, where) => resolveStep(
3457
+ buildLayoutManifest(await layout(cover), {
3458
+ shotId: script.id,
3459
+ route: step.route,
3460
+ path: where.split("?")[0],
3461
+ variant: "desktop",
3462
+ image: { ...CLIP_VIEWPORT },
3463
+ origin: new URL(options.baseUrl).origin
3464
+ }),
3465
+ step
3466
+ );
3467
+ const steady = async () => {
3468
+ let previous = await page.screenshot(cover);
3469
+ for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3470
+ await pause(FRAME_MS);
3471
+ const next = await page.screenshot(cover);
3472
+ if (sameBytes(next, previous)) return;
3473
+ previous = next;
3474
+ }
3475
+ };
3476
+ const reach = async (route) => {
3477
+ let where = await location2();
3478
+ for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3479
+ await pause(FRAME_MS);
3480
+ where = await location2();
3481
+ }
3482
+ if (onScreen(route, where)) {
3483
+ await page.quiet?.(SETTLE_MS);
3484
+ await steady();
3485
+ }
3486
+ return where;
3487
+ };
3488
+ const lead = async (step) => {
3489
+ const where = await reach(step.route);
3490
+ if (!onScreen(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
3491
+ let resolution = await measureOn(step, where);
3492
+ for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3493
+ await pause(FRAME_MS);
3494
+ resolution = await measureOn(step, where);
3495
+ }
3496
+ if (!resolution.ok) return resolution.because;
3497
+ const target = centreOf(resolution.control.box);
3498
+ const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3499
+ if (action === "fill") {
3500
+ if (!step.value) return `it types into ${resolution.control.name}, and says nothing to type`;
3501
+ await pointer({ kind: "focus", at: target });
3502
+ await type(step.value);
3503
+ } else if (action === "select") {
3504
+ await pointer({ kind: "select", at: target, value: step.value ?? "" });
3505
+ } else {
3506
+ await pointer({ kind: "click", at: target });
3507
+ }
3508
+ await page.quiet?.(SETTLE_MS);
3509
+ await steady();
3510
+ return null;
3511
+ };
3425
3512
  await page.goto(new URL(script.visit, options.baseUrl).toString());
3426
3513
  if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
3427
3514
  const opened = await location2();
3428
- if (!onScreen(script.steps[0].route, opened)) return fail(`it opened on ${opened}, not ${script.steps[0].route}`);
3429
- const before = await judge(opened);
3515
+ const performedFirst = script.before?.[0] ?? script.steps[0];
3516
+ if (!onScreen(performedFirst.route, opened)) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
3517
+ for (const [index, step] of (script.before ?? []).entries()) {
3518
+ const problem = await lead(step);
3519
+ if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
3520
+ }
3521
+ const starting = script.before?.length ? await reach(script.steps[0].route) : opened;
3522
+ if (!onScreen(script.steps[0].route, starting)) return fail(`once the steps before it were done, the browser is on ${starting}, not ${script.steps[0].route}`);
3523
+ const before = await judge(starting);
3430
3524
  if (before) return refuse(before);
3431
3525
  let resting = START;
3432
3526
  let poster;
@@ -3453,26 +3547,17 @@ async function recordClip(options, script, touched) {
3453
3547
  if (!onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3454
3548
  const arrived = await judge(where);
3455
3549
  if (arrived) return refuse(arrived);
3456
- const measure = async () => resolveStep(
3457
- buildLayoutManifest(await layout(cover), {
3458
- shotId: script.id,
3459
- route: step.route,
3460
- path: where.split("?")[0],
3461
- variant: "desktop",
3462
- image: { ...CLIP_VIEWPORT },
3463
- origin: new URL(options.baseUrl).origin
3464
- }),
3465
- step
3466
- );
3467
- let resolution = await measure();
3550
+ let resolution = await measureOn(step, where);
3468
3551
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3469
3552
  await cursor(resting);
3470
3553
  await film();
3471
- resolution = await measure();
3554
+ resolution = await measureOn(step, where);
3472
3555
  }
3473
3556
  if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
3474
3557
  const target = centreOf(resolution.control.box);
3475
3558
  const startedAt = at;
3559
+ const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3560
+ if (action === "fill" && !step.value) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
3476
3561
  for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
3477
3562
  await cursor(point);
3478
3563
  await shoot(FRAME_MS);
@@ -3482,14 +3567,15 @@ async function recordClip(options, script, touched) {
3482
3567
  poster ??= frames[frames.length - 1].file;
3483
3568
  await cursor(target, true);
3484
3569
  await shoot(PRESS_MS);
3485
- if (step.action === "fill") {
3570
+ if (action === "fill") {
3571
+ const value = step.value ?? "";
3486
3572
  await pointer({ kind: "focus", at: target });
3487
3573
  await cursor(target, false);
3488
- for (const chunk of chunksOf(step.value ?? "", CHARS_PER_FRAME)) {
3574
+ for (const chunk of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
3489
3575
  await type(chunk);
3490
3576
  await shoot(FRAME_MS);
3491
3577
  }
3492
- } else if (step.action === "select") {
3578
+ } else if (action === "select") {
3493
3579
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3494
3580
  } else {
3495
3581
  await pointer({ kind: "click", at: target });
@@ -3507,9 +3593,9 @@ async function recordClip(options, script, touched) {
3507
3593
  if (after) return refuse(after);
3508
3594
  steps.push({
3509
3595
  route: step.route,
3510
- role: step.control.role,
3596
+ role: resolution.control.role,
3511
3597
  name: resolution.control.name,
3512
- action: step.action,
3598
+ action,
3513
3599
  says: step.says,
3514
3600
  atMs: startedAt,
3515
3601
  box: resolution.control.box
@@ -3559,7 +3645,7 @@ async function recordClip(options, script, touched) {
3559
3645
  await rm(dir, { recursive: true, force: true }).catch(() => void 0);
3560
3646
  }
3561
3647
  }
3562
- var CLIP_BUDGET_MS = 10 * 6e4;
3648
+ var CLIP_BUDGET_MS = 20 * 6e4;
3563
3649
  async function runClips(options) {
3564
3650
  const started = Date.now();
3565
3651
  const outcomes = [];
@@ -4767,6 +4853,28 @@ async function writeLocalClip(dir, clip) {
4767
4853
  durationMs: clip.recording.durationMs
4768
4854
  };
4769
4855
  }
4856
+ function articleClipsToRecord(raw, examples) {
4857
+ if (!Array.isArray(raw)) return readClipScripts(raw, CLIP_MAX_ARTICLE_SCRIPTS);
4858
+ const named = examples ?? { paths: /* @__PURE__ */ new Map(), refused: [] };
4859
+ const unopened = [];
4860
+ const opened = raw.map((value) => {
4861
+ const script = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
4862
+ const visit = typeof script["visit"] === "string" ? script["visit"] : "";
4863
+ const resolved = resolveVisit(visit, named);
4864
+ if (!("needs" in resolved)) return { ...script, visit: resolved.visit };
4865
+ unopened.push({ id: typeof script["id"] === "string" ? script["id"] : "a clip", because: describeMissingExample(visit, resolved.needs) });
4866
+ return void 0;
4867
+ });
4868
+ const read = readClipScripts(
4869
+ opened.filter((value) => value !== void 0),
4870
+ CLIP_MAX_ARTICLE_SCRIPTS
4871
+ );
4872
+ return { scripts: read.scripts, refused: [...unopened, ...read.refused] };
4873
+ }
4874
+ function clipsToRecord(seeded, articles) {
4875
+ const named = new Set(seeded.map((script) => script.id));
4876
+ return [...seeded, ...articles.filter((script) => !named.has(script.id))];
4877
+ }
4770
4878
  function clipForm(clip) {
4771
4879
  const id = clip.recording.clipId;
4772
4880
  const form = new FormData();
@@ -4784,9 +4892,18 @@ async function clipsOnDisk(dir) {
4784
4892
  return /* @__PURE__ */ new Map();
4785
4893
  }
4786
4894
  }
4895
+ function unrecordedLines(refused, skipped) {
4896
+ const lines = [
4897
+ ...skipped.map((piece) => ` ${piece.slug ? `${piece.slug}${piece.at ? ` at ${piece.at}` : ""}` : "The help desk"}: ${piece.because}`),
4898
+ ...refused.map((entry) => ` ${entry.id}: ${entry.because}`)
4899
+ ];
4900
+ if (!lines.length) return [];
4901
+ const shown = lines.slice(0, 12);
4902
+ return [`${lines.length} piece(s) of the articles' steps are not recorded:`, ...shown, ...lines.length > shown.length ? [` and ${lines.length - shown.length} more.`] : []];
4903
+ }
4787
4904
  async function recordSeededClips(args) {
4788
4905
  if (!args.scripts.length) {
4789
- console.log("\n--clips was given, but the seed command named no clips, so none were recorded.");
4906
+ console.log("\n--clips was given, but the seed command named no clips and no article asks for one, so none were recorded.");
4790
4907
  return [];
4791
4908
  }
4792
4909
  if (!await ffmpegAvailable()) {
@@ -4799,9 +4916,12 @@ async function recordSeededClips(args) {
4799
4916
  );
4800
4917
  const previous = args.local ? await clipsOnDisk(args.local) : /* @__PURE__ */ new Map();
4801
4918
  const written = [];
4802
- console.log(`
4803
- The seed command named ${args.scripts.length} clip(s). Each is recorded in the demo tenant it built when it is new or something it shows has changed.
4804
- `);
4919
+ const asked = args.scripts.length - args.named;
4920
+ console.log(
4921
+ `
4922
+ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how-to and troubleshooting articles ask for ${asked} more, one for each piece of their steps` : ""}. Each is recorded in the demo tenant the seed built when it is new or something it shows has changed.
4923
+ `
4924
+ );
4805
4925
  const outcomes = await runClips({
4806
4926
  baseUrl: args.baseUrl,
4807
4927
  browser: args.browser,
@@ -5142,8 +5262,19 @@ ${unclassified.join("\n")}`);
5142
5262
  Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
5143
5263
  }
5144
5264
  if (parsed.clips) {
5265
+ const named = seeded?.clips ?? [];
5266
+ const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
5267
+ const asked = Array.isArray(orders.clipScripts) ? orders.clipScripts.length : 0;
5268
+ if (!seeded && asked) {
5269
+ console.log(`
5270
+ The help desk's articles ask for ${asked} clip(s), and none is recorded: clips are recorded only in a demo tenant a --seed-command built.`);
5271
+ }
5272
+ const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
5273
+ if (unrecorded.length) console.log(`
5274
+ ${unrecorded.join("\n")}`);
5145
5275
  await recordSeededClips({
5146
- scripts: seeded?.clips ?? [],
5276
+ scripts: clipsToRecord(named, articles.scripts),
5277
+ named: named.length,
5147
5278
  browser,
5148
5279
  baseUrl,
5149
5280
  clearance,
@@ -5307,13 +5438,16 @@ if (runningAsCommand(import.meta.url)) {
5307
5438
  );
5308
5439
  }
5309
5440
  export {
5441
+ articleClipsToRecord,
5310
5442
  clipForm,
5443
+ clipsToRecord,
5311
5444
  frameForm,
5312
5445
  framesOnDisk,
5313
5446
  main,
5314
5447
  onePerScreen,
5315
5448
  seededLast,
5316
5449
  shotsFromRepo,
5450
+ unrecordedLines,
5317
5451
  withExamples,
5318
5452
  withPhoneShots,
5319
5453
  writeLocalClip,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {