@graphty/visual-review 0.1.3 → 0.2.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/README.md CHANGED
@@ -43,8 +43,9 @@ your login.
43
43
  4. **Gate (CI).** The "Visual gate" job fails while a pull request holds a difference nobody
44
44
  accepted, or a baseline file changed without a review record naming it.
45
45
 
46
- A story with no baseline yet does not block anything until a pull request changes it, so you can
47
- seed baselines a few stories at a time.
46
+ Every story needs a baseline you approved before a pull request can merge. A story with no
47
+ baseline blocks every pull request until you accept it, either on a pull request or by seeding it
48
+ from the default branch, so seed a project's baselines before its stories start blocking work.
48
49
 
49
50
  ## Requirements
50
51
 
@@ -146,8 +147,10 @@ Per project:
146
147
  | `seedFromDefaultBranch` | `true` | `false`: the project's first baselines are accepted on a pull request, not seeded from the default branch |
147
148
  | `waitFor` | none | After a story renders, call `method()` on every element matching `selector` and wait for the promise it returns, for a component that keeps drawing after Storybook says it is done. A console line containing `failOnConsole` fails the story |
148
149
 
149
- Project ids are letters, digits, `.`, `_` and `-`. The pull request gate reads the config as it is
150
- on the base branch, so a pull request cannot move `baselines` out from under it.
150
+ Project ids are letters, digits, `.`, `_` and `-`. Every project is gated; there is no setting
151
+ that turns the gate off, and a config that sets `gate` is refused. The pull request gate reads the
152
+ config as it is on the base branch, so a pull request cannot move `baselines` out from under it or
153
+ drop a project from the gate; a project that a pull request adds to its own config is gated too.
151
154
 
152
155
  ## The GitHub Actions workflows
153
156
 
@@ -180,8 +183,10 @@ Both need nothing but the default `GITHUB_TOKEN`: the capture job reads Actions
180
183
 
181
184
  ## Your first review: seeding baselines
182
185
 
183
- A project has no baselines until you accept some, and until it has one the gate ignores it. After
184
- the setup pull request merges, the default branch's push runs the capture:
186
+ A project has no baselines until you accept some, and the gate fails closed until it has them:
187
+ every pull request is blocked by each of its stories (`new` or `no baseline yet`), with a message
188
+ saying to seed the project. After the setup pull request merges, the default branch's push runs
189
+ the capture:
185
190
 
186
191
  1. Find that run's id: `gh run list --workflow visual-review.yml --branch main --limit 1`.
187
192
  2. Start the page with `--master-run <run id>` ([Opening the review page](#opening-the-review-page))
@@ -311,9 +316,10 @@ story is new or looks different from the default branch's newest capture of it),
311
316
  story no longer exists, lost a mode, or whose story's own parameters now exclude it), `unstable`
312
317
  (two captures of the same commit differed), `failed` (did not render, even after one retry).
313
318
 
314
- `no baseline yet` items are listed under their own filter in the grid and never need a decision:
315
- they do not block the pull request, Accept all skips them, and the story screen offers no buttons
316
- for them. Seed them from the default branch (below), or accept them on the pull request that changes them.
319
+ `no baseline yet` items block the pull request like `new` ones: they count as needing a decision,
320
+ Accept all includes them, and the story screen offers Accept, Reject and Exclude for them. Accepting
321
+ one makes its capture the story's first baseline. The grid also lists them under their own filter.
322
+ Seed them from the default branch (below), or accept them on the pull request.
317
323
 
318
324
  ## Keys
319
325
 
@@ -424,15 +430,23 @@ names the captured commit, so Finish's seed branch starts from that commit.
424
430
  A story with no baseline on the default branch is in the "no baseline yet" state. On every pull
425
431
  request, CI compares its capture with the default branch's newest capture of that story:
426
432
 
427
- - **The pull request does not change it:** `no baseline yet` (`unseeded`). It is shown, it does
428
- not block the pull request, and it is never accepted by Accept all.
433
+ - **The pull request does not change it:** `no baseline yet` (`unseeded`).
429
434
  - **The pull request adds the story, or changes how it looks** (for example an agent fixing a
430
- story you rejected): `new`. It blocks that pull request until you decide. Review it there;
431
- accepting it creates its first baseline in that pull request's accept commit.
432
-
433
- So seeding never restarts from scratch: each round accepts what now looks right, and the rest
434
- waits, blocking nothing, until a pull request touches it. A project enters the merge gate when its
435
- first baseline lands on the default branch; before that the gate ignores it entirely.
435
+ story you rejected): `new`.
436
+
437
+ Both block the pull request until you decide. Review them there; accepting one creates its first
438
+ baseline in that pull request's accept commit. Seeding never restarts from scratch: each round
439
+ accepts what now looks right, and every story still without a baseline keeps blocking pull
440
+ requests until it is accepted or seeded.
441
+
442
+ The same holds for a project with no baselines at all: the gate fails closed. Every project in the
443
+ config is gated from the start, so every pull request is blocked by the stories of an unseeded
444
+ project, and the gate's message says how to unblock it: seed the project (capture a known-good
445
+ commit with `visual-seed.yml`, or take the default branch's newest run, review it with
446
+ `serve --master-run <run id>`, merge the seed pull request, then merge the default branch into the
447
+ blocked one), or accept the items on that pull request. A project with
448
+ `"seedFromDefaultBranch": false` is told to accept them on the pull request. Seed only from a commit
449
+ whose images a person already reviewed.
436
450
 
437
451
  If the default branch's capture could not be downloaded (its artifacts expired, or no run there has
438
452
  finished one), every story without a baseline is `new` on that pull request. Re-run its `visual` job
@@ -543,14 +557,13 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
543
557
 
544
558
  ## What the gate does and does not guarantee
545
559
 
546
- - A pull request cannot pass the gate while its capture of a seeded project holds anything but
547
- `unchanged`, `excluded` or `no baseline yet` items, including after "Re-run failed jobs" (the
560
+ - A pull request cannot pass the gate while its capture of a gated project holds anything but
561
+ `unchanged` or `excluded` items, including after "Re-run failed jobs" (the
548
562
  highest attempt's artifact counts); a missing, unfinished or invalid capture blocks it too. A
549
563
  rejected item stays blocking until a code change makes it match the baseline.
550
- - `no baseline yet` rests on the default branch's capture being honest and recent: a story is
551
- `new` (blocking) only when it looks different from the default branch's newest complete capture
552
- of it. That capture may be a few merges older than the pull request's base; a story changed in
553
- between shows as `new`, which blocks rather than passes.
564
+ - A story with no baseline always blocks. `new` and `no baseline yet` only tell the reviewer
565
+ whether the pull request changed it, measured against the default branch's newest complete
566
+ capture, which may be a few merges older than the pull request's base.
554
567
  - Every baseline PNG, and every settings file that excludes a story, that the pull request adds,
555
568
  changes or deletes must be named with its new hash in a review record the pull request adds
556
569
  under `<baselines>/reviews/`. A baseline PNG is a Git LFS pointer in git, and the gate reads the
@@ -565,8 +578,9 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
565
578
  anything running as you (an AI coding agent included) has your GitHub login and signing key and
566
579
  could press Accept or call the page's API. Nothing technical prevents that today; tell your
567
580
  agents not to, and keep the review to yourself.
568
- - The projects the gate checks are the ones with baselines on the base branch, so removing a
569
- project from the config does not remove it from the gate.
581
+ - The projects the gate checks are every project in the base branch's config and in the pull
582
+ request's config, seeded or not, plus every project with baselines on the base branch. So
583
+ removing a project from the config does not remove it from the gate.
570
584
  - The gate is part of a workflow file, which a pull request can edit, and a pull request can
571
585
  loosen a story's own `diffThreshold` or `delay`, or a settings file's non-excluding keys,
572
586
  without a review item. Read changes to those in code review.
@@ -506,7 +506,10 @@ async function loadReference(dir) {
506
506
  if (!results || validateResults(results).length > 0 || !results.complete) {
507
507
  return { images, runId: null };
508
508
  }
509
- for (const item of results.items.filter((i) => i.status === "new")) {
509
+ // Both statuses mean "captured with no baseline, hash recorded". Reading only `new` made a story
510
+ // alternate: a run that matched its reference says `unseeded`, so the next run found no image
511
+ // for it and said `new` again.
512
+ for (const item of results.items.filter((i) => i.status === "new" || i.status === "unseeded")) {
510
513
  const bytes = await readFile(join(dir, item.file)).catch(() => null);
511
514
  if (bytes && sha256(bytes) === item.capture) {
512
515
  images.set(item.file, bytes);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/visual-review",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
@@ -79,7 +79,7 @@ jobs:
79
79
  run: ${{ matrix.build }}
80
80
 
81
81
  # A story with no baseline that looks as it does in the default branch's newest capture is
82
- # "unseeded" and does not block this pull request; without a reference every one is "new".
82
+ # "unseeded"; without a reference every one is "new". Both block until accepted or seeded.
83
83
  - name: Download the default branch's newest capture
84
84
  id: reference
85
85
  if: github.event_name == 'pull_request'
@@ -115,8 +115,9 @@ jobs:
115
115
  fi
116
116
  } >> "$GITHUB_STEP_SUMMARY"
117
117
 
118
- # Fails while a project with baselines on the base branch holds anything but unchanged, excluded
119
- # or unseeded items in its newest capture, or a baseline file changed without a review record.
118
+ # Fails while a project of the base branch's or the pull request's config, or with baselines on
119
+ # the base branch (seeded or not), holds anything but unchanged or excluded items in its newest
120
+ # capture, or a baseline file changed without a review record.
120
121
  # It runs the published gate at a pinned version, so a pull request's own dependencies cannot
121
122
  # change it. Depth 2: HEAD^1, the merge commit's first parent, is the base branch tip.
122
123
  visual-gate:
package/trusted/gate.mjs CHANGED
@@ -1,20 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The pull request gate: fails while a project that has baselines on the base branch holds visual
4
- * changes the owner has not reviewed.
3
+ * The pull request gate: fails while a project holds visual changes the owner has not reviewed.
5
4
  *
6
- * Seeding is per story, so a seeded project can hold stories with no baseline yet. Those that the
7
- * pull request did not change are `unseeded` (capture compared them with master's newest capture)
8
- * and pass; a story the pull request adds or changes is `new` and blocks until the owner accepts
9
- * it there, which creates its first baseline.
5
+ * It fails closed: nothing merges with an image nobody approved. Every project with baselines on the
6
+ * base branch, and every project in the base branch's config or the pull request's, is gated, seeded
7
+ * or not. Every story needs an approved baseline. A story with none blocks: `new` when the pull
8
+ * request adds or changes it, `unseeded` when it looks as in master's newest capture. Either way
9
+ * the owner accepts it (on the pull request, or by seeding it from the default branch), which
10
+ * creates its first baseline.
10
11
  *
11
12
  * <dir> holds the downloaded `visual-<project>-<attempt>` artifacts of this CI run, every attempt
12
13
  * of it. For each project only the highest attempt counts, so re-running failed jobs (which
13
14
  * leaves the visual jobs' old attempt as the newest) can neither hide nor resurrect a capture.
14
15
  * Which projects exist and are seeded is read from <ref> (the base branch tip, fetched by the
15
- * caller), not from the pull request, and so is visual-review.config.json (where the baselines
16
- * live), so neither deleting a project's baselines nor moving the baselines directory in the pull
17
- * request turns the gate off. A seeded project with no results.json, or an incomplete one, fails: a capture that
16
+ * caller), and so is visual-review.config.json (the projects and where the baselines live), so
17
+ * neither deleting a project's baselines, nor removing it from the config, nor moving the baselines
18
+ * directory in the pull request turns the gate off. The pull request's config can only add projects. A gated project with no results.json, or an incomplete one, fails: a capture that
18
19
  * crashed has shown the owner nothing. An invalid results.json counts as missing.
19
20
  *
20
21
  * It also fails when a baseline PNG, or a settings file that excludes a story, differs from the
@@ -40,7 +41,7 @@ import { parseArgs } from "node:util";
40
41
  import { loadConfigAt, repoRoot } from "./lib/config.mjs";
41
42
  import { validateResults } from "./lib/results.mjs";
42
43
 
43
- const PASSING = new Set(["unchanged", "excluded", "unseeded"]);
44
+ const PASSING = new Set(["unchanged", "excluded"]);
44
45
 
45
46
  /**
46
47
  * The newest attempt's results.json of every project in a directory of downloaded artifacts.
@@ -67,19 +68,30 @@ export function newestResults(dir) {
67
68
  return out;
68
69
  }
69
70
 
71
+ /**
72
+ * The projects the gate checks: every project with baselines at the base, and every project of the
73
+ * base's config and of the pull request's.
74
+ * @param {{ projects: Record<string, object> }} config the base branch's config
75
+ * @param {Set<string>} seeded the projects with baselines at the base
76
+ * @param {{ projects: Record<string, object> }} [headConfig] the pull request's config
77
+ * @returns {string[]} the gated project ids
78
+ */
79
+ export function gatedProjects(config, seeded, headConfig) {
80
+ return [...new Set([...seeded, ...Object.keys(config.projects), ...Object.keys(headConfig?.projects ?? {})])];
81
+ }
82
+
70
83
  /**
71
84
  * What blocks the pull request.
72
- * @param {{ projects: string[], seeded: Set<string>, captures: Record<string, { attempt: number,
73
- * results: object | null }> }} input every captured project, those with baselines on the base
74
- * branch, and the newest capture of each
85
+ * @param {{ config: { defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> },
86
+ * headConfig: { projects: Record<string, object> } | undefined, seeded: Set<string>,
87
+ * captures: Record<string, { attempt: number, results: object | null }> }} input the base
88
+ * branch's config, the pull request's config (if any), the projects with baselines on the base
89
+ * branch, and the newest capture of each project
75
90
  * @returns {string[]} one line per blocked project; empty when the gate passes
76
91
  */
77
- export function gateProblems({ projects, seeded, captures }) {
92
+ export function gateProblems({ config, headConfig, seeded, captures }) {
78
93
  const problems = [];
79
- for (const p of projects) {
80
- if (!seeded.has(p)) {
81
- continue;
82
- }
94
+ for (const p of gatedProjects(config, seeded, headConfig)) {
83
95
  const r = captures[p]?.results;
84
96
  if (!r) {
85
97
  problems.push(`${p}: no capture results (the visual job failed or uploaded nothing); re-run it`);
@@ -101,15 +113,36 @@ export function gateProblems({ projects, seeded, captures }) {
101
113
  for (const s of open) {
102
114
  counts.set(s, (counts.get(s) ?? 0) + 1);
103
115
  }
104
- problems.push(
105
- `${p}: ${[...counts].map(([s, n]) => `${n} ${s}`).join(", ")} ` +
106
- "(not accepted; a rejected item needs a code change, not another review)",
107
- );
116
+ const what = [...counts].map(([s, n]) => `${n} ${s}`).join(", ");
117
+ problems.push(`${p}: ${what} ${seeded.has(p) ? NOT_ACCEPTED : notSeeded(config, p)}`);
108
118
  }
109
119
  }
110
120
  return problems;
111
121
  }
112
122
 
123
+ const NOT_ACCEPTED = "(not accepted; a rejected item needs a code change, not another review)";
124
+
125
+ /**
126
+ * Why an unseeded project blocks, and how to seed it.
127
+ * @param {{ defaultBranch: string, projects: Record<string, { seedFromDefaultBranch: boolean }> }} config the
128
+ * base branch's config
129
+ * @param {string} p the project
130
+ * @returns {string} the rest of the gate's line
131
+ */
132
+ function notSeeded(config, p) {
133
+ const branch = config.defaultBranch;
134
+ const here = "accept them on this pull request with `visual-review serve`";
135
+ if (config.projects[p]?.seedFromDefaultBranch === false) {
136
+ return `(not accepted; ${p} has no baselines on ${branch} yet, so ${here} to create its first ones)`;
137
+ }
138
+ return (
139
+ `(not accepted; ${p} has no baselines on ${branch} yet. Seed it: capture a known-good commit with ` +
140
+ `\`gh workflow run visual-seed.yml --ref ${branch} -f ref=<sha>\` (or take ${branch}'s newest run), ` +
141
+ `review that run with \`visual-review serve --master-run <run id>\` and merge the seed pull ` +
142
+ `request, then merge ${branch} into this branch; or ${here})`
143
+ );
144
+ }
145
+
113
146
  const gitOut = (cwd, args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 28 });
114
147
 
115
148
  /**
@@ -189,8 +222,7 @@ function parseOr(bytes) {
189
222
 
190
223
  /**
191
224
  * The projects with at least one baseline PNG at a git ref: the directories under the baselines
192
- * directory at the base tip, so a pull request cannot drop a project from the gate by editing
193
- * visual-review.config.json.
225
+ * directory at the base tip. They are gated whatever the config says.
194
226
  * @param {string} ref the base branch tip
195
227
  * @param {string} [cwd] the repository
196
228
  * @param {string} [baselines] the baselines directory
@@ -245,17 +277,14 @@ export function runGate(args) {
245
277
  return 2;
246
278
  }
247
279
  const root = repoRoot();
248
- const { baselines } = loadConfigAt(values.base, root);
249
- const seeded = seededAt(values.base, root, baselines);
250
- const problems = gateProblems({
251
- projects: [...seeded],
252
- seeded,
253
- captures: newestResults(values.captures),
254
- });
280
+ const config = loadConfigAt(values.base, root);
281
+ const seeded = seededAt(values.base, root, config.baselines);
282
+ const headConfig = loadConfigAt(values.head, root);
283
+ const problems = gateProblems({ config, headConfig, seeded, captures: newestResults(values.captures) });
255
284
  for (const line of problems) {
256
285
  console.log(`::error::visual changes not accepted -- ${line}`);
257
286
  }
258
- const unrecorded = unrecordedChanges(values.base, values.head, root, baselines);
287
+ const unrecorded = unrecordedChanges(values.base, values.head, root, config.baselines);
259
288
  for (const line of unrecorded) {
260
289
  console.log(`::error::baseline without a review -- ${line}`);
261
290
  }
@@ -26,7 +26,7 @@ import { isLfsPointer } from "./compare.mjs";
26
26
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
27
27
 
28
28
  /** The statuses an item can be accepted or rejected in; unstable and failed are only excluded. */
29
- const DECIDABLE = new Set(["changed", "moved", "new", "removed"]);
29
+ const DECIDABLE = new Set(["changed", "moved", "new", "unseeded", "removed"]);
30
30
  const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
31
31
 
32
32
  /**
@@ -90,6 +90,9 @@ export function normalizeConfig(input) {
90
90
  if (p.seedFromDefaultBranch !== undefined && typeof p.seedFromDefaultBranch !== "boolean") {
91
91
  fail(`${where}.seedFromDefaultBranch must be true or false`);
92
92
  }
93
+ if (p.gate !== undefined) {
94
+ fail(`${where}.gate is not a setting: every project is gated, and every story needs an approved baseline`);
95
+ }
93
96
  let waitFor = null;
94
97
  if (p.waitFor !== undefined && p.waitFor !== null) {
95
98
  const w = p.waitFor;
@@ -44,7 +44,7 @@ const HEADERS = {
44
44
  */
45
45
  const componentOf = (id) => id.split("--")[0];
46
46
 
47
- const REVIEWABLE = new Set(["changed", "moved", "new", "removed", "unstable", "failed"]);
47
+ const REVIEWABLE = new Set(["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"]);
48
48
  const WRITES = new Set(["decide", "accept-all", "finish"]);
49
49
 
50
50
  /**
@@ -11,9 +11,10 @@ const app = document.getElementById("app");
11
11
  const crumbs = document.getElementById("crumbs");
12
12
  const statusLine = document.getElementById("status");
13
13
 
14
- const REVIEWABLE = ["changed", "moved", "new", "removed", "unstable", "failed"];
15
- const ACCEPTABLE = ["changed", "moved", "new", "removed"];
16
- // A story with no baseline that this pull request did not change: shown, never a decision here.
14
+ const REVIEWABLE = ["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"];
15
+ const ACCEPTABLE = ["changed", "moved", "new", "unseeded", "removed"];
16
+ // A story with no baseline that this pull request did not change. It blocks like `new`: accepting it
17
+ // creates its first baseline.
17
18
  const UNSEEDED = "unseeded";
18
19
  const NO_BASELINE = "no baseline yet";
19
20
  const statusLabel = (status) => (status === UNSEEDED ? NO_BASELINE : status);
@@ -58,7 +59,7 @@ const state = {
58
59
  };
59
60
  const running = () => state.job?.running === true;
60
61
  const VIEWS = ["side", "flash", "highlight", "spotlight"];
61
- const FILTERS = ["undecided", "all", ...REVIEWABLE, UNSEEDED, ...Object.keys(DECISIONS)];
62
+ const FILTERS = ["undecided", "all", ...REVIEWABLE, ...Object.keys(DECISIONS)];
62
63
  let routing = false; // true while the page follows the address (a link opened, Back, Forward)
63
64
  const images = new Map();
64
65
  const diffs = new Map();
@@ -182,18 +183,13 @@ function ordered(items) {
182
183
 
183
184
  function visibleItems() {
184
185
  const text = state.text.trim().toLowerCase();
185
- let items;
186
- if (state.filter === UNSEEDED) {
187
- items = state.data.items.filter((i) => i.status === UNSEEDED);
188
- } else {
189
- items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
190
- if (state.filter === "undecided") {
191
- items = items.filter((i) => !decisionOf(i));
192
- } else if (Object.hasOwn(DECISIONS, state.filter)) {
193
- items = items.filter((i) => decisionOf(i)?.decision === state.filter);
194
- } else if (state.filter !== "all") {
195
- items = items.filter((i) => i.status === state.filter);
196
- }
186
+ let items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
187
+ if (state.filter === "undecided") {
188
+ items = items.filter((i) => !decisionOf(i));
189
+ } else if (Object.hasOwn(DECISIONS, state.filter)) {
190
+ items = items.filter((i) => decisionOf(i)?.decision === state.filter);
191
+ } else if (state.filter !== "all") {
192
+ items = items.filter((i) => i.status === state.filter);
197
193
  }
198
194
  return ordered(text ? items.filter((i) => i.file.includes(text)) : items);
199
195
  }
@@ -384,7 +380,7 @@ function targetCard(t) {
384
380
  el(
385
381
  "td",
386
382
  {},
387
- p.reviewable > 0 || p.counts[UNSEEDED]
383
+ p.reviewable > 0
388
384
  ? el("button", { type: "button", onclick: () => openProject(t, p.project) }, "Review")
389
385
  : null,
390
386
  ),
@@ -864,7 +860,7 @@ function showStory() {
864
860
  const view = note ? "side" : state.view;
865
861
  const onlyExclude = item.status === "unstable" || item.status === "failed";
866
862
  const unseeded = item.status === UNSEEDED;
867
- const decidable = !isLocal() && !unseeded;
863
+ const decidable = !isLocal();
868
864
  setCrumbs(
869
865
  el("button", { type: "button", class: "link", onclick: showGrid }, `${targetLabel()} / ${state.project}`),
870
866
  el("span", {}, itemName(item)),
@@ -1069,7 +1065,7 @@ function showStory() {
1069
1065
  "p",
1070
1066
  {},
1071
1067
  "No baseline yet, and this pull request does not change it: it looks as on master. " +
1072
- "Seed it from master's capture, or accept it on the pull request that changes it.",
1068
+ "Accepting it makes this image its first baseline.",
1073
1069
  )
1074
1070
  : null,
1075
1071
  decidable ? decisionButtons : null,