@wix/himalaya-cli 0.859.0 → 0.861.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.
@@ -315,11 +315,30 @@ the relations held.
315
315
 
316
316
  - **Measured, not asserted.** A committed kill-rate benchmark records what fraction of planted,
317
317
  realistic reducer bugs the crawl actually catches, per bug class, and every new check has to move
318
- that number. Today it is **13.6%** (11 of 81 planted bugs across 25 content dirs), and the table
319
- is as useful for what it misses as for what it catches: bugs that produce an *implausible* value
320
- — a crash, a stuck skeleton, a `NaN` — are caught, while bugs that produce a *plausible wrong
321
- value*, such as reading the wrong key off a response or a total off by one, are caught 0 of 34
322
- times. A green run means no invariant fired, not that the app is correct.
318
+ that number. It is **31 of 269 = 11.5%** across all 48 content dirs, and the table is as useful
319
+ for what it misses as for what it catches. Most of the checks judge a *single settled state* and
320
+ look there for an *implausible* value — a crash, a stuck skeleton, a `NaN` — so a bug that
321
+ produces a plausible wrong one walks straight past them: a total off by one is 0/32, the wrong
322
+ row dropped 0/27. What reaches a plausible wrong value is the smaller set of checks that compare
323
+ two states instead of judging one — the relational axis (an action against the action that
324
+ undoes it, a refresh against a fresh boot) and `typed-value-ignored`, which fires when a dispatch
325
+ carrying a real payload changes nothing at all. That last one is what moved `wrong-key` to 12/48,
326
+ by typing the value a UI control actually carries so the half of each reducer that reads its
327
+ payload finally runs. A green run means no invariant fired, not that the app is correct.
328
+
329
+ The benchmark also carries a `control-throw` row at 48/48, and it is **excluded from the rate on
330
+ purpose**: a planted `throw` is caught by construction, so counting it would inflate the figure
331
+ with a class that can never move. It is the harness checking itself — anything below 100% there
332
+ means the run stopped dispatching and no other number in the table should be believed. Read it as
333
+ the harness's self-test — never as evidence that the crawl catches crashes in your app.
334
+
335
+ Two things about that number are worth knowing, because both were learned the hard way. It was
336
+ published as 13.6% (11 of 81) until that denominator turned out to omit a whole bug class, and it
337
+ was measured over 25 sample apps until the starter templates `himi init --template` ships — the
338
+ 23 a customer actually adopts, and 97% of the benchmark's worker *files* (90% of its code) —
339
+ turned out never to have been in it at all. Measured over the code customers ship, the crawl is roughly half as effective
340
+ as the sample apps suggested. This page carries the current figure; treat one quoted anywhere
341
+ else, including in a dated release note, as the number on the day it was written.
323
342
  - **Zero false positives, corpus-proven.** Held across every sweep of the 40+ app corpus —
324
343
  except `poll-action-missing`, which ships at `error` without corpus evidence behind it
325
344
  (see the exception noted under "What it checks" above).
@@ -115,8 +115,10 @@ errors block readiness; record tool-coverage limits separately.
115
115
 
116
116
  ## Evidence
117
117
 
118
- - Render compact (320/360), phone, and expanded widths. Record whether a one-column expanded
119
- layout is intentional or the screen adapts.
118
+ - Render each screen at a phone width, at the narrowest width you support, and again at a width
119
+ past the 600px breakpoint. `himi render <screen> --width 320` then `--width 834` writes both
120
+ into `.himi/ux-audit.json`, which is what this marker grades. Record whether a one-column
121
+ layout at the wider size is intentional or the screen adapts.
120
122
  - Exercise typed input, submit, completion, cancel/back, and any destructive action; record
121
123
  confirmation, undo, or recovery.
122
124
  - Drive iOS back/cancel, keyboard submit, Dynamic Type, and VoiceOver; drive Android system Back,
@@ -143,8 +145,11 @@ itself the evidence — see the strict audit contract.
143
145
 
144
146
  **Two commands read this contract.** `himi validate` reports a gap as a **warning**, so a rewrite
145
147
  that drops something the gate needs is caught in the loop you are already running rather than at
146
- the end of it. `himi validate --strict` and `himi ux audit --strict` both promote that warning to a
147
- **release blocker**. Both read the same markers. The two HEADINGS are matched verbatim — other
148
+ the end of it. `himi ux audit --strict` promotes every gap to a **release blocker**.
149
+ `himi validate --strict` promotes the structural ones — the two headings and the one-CTA rule — but
150
+ deliberately leaves **width evidence** a warning: a package that has not rendered yet would
151
+ otherwise fail strict validation the moment it is scaffolded, so that gap is the evidence gate's to
152
+ refuse. Both read the same markers. The two HEADINGS are matched verbatim — other
148
153
  tooling addresses the document by them, so a heading really is the thing itself:
149
154
 
150
155
  - The heading `## Implementation acceptance`.
@@ -229,8 +234,11 @@ tried in this order:
229
234
  icon-834.png` pass. The marker is permissive there on purpose. A gate that rejects real work
230
235
  costs build loops; a permissive one costs a line of prose, and a card or drawer width always
231
236
  passed anyway.
232
- - Or keep the scaffold's own sentence, `compact (320/360)` followed later by `expanded`, which is
233
- still accepted as-is. The two **may wrap across lines** — the check is not line-based — but
237
+ - Or, in a package scaffolded before #2590, keep the legacy scaffold sentence,
238
+ `compact (320/360)` followed later by `expanded`, which is still accepted as-is. The template
239
+ stopped shipping that sentence in #2590 — it satisfied this marker on a package that had
240
+ rendered nothing — so a newer scaffold does not carry it, and adding it by hand claims evidence
241
+ you may not have. The two **may wrap across lines** — the check is not line-based — but
234
242
  `expanded` must follow within about 400 characters, so keep them in the same sentence or bullet
235
243
  rather than in separate sections.
236
244
 
@@ -23,6 +23,11 @@ Source of truth: `apps/himi-owner-lite/config.ts`. A management app that rendere
23
23
  knowing *which owner* is looking at it would be showing someone else's business, which is why
24
24
  the gate fails closed rather than falling back to a visitor.
25
25
 
26
+ The Sites List is the Tier-5 `himi-owner-lite-sites` worker. Its `pick` action calls
27
+ `site.setActiveSite(msid)` and waits for the site-scoped signed instance before the selected
28
+ business is used by subsequent management screens. The worker also clears the Stores catalog
29
+ version around the switch so a catalog resolved for one business is not reused for another.
30
+
26
31
  ## What owner-lite *does* read
27
32
 
28
33
  Verified against `apps/himi-owner-lite/config.ts` — if a variable is not in this table, owner-lite
@@ -172,12 +172,19 @@ areas, always in the same order, so you can skim for the one you care about.
172
172
  the behavioural crawl over any error-level lint issue, and skipped typed journeys entirely while
173
173
  still reporting a pass. (#2392)
174
174
  - **The `himi test` reference now states what a green run does and does not prove.** A benchmark
175
- plants realistic reducer bugs and measures the fraction the crawl catches, per bug class — 13.6%
176
- today. The useful half is where it is zero: bugs that produce an *implausible* value (a crash, a
177
- stuck skeleton, a `NaN`) are caught, while bugs that produce a *plausible wrong value*, such as
178
- reading the wrong key off a response or a total off by one, are caught 0 of 34 times. A green run
179
- means no invariant fired, not that your app is correct. The figure comes from that benchmark, not
180
- from your own runs — `himi test` output is unchanged. (#2402)
175
+ plants realistic reducer bugs and measures the fraction the crawl catches, per bug class. The
176
+ useful half is where it is low: bugs that produce an *implausible* value (a crash, a stuck
177
+ skeleton, a `NaN`) are caught, while bugs that produce a *plausible wrong value* — a total off by
178
+ one, the wrong row dropped — largely are not. A green run means no invariant fired, not that your
179
+ app is correct. The figure comes from that benchmark, not from your own runs — `himi test` output
180
+ is unchanged. (#2402)
181
+
182
+ This entry no longer quotes the rate, and that is deliberate. It said "13.6% today", a figure
183
+ corrected shortly afterwards for omitting a whole bug class and moved several times since by
184
+ changes to the corpus and the oracles — so a reader calibrating how far to trust a green run was
185
+ reading a number that was both wrong and getting wronger. A dated note cannot stay right about a
186
+ live measurement. `himi docs himi-test` carries the current figure, how it was measured, and the
187
+ corrections behind it.
181
188
  - **`himi validate` no longer answers `ok: true` after a CMS check that never ran.** An unloadable
182
189
  `config.ts` was swallowed, the CMS pass returned nothing, and validation reported success. (#2389)
183
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.859.0",
3
+ "version": "0.861.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -63,5 +63,5 @@
63
63
  "artifactId": "himalaya-cli"
64
64
  }
65
65
  },
66
- "falconPackageHash": "d7b917e8cde66a4d5863631ca5f5de90316a307fb73e854c63310248"
66
+ "falconPackageHash": "2d77c6f215930baecdc2a1637d6fb8dd37e4a6ba32421c3a31e94a16"
67
67
  }
@@ -181,11 +181,22 @@ function subjectLabelOf(m: SemanticModelSummary): string {
181
181
  * renders as a dash rather than as 0. `formatNumber` and not `toLocaleString`,
182
182
  * which is ECMA-402 and absent on QuickJS-ng (watchOS/Wear OS).
183
183
  */
184
+ /**
185
+ * The one placeholder a cell falls back to when there is nothing to render.
186
+ *
187
+ * Named, because two things depend on it meaning exactly one thing: `cellText`
188
+ * produces it, and `runQuery` asks whether the server's total came back as it —
189
+ * which is how "the owner asked for a total and got a dash" is told apart from
190
+ * "the server answered". Spelling it twice let those two drift apart once
191
+ * already.
192
+ */
193
+ const DASH = "\u2014";
194
+
184
195
  function cellText(v: SemanticValue | undefined): string {
185
- if (!v) return "—";
196
+ if (!v) return DASH;
186
197
  if (v.formatted !== undefined && v.formatted !== "") return v.formatted;
187
198
  const raw = v.value;
188
- if (raw === null || raw === undefined) return "—";
199
+ if (raw === null || raw === undefined) return DASH;
189
200
  if (typeof raw === "number") return formatNumber(raw);
190
201
  if (typeof raw === "boolean") return raw ? "Yes" : "No";
191
202
  if (typeof raw === "string") return raw;
@@ -387,6 +398,23 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
387
398
  ? copyParam("ownerReport.missingFields", "No data returned for: ") + result.missingFields.join(", ")
388
399
  : "";
389
400
 
401
+ // Did the SERVER actually answer with a total for the measure asked for?
402
+ // This is NOT the same question as "is a totals row on screen": the row is
403
+ // rendered as a dash when the owner asked and nothing came back, and a dash
404
+ // is a placeholder, not an answer. Keeping the two apart is what stops a
405
+ // placeholder from counting as content — see `hideTable`/`noEmpty` below.
406
+ //
407
+ // A zero is unaffected: `cellText` formats it as "0", not as the dash, and a
408
+ // zero total is a real answer that must keep the table up.
409
+ // Asked of `cellText` rather than re-derived: a `SemanticValue` is an OBJECT
410
+ // even when it carries `value: null`, so a nullish check on the entry itself
411
+ // calls a valueless total an answer. `cellText` already owns the decision of
412
+ // what counts as showable, down to `formatted: ""`, so the question is simply
413
+ // whether it produced the placeholder.
414
+ const serverTotal = result.totals?.[s.measureName];
415
+ const totalText = cellText(serverTotal);
416
+ const answeredTotal = totalText !== DASH;
417
+
390
418
  // `noTotals` is the owner's REQUEST and stays theirs; only they change it.
391
419
  // When they asked and the server sent no total, that is reported as a dash —
392
420
  // an absent number the owner asked for must be visible, not silently dropped
@@ -395,9 +423,12 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
395
423
  ? null
396
424
  : {
397
425
  dimension: copyParam("ownerReport.totalsLabel", "Total"),
398
- measure: result.totals ? cellText(result.totals[s.measureName]) : "—",
426
+ measure: totalText,
399
427
  };
400
428
 
429
+ // Is there anything to READ? Rows, or a total the server actually sent.
430
+ const hasContent = rows.length > 0 || answeredTotal;
431
+
401
432
  const out: ReportState = {
402
433
  ...s,
403
434
  status: "ready",
@@ -405,16 +436,22 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
405
436
  + `${s.measureLabel} ${copyParam("ownerReport.by", "by")} ${s.dimensionLabel}`,
406
437
  rows,
407
438
  totals,
408
- rowCount: rows.length > 0
409
- ? `${result.paging.count || rows.length} ${copyParam("ownerReport.rows", "rows")}`
410
- : "",
439
+ // A zero count is stated, not left blank. Silence beside a caption reads
440
+ // as "still loading" — the ambiguity this screen removes everywhere else —
441
+ // so "0 rows" says the query ran and came back with nothing.
442
+ rowCount: `${rows.length > 0 ? result.paging.count || rows.length : 0} ${copyParam("ownerReport.rows", "rows")}`,
411
443
  missingFields: gap,
412
444
  noGap: gap === "",
413
- // Rows OR totals. The API can answer `results: []` WITH a `totals` object
414
- // — the SDK has a test for exactly that shape — and gating on rows alone
415
- // hid the server's total and put "Nothing to show" over it. Opting into
416
- // Show total would then have thrown away the only answer that came back.
417
- hideTable: rows.length === 0 && !totals,
445
+ // Rows OR a total the SERVER sent. The API can answer `results: []` WITH a
446
+ // `totals` object — the SDK has a test for exactly that shape — and gating
447
+ // on rows alone hid the server's total and put "Nothing to show" over it.
448
+ //
449
+ // But it must be `answeredTotal`, not `totals`: `totals` is non-null for
450
+ // the whole time the owner has Show total on, INCLUDING when the answer is
451
+ // the "—" placeholder. Gating on it meant an empty result with the toggle
452
+ // on rendered a table whose only row was `Total —`, and the empty state
453
+ // could never fire while the toggle was on. Two states each correct alone.
454
+ hideTable: !hasContent,
418
455
  notLoading: true,
419
456
  notErrored: true,
420
457
  hideContent: false,
@@ -423,10 +460,10 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
423
460
  // It is NOT conflated with a dropped field: the SDK reports no
424
461
  // `missingFields` when nothing came back at all, precisely because the two
425
462
  // are indistinguishable there, so this arm never accuses a field.
426
- noEmpty: rows.length > 0 || totals !== null,
427
- emptyText: rows.length === 0 && !totals
428
- ? copyParam("ownerReport.empty", "No data for this range.")
429
- : "",
463
+ noEmpty: hasContent,
464
+ emptyText: hasContent
465
+ ? ""
466
+ : copyParam("ownerReport.empty", "No data for this range."),
430
467
  // Carried through untouched: see the totals comment above.
431
468
  noTotals: s.noTotals,
432
469
  error: "",
@@ -181,11 +181,22 @@ function subjectLabelOf(m: SemanticModelSummary): string {
181
181
  * renders as a dash rather than as 0. `formatNumber` and not `toLocaleString`,
182
182
  * which is ECMA-402 and absent on QuickJS-ng (watchOS/Wear OS).
183
183
  */
184
+ /**
185
+ * The one placeholder a cell falls back to when there is nothing to render.
186
+ *
187
+ * Named, because two things depend on it meaning exactly one thing: `cellText`
188
+ * produces it, and `runQuery` asks whether the server's total came back as it —
189
+ * which is how "the owner asked for a total and got a dash" is told apart from
190
+ * "the server answered". Spelling it twice let those two drift apart once
191
+ * already.
192
+ */
193
+ const DASH = "\u2014";
194
+
184
195
  function cellText(v: SemanticValue | undefined): string {
185
- if (!v) return "—";
196
+ if (!v) return DASH;
186
197
  if (v.formatted !== undefined && v.formatted !== "") return v.formatted;
187
198
  const raw = v.value;
188
- if (raw === null || raw === undefined) return "—";
199
+ if (raw === null || raw === undefined) return DASH;
189
200
  if (typeof raw === "number") return formatNumber(raw);
190
201
  if (typeof raw === "boolean") return raw ? "Yes" : "No";
191
202
  if (typeof raw === "string") return raw;
@@ -387,6 +398,23 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
387
398
  ? copyParam("ownerReport.missingFields", "No data returned for: ") + result.missingFields.join(", ")
388
399
  : "";
389
400
 
401
+ // Did the SERVER actually answer with a total for the measure asked for?
402
+ // This is NOT the same question as "is a totals row on screen": the row is
403
+ // rendered as a dash when the owner asked and nothing came back, and a dash
404
+ // is a placeholder, not an answer. Keeping the two apart is what stops a
405
+ // placeholder from counting as content — see `hideTable`/`noEmpty` below.
406
+ //
407
+ // A zero is unaffected: `cellText` formats it as "0", not as the dash, and a
408
+ // zero total is a real answer that must keep the table up.
409
+ // Asked of `cellText` rather than re-derived: a `SemanticValue` is an OBJECT
410
+ // even when it carries `value: null`, so a nullish check on the entry itself
411
+ // calls a valueless total an answer. `cellText` already owns the decision of
412
+ // what counts as showable, down to `formatted: ""`, so the question is simply
413
+ // whether it produced the placeholder.
414
+ const serverTotal = result.totals?.[s.measureName];
415
+ const totalText = cellText(serverTotal);
416
+ const answeredTotal = totalText !== DASH;
417
+
390
418
  // `noTotals` is the owner's REQUEST and stays theirs; only they change it.
391
419
  // When they asked and the server sent no total, that is reported as a dash —
392
420
  // an absent number the owner asked for must be visible, not silently dropped
@@ -395,9 +423,12 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
395
423
  ? null
396
424
  : {
397
425
  dimension: copyParam("ownerReport.totalsLabel", "Total"),
398
- measure: result.totals ? cellText(result.totals[s.measureName]) : "—",
426
+ measure: totalText,
399
427
  };
400
428
 
429
+ // Is there anything to READ? Rows, or a total the server actually sent.
430
+ const hasContent = rows.length > 0 || answeredTotal;
431
+
401
432
  const out: ReportState = {
402
433
  ...s,
403
434
  status: "ready",
@@ -405,16 +436,22 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
405
436
  + `${s.measureLabel} ${copyParam("ownerReport.by", "by")} ${s.dimensionLabel}`,
406
437
  rows,
407
438
  totals,
408
- rowCount: rows.length > 0
409
- ? `${result.paging.count || rows.length} ${copyParam("ownerReport.rows", "rows")}`
410
- : "",
439
+ // A zero count is stated, not left blank. Silence beside a caption reads
440
+ // as "still loading" — the ambiguity this screen removes everywhere else —
441
+ // so "0 rows" says the query ran and came back with nothing.
442
+ rowCount: `${rows.length > 0 ? result.paging.count || rows.length : 0} ${copyParam("ownerReport.rows", "rows")}`,
411
443
  missingFields: gap,
412
444
  noGap: gap === "",
413
- // Rows OR totals. The API can answer `results: []` WITH a `totals` object
414
- // — the SDK has a test for exactly that shape — and gating on rows alone
415
- // hid the server's total and put "Nothing to show" over it. Opting into
416
- // Show total would then have thrown away the only answer that came back.
417
- hideTable: rows.length === 0 && !totals,
445
+ // Rows OR a total the SERVER sent. The API can answer `results: []` WITH a
446
+ // `totals` object — the SDK has a test for exactly that shape — and gating
447
+ // on rows alone hid the server's total and put "Nothing to show" over it.
448
+ //
449
+ // But it must be `answeredTotal`, not `totals`: `totals` is non-null for
450
+ // the whole time the owner has Show total on, INCLUDING when the answer is
451
+ // the "—" placeholder. Gating on it meant an empty result with the toggle
452
+ // on rendered a table whose only row was `Total —`, and the empty state
453
+ // could never fire while the toggle was on. Two states each correct alone.
454
+ hideTable: !hasContent,
418
455
  notLoading: true,
419
456
  notErrored: true,
420
457
  hideContent: false,
@@ -423,10 +460,10 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
423
460
  // It is NOT conflated with a dropped field: the SDK reports no
424
461
  // `missingFields` when nothing came back at all, precisely because the two
425
462
  // are indistinguishable there, so this arm never accuses a field.
426
- noEmpty: rows.length > 0 || totals !== null,
427
- emptyText: rows.length === 0 && !totals
428
- ? copyParam("ownerReport.empty", "No data for this range.")
429
- : "",
463
+ noEmpty: hasContent,
464
+ emptyText: hasContent
465
+ ? ""
466
+ : copyParam("ownerReport.empty", "No data for this range."),
430
467
  // Carried through untouched: see the totals comment above.
431
468
  noTotals: s.noTotals,
432
469
  error: "",
@@ -181,11 +181,22 @@ function subjectLabelOf(m: SemanticModelSummary): string {
181
181
  * renders as a dash rather than as 0. `formatNumber` and not `toLocaleString`,
182
182
  * which is ECMA-402 and absent on QuickJS-ng (watchOS/Wear OS).
183
183
  */
184
+ /**
185
+ * The one placeholder a cell falls back to when there is nothing to render.
186
+ *
187
+ * Named, because two things depend on it meaning exactly one thing: `cellText`
188
+ * produces it, and `runQuery` asks whether the server's total came back as it —
189
+ * which is how "the owner asked for a total and got a dash" is told apart from
190
+ * "the server answered". Spelling it twice let those two drift apart once
191
+ * already.
192
+ */
193
+ const DASH = "\u2014";
194
+
184
195
  function cellText(v: SemanticValue | undefined): string {
185
- if (!v) return "—";
196
+ if (!v) return DASH;
186
197
  if (v.formatted !== undefined && v.formatted !== "") return v.formatted;
187
198
  const raw = v.value;
188
- if (raw === null || raw === undefined) return "—";
199
+ if (raw === null || raw === undefined) return DASH;
189
200
  if (typeof raw === "number") return formatNumber(raw);
190
201
  if (typeof raw === "boolean") return raw ? "Yes" : "No";
191
202
  if (typeof raw === "string") return raw;
@@ -387,6 +398,23 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
387
398
  ? copyParam("ownerReport.missingFields", "No data returned for: ") + result.missingFields.join(", ")
388
399
  : "";
389
400
 
401
+ // Did the SERVER actually answer with a total for the measure asked for?
402
+ // This is NOT the same question as "is a totals row on screen": the row is
403
+ // rendered as a dash when the owner asked and nothing came back, and a dash
404
+ // is a placeholder, not an answer. Keeping the two apart is what stops a
405
+ // placeholder from counting as content — see `hideTable`/`noEmpty` below.
406
+ //
407
+ // A zero is unaffected: `cellText` formats it as "0", not as the dash, and a
408
+ // zero total is a real answer that must keep the table up.
409
+ // Asked of `cellText` rather than re-derived: a `SemanticValue` is an OBJECT
410
+ // even when it carries `value: null`, so a nullish check on the entry itself
411
+ // calls a valueless total an answer. `cellText` already owns the decision of
412
+ // what counts as showable, down to `formatted: ""`, so the question is simply
413
+ // whether it produced the placeholder.
414
+ const serverTotal = result.totals?.[s.measureName];
415
+ const totalText = cellText(serverTotal);
416
+ const answeredTotal = totalText !== DASH;
417
+
390
418
  // `noTotals` is the owner's REQUEST and stays theirs; only they change it.
391
419
  // When they asked and the server sent no total, that is reported as a dash —
392
420
  // an absent number the owner asked for must be visible, not silently dropped
@@ -395,9 +423,12 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
395
423
  ? null
396
424
  : {
397
425
  dimension: copyParam("ownerReport.totalsLabel", "Total"),
398
- measure: result.totals ? cellText(result.totals[s.measureName]) : "—",
426
+ measure: totalText,
399
427
  };
400
428
 
429
+ // Is there anything to READ? Rows, or a total the server actually sent.
430
+ const hasContent = rows.length > 0 || answeredTotal;
431
+
401
432
  const out: ReportState = {
402
433
  ...s,
403
434
  status: "ready",
@@ -405,16 +436,22 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
405
436
  + `${s.measureLabel} ${copyParam("ownerReport.by", "by")} ${s.dimensionLabel}`,
406
437
  rows,
407
438
  totals,
408
- rowCount: rows.length > 0
409
- ? `${result.paging.count || rows.length} ${copyParam("ownerReport.rows", "rows")}`
410
- : "",
439
+ // A zero count is stated, not left blank. Silence beside a caption reads
440
+ // as "still loading" — the ambiguity this screen removes everywhere else —
441
+ // so "0 rows" says the query ran and came back with nothing.
442
+ rowCount: `${rows.length > 0 ? result.paging.count || rows.length : 0} ${copyParam("ownerReport.rows", "rows")}`,
411
443
  missingFields: gap,
412
444
  noGap: gap === "",
413
- // Rows OR totals. The API can answer `results: []` WITH a `totals` object
414
- // — the SDK has a test for exactly that shape — and gating on rows alone
415
- // hid the server's total and put "Nothing to show" over it. Opting into
416
- // Show total would then have thrown away the only answer that came back.
417
- hideTable: rows.length === 0 && !totals,
445
+ // Rows OR a total the SERVER sent. The API can answer `results: []` WITH a
446
+ // `totals` object — the SDK has a test for exactly that shape — and gating
447
+ // on rows alone hid the server's total and put "Nothing to show" over it.
448
+ //
449
+ // But it must be `answeredTotal`, not `totals`: `totals` is non-null for
450
+ // the whole time the owner has Show total on, INCLUDING when the answer is
451
+ // the "—" placeholder. Gating on it meant an empty result with the toggle
452
+ // on rendered a table whose only row was `Total —`, and the empty state
453
+ // could never fire while the toggle was on. Two states each correct alone.
454
+ hideTable: !hasContent,
418
455
  notLoading: true,
419
456
  notErrored: true,
420
457
  hideContent: false,
@@ -423,10 +460,10 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
423
460
  // It is NOT conflated with a dropped field: the SDK reports no
424
461
  // `missingFields` when nothing came back at all, precisely because the two
425
462
  // are indistinguishable there, so this arm never accuses a field.
426
- noEmpty: rows.length > 0 || totals !== null,
427
- emptyText: rows.length === 0 && !totals
428
- ? copyParam("ownerReport.empty", "No data for this range.")
429
- : "",
463
+ noEmpty: hasContent,
464
+ emptyText: hasContent
465
+ ? ""
466
+ : copyParam("ownerReport.empty", "No data for this range."),
430
467
  // Carried through untouched: see the totals comment above.
431
468
  noTotals: s.noTotals,
432
469
  error: "",
@@ -181,11 +181,22 @@ function subjectLabelOf(m: SemanticModelSummary): string {
181
181
  * renders as a dash rather than as 0. `formatNumber` and not `toLocaleString`,
182
182
  * which is ECMA-402 and absent on QuickJS-ng (watchOS/Wear OS).
183
183
  */
184
+ /**
185
+ * The one placeholder a cell falls back to when there is nothing to render.
186
+ *
187
+ * Named, because two things depend on it meaning exactly one thing: `cellText`
188
+ * produces it, and `runQuery` asks whether the server's total came back as it —
189
+ * which is how "the owner asked for a total and got a dash" is told apart from
190
+ * "the server answered". Spelling it twice let those two drift apart once
191
+ * already.
192
+ */
193
+ const DASH = "\u2014";
194
+
184
195
  function cellText(v: SemanticValue | undefined): string {
185
- if (!v) return "—";
196
+ if (!v) return DASH;
186
197
  if (v.formatted !== undefined && v.formatted !== "") return v.formatted;
187
198
  const raw = v.value;
188
- if (raw === null || raw === undefined) return "—";
199
+ if (raw === null || raw === undefined) return DASH;
189
200
  if (typeof raw === "number") return formatNumber(raw);
190
201
  if (typeof raw === "boolean") return raw ? "Yes" : "No";
191
202
  if (typeof raw === "string") return raw;
@@ -387,6 +398,23 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
387
398
  ? copyParam("ownerReport.missingFields", "No data returned for: ") + result.missingFields.join(", ")
388
399
  : "";
389
400
 
401
+ // Did the SERVER actually answer with a total for the measure asked for?
402
+ // This is NOT the same question as "is a totals row on screen": the row is
403
+ // rendered as a dash when the owner asked and nothing came back, and a dash
404
+ // is a placeholder, not an answer. Keeping the two apart is what stops a
405
+ // placeholder from counting as content — see `hideTable`/`noEmpty` below.
406
+ //
407
+ // A zero is unaffected: `cellText` formats it as "0", not as the dash, and a
408
+ // zero total is a real answer that must keep the table up.
409
+ // Asked of `cellText` rather than re-derived: a `SemanticValue` is an OBJECT
410
+ // even when it carries `value: null`, so a nullish check on the entry itself
411
+ // calls a valueless total an answer. `cellText` already owns the decision of
412
+ // what counts as showable, down to `formatted: ""`, so the question is simply
413
+ // whether it produced the placeholder.
414
+ const serverTotal = result.totals?.[s.measureName];
415
+ const totalText = cellText(serverTotal);
416
+ const answeredTotal = totalText !== DASH;
417
+
390
418
  // `noTotals` is the owner's REQUEST and stays theirs; only they change it.
391
419
  // When they asked and the server sent no total, that is reported as a dash —
392
420
  // an absent number the owner asked for must be visible, not silently dropped
@@ -395,9 +423,12 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
395
423
  ? null
396
424
  : {
397
425
  dimension: copyParam("ownerReport.totalsLabel", "Total"),
398
- measure: result.totals ? cellText(result.totals[s.measureName]) : "—",
426
+ measure: totalText,
399
427
  };
400
428
 
429
+ // Is there anything to READ? Rows, or a total the server actually sent.
430
+ const hasContent = rows.length > 0 || answeredTotal;
431
+
401
432
  const out: ReportState = {
402
433
  ...s,
403
434
  status: "ready",
@@ -405,16 +436,22 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
405
436
  + `${s.measureLabel} ${copyParam("ownerReport.by", "by")} ${s.dimensionLabel}`,
406
437
  rows,
407
438
  totals,
408
- rowCount: rows.length > 0
409
- ? `${result.paging.count || rows.length} ${copyParam("ownerReport.rows", "rows")}`
410
- : "",
439
+ // A zero count is stated, not left blank. Silence beside a caption reads
440
+ // as "still loading" — the ambiguity this screen removes everywhere else —
441
+ // so "0 rows" says the query ran and came back with nothing.
442
+ rowCount: `${rows.length > 0 ? result.paging.count || rows.length : 0} ${copyParam("ownerReport.rows", "rows")}`,
411
443
  missingFields: gap,
412
444
  noGap: gap === "",
413
- // Rows OR totals. The API can answer `results: []` WITH a `totals` object
414
- // — the SDK has a test for exactly that shape — and gating on rows alone
415
- // hid the server's total and put "Nothing to show" over it. Opting into
416
- // Show total would then have thrown away the only answer that came back.
417
- hideTable: rows.length === 0 && !totals,
445
+ // Rows OR a total the SERVER sent. The API can answer `results: []` WITH a
446
+ // `totals` object — the SDK has a test for exactly that shape — and gating
447
+ // on rows alone hid the server's total and put "Nothing to show" over it.
448
+ //
449
+ // But it must be `answeredTotal`, not `totals`: `totals` is non-null for
450
+ // the whole time the owner has Show total on, INCLUDING when the answer is
451
+ // the "—" placeholder. Gating on it meant an empty result with the toggle
452
+ // on rendered a table whose only row was `Total —`, and the empty state
453
+ // could never fire while the toggle was on. Two states each correct alone.
454
+ hideTable: !hasContent,
418
455
  notLoading: true,
419
456
  notErrored: true,
420
457
  hideContent: false,
@@ -423,10 +460,10 @@ async function runQuery(s: ReportState, days: number): Promise<ReportState | Dec
423
460
  // It is NOT conflated with a dropped field: the SDK reports no
424
461
  // `missingFields` when nothing came back at all, precisely because the two
425
462
  // are indistinguishable there, so this arm never accuses a field.
426
- noEmpty: rows.length > 0 || totals !== null,
427
- emptyText: rows.length === 0 && !totals
428
- ? copyParam("ownerReport.empty", "No data for this range.")
429
- : "",
463
+ noEmpty: hasContent,
464
+ emptyText: hasContent
465
+ ? ""
466
+ : copyParam("ownerReport.empty", "No data for this range."),
430
467
  // Carried through untouched: see the totals comment above.
431
468
  noTotals: s.noTotals,
432
469
  error: "",