From 0c77cc45348fcd90d2af7c3d47933a3612814bb7 Mon Sep 17 00:00:00 2001
From: Kirill Pimenov
Date: Tue, 4 Aug 2026 16:10:30 +0200
Subject: [PATCH] fix: correct reprint threshold display and de-duplicate the
formula (#124)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: correct reprint threshold display and de-duplicate the formula (F5)
Print.vue recomputed the recovery threshold as floor(total/2)+2 and stored
the *total* in a field misleadingly named `requiredShards`. The reprint
sheets therefore overstated how many more QR codes are needed (e.g. total=5
showed "need 4" instead of the correct 3) — misleading during recovery,
though the QR payloads themselves were always untouched.
Root cause: the threshold policy was duplicated. Share.vue computes
floor(total/2)+1; Print.vue re-implemented it and drifted. Extract a single
`defaultThreshold()` helper (src/util/shards.ts) and use it in both, so they
can never disagree again. Rename Print.vue's field to `totalShards` to match
what it actually holds.
Add unit tests for the helper, including the exact F5 case (total=5 -> 3) and
a strict-majority invariant across the whole 3..255 UI range.
* fix: reject fractional shard counts, clamp the remaining-code count
Review follow-ups on the reprint view.
`` only constrains the spinner, not the value: typing
"3.5" (or clearing the box, which `v-model.number` leaves as "") sailed
through the old `>= 3 && <= 255` check and reached defaultThreshold(),
producing fractional totals and remaining counts. Add `step="1"` for the
spinner and gate on a shared `isValidShardCount()` predicate that also
requires a whole number, so the input and the threshold policy keep agreeing
about what a shard count is.
`needMoreShards` compared with `!==`, so scanning more codes than announced
kept the scanner open and drove `remainingCodes` negative. Compare with `<`
and clamp the remainder at 0.
`threshold` returned 0 for "nothing entered yet", a valid-looking value that
could reach ShardInfo's required-shards prop. Return `undefined` instead and
guard the consuming block on it, making the not-yet-entered state explicit.
The `threshold` and validation paths are covered by shards.spec.ts; the
scanner-overshoot clamp is not, as the repo has no component-test harness yet.
* fix: apply the same shard-count validation to the generator
Share.vue's shard-count input had the defect Copilot flagged on Print.vue's:
no `step`, no integer check, and `totalShards` fed straight into
crypto.share() — a fractional count surfaced as an opaque secrets.js error
routed through the generic error hub, and an emptied box passed "" through.
Reuse `isValidShardCount()` and gate the generate button on it, matching the
existing `secretTooLong` pattern (disabled button plus an inline error span),
so both shard-count inputs now agree on what a shard count is.
* fix: stop rendering a threshold derived from an invalid shard count
Gating the generate button left the sentence above it still interpolating
defaultThreshold(totalShards) for values that had just been rejected: an
emptied field coerces to 0 and renders "Will require any 1 shards", and 3.5
renders 2. Give Share.vue's `requiredShards` the same contract as Print.vue's
`threshold` — `undefined` unless the count is usable — show an em dash in the
sentence, and guard both the generated-shards block and crypto.share() on it.
Also strengthen the threshold invariant test. Asserting only
`defaultThreshold(n) > n / 2` does not pin the policy: floor(n/2)+2, the very
formula this branch removed, satisfies it for every even n. Assert the
smallest strict majority instead, which floor(n/2)+1 alone satisfies.
---
src/util/shards.ts | 16 +++++++++++
src/views/Print.vue | 29 +++++++++++++-------
src/views/Share.vue | 29 +++++++++++++++-----
tests/unit/shards.spec.ts | 57 +++++++++++++++++++++++++++++++++++++++
4 files changed, 115 insertions(+), 16 deletions(-)
create mode 100644 src/util/shards.ts
create mode 100644 tests/unit/shards.spec.ts
diff --git a/src/util/shards.ts b/src/util/shards.ts
new file mode 100644
index 0000000..87a1023
--- /dev/null
+++ b/src/util/shards.ts
@@ -0,0 +1,16 @@
+// Banana Split's fixed threshold policy: reconstruction requires a majority of
+// the shards, i.e. floor(total / 2) + 1. This lives in one place so the
+// generator (Share.vue) and the reprint view (Print.vue) can never disagree —
+// they did once: Print.vue re-implemented the formula as floor(total/2)+2 and
+// displayed the wrong "you need N more" count on reprints (finding F5).
+export function defaultThreshold(totalShards: number): number {
+ return Math.floor(totalShards / 2) + 1;
+}
+
+// Mirrors the min/max on the shard-count inputs. `v-model.number` on an
+// `` hands us whatever the field holds, which includes ""
+// for an empty box and fractions like 3.5 — neither is a shard count, so the
+// range check alone is not enough.
+export function isValidShardCount(value: unknown): value is number {
+ return typeof value === "number" && Number.isInteger(value) && value >= 3 && value <= 255;
+}
diff --git a/src/views/Print.vue b/src/views/Print.vue
index a207e74..624f637 100644
--- a/src/views/Print.vue
+++ b/src/views/Print.vue
@@ -11,10 +11,11 @@