Skip to content

fix: four findings left open by the previous two reviews - #49

Merged
JoelA510 merged 70 commits into
mainfrom
claude/invoice-shipping-forms-5ejpeb
Aug 12, 2026
Merged

JoelA510 merged 70 commits into
mainfrom
claude/invoice-shipping-forms-5ejpeb

Conversation

@JoelA510

Copy link
Copy Markdown
Owner

What

Closes the four items #47 and #48 deliberately left out of scope. All four are pre-existing on main; each was reproduced before being changed.

  1. Export-control settings leaked between shipments (App.tsx). The saved-consignee lookup and the settings reset shared one try, so a rejected read skipped setSettings(base) entirely. defaultShipmentSettings blanks ECCN, licence and SME on purpose — an ECCN is a classification the filer makes per shipment — so the previous shipment's triplet stayed live on the next one, and the export-control check passed on it while the banner only mentioned consignee details. The reset now happens before the lookup; the enrichment still applies on success.

  2. The declaration drew addresses over the next box's caption (carriers/dgd/render.ts). lines() bounded party text by width but not by row count. The Shipper and Consignee boxes are 74pt tall, their first baseline sits 22pt down and they step 10pt — five fit, and a seventh landed below the box, on the Consignee caption, on a regulated form. Overflow is now left off and reported, matching how every other overflow on this form already behaves.

  3. The retention history read a day short (features/dangerous-goods.tsx). The prepared column showed preparedAt.slice(0, 10) — a UTC date — beside a retainUntil computed from local calendar parts. A declaration prepared at 18:00 west of Greenwich rendered "Prepared 2026-08-12 / Keep until 2028-08-11". Both now derive from the existing localDate helper (which retainUntil had been duplicating).

  4. The parser read the totals band as a compliance row (domain/cipl/parse-omron-ci.ts). Confirmed reachable: with all eight form lines used and the last block's compliance row collapsed away, the SUBTOTAL row was read as that line's COO/HTS/ECCN. It yields blanks today only because that band happens to hold nothing in those columns — a coincidence of the current layout, not a decision. Both readers now recognise the band through one shared predicate, so a future revision putting anything in those cells cannot have it filed as a country of origin.

Testing

  • 5 new tests: renderer overflow warning (and silence when it fits), the prepared/retain date pair across an evening-west-of-Greenwich instant, and two for the last-block resync — including a totals band deliberately seeded with text in the compliance columns.
  • npm run check green: typecheck, lint, 578 tests, build.
  • No component-test harness exists in this repo, so the App.tsx change is covered by review rather than a test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1


Generated by Claude Code

- A failed saved-consignee read left the previous shipment's settings in
  place, because the defaults were applied only on the success path.
  `defaultShipmentSettings` blanks the export-control triplet on purpose —
  an ECCN is a classification the filer makes per shipment — so a carried
  over one was filed against the next consignment with the export-control
  check passing on it. The reset now happens before the lookup.
- A party block longer than its box drew over the caption of the box
  beneath it: five baselines fit the 74pt Shipper and Consignee boxes and
  a seventh landed outside them. What does not fit is now left off and
  reported, like every other overflow on this form.
- The retention history showed a UTC date beside a retain-until computed
  from local parts, so a declaration prepared in the evening west of
  Greenwich read a day short of the two years the table evidences. Both
  now come from the same helper.
- The commodity reader treated the totals band as a compliance row when
  the last line's own was collapsed away. It read blanks either way,
  because that band happens to hold nothing in those columns — a
  coincidence of the present layout, not something to rest a country of
  origin on. Both readers now recognise the band through one predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Status

  • Head SHA: 85bb8e5543f8c32deaa010237ccec7ce63229bfb
  • Base SHA: 31d6d8ac9664fab03d73258ff4de39c227e7841e
  • CI state: pending
  • Files reviewed: 30
  • Patches proposed: 0
  • Change types: code, docs-only

Findings

No issues found in changed scope.

Proposed patches

None

CI failure mapping

CI is still running (pending).

Diagnostic plan

npm ci
npm run lint
npm test -- --watch=false
npm run build
npx --yes markdown-link-check <changed-docs>
npx --yes cspell <changed-docs>

Next actions

  • Awaiting updates for head 85bb8e5543f8c32deaa010237ccec7ce63229bfb

Meta

Processed at 2026-08-12 14:09:54.465 UTC
Idempotency key = 85bb8e5

claude added 27 commits August 11, 2026 15:08
- The UN 38.3 exception for button cells is written against cells
  *installed in equipment*, and the check read the flag without the
  configuration: a standalone entry with it ticked generated a UN3480
  declaration with no test summary on file at all. The battery-mark rule
  keyed on the same flag has always required the configuration. The
  toggle is no longer offered where it cannot apply.
- A commodity heading the extractor splits across items leaves its column
  with no anchor, and an unfound anchor is -Infinity: the quantity border
  collapses, every cell looks like it belongs to a numeric column, and
  values from the missing column fall to whichever heading is nearest —
  an SME filed into the licence box. The table is now handed back
  unreshaped when any heading is unlocated, so the parser reports a table
  it could not read rather than inventing lines from a grid nobody could
  calibrate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The previous commit's guard bailed out of the whole grid reshaping when a
heading could not be located, which was both too much and too little:

- Too much, because it discarded the party-band column mapping and the
  item coalescing along with the table. A consignee with a blank address
  row then absorbed the bill-to's street.
- Too little, because in a layout where the merged headings land on the
  PART # row, `readLines` found headings anyway and read the blocks by
  item index — filing a printed AMOUNT as a unit value and fabricating an
  extended value from it, with no warning naming the heading.

Now only the synthesized heading rows and the blocks are withheld. The
header fields, the party band and the totals band still read, and
`readLines` finds no table and says so.

Also deduplicates the renderer's warnings. The party blocks are drawn on
every sheet, so an over-long address reported one problem once per page,
and the panel keys them by their text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
…ating

- Truncating a party block was the wrong remedy for the overflow. Boxes 1
  and 2 are required to be complete, and an address delivered without its
  country line is a defective declaration whether the panel mentions it
  or not — the more so because the PDF is written and opened before the
  warning reaches the screen. A block that will not fit at full size is
  now condensed to a tighter tier, which holds seven lines; only past
  that is anything left off, and then loudly.
- The uncalibrated-table gate demanded anchors for PART # and DESCRIPTION
  OF GOODS, neither of which positions a cell — the two are separated by
  the border between the COO and HTS columns beneath. A split DESCRIPTION
  heading therefore refused a table that reads perfectly well, which is
  the guard turned against a document it should have read. It now gates
  on the ten anchors that actually place something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
…N case

- The A181 check tells the shipper that a package holding batteries both
  packed with and contained in equipment is described as packed with
  equipment — the package and the shipping paper alike — while the
  declaration went on printing the contained-in entry as its own line.
  The description now follows the provision, with the package total on
  the one line A181 puts it on.
- A fully regulated entry with no section skipped the UN specification
  packaging check entirely: PI 976 has no sections and every standalone
  sodium ion battery under it is fully regulated, and an unknown energy
  band is read conservatively as fully regulated too. Both were being
  told a strong outer packaging suffices, citing an A802 exception
  written for Section IB of two other packing instructions. That
  exception is now cited only where it is the reason.
- The CEVA ECCN box compares against EAR99 the way `distinct` compares —
  case-insensitively — so a hand-typed `ear99` leaves the box blank as an
  all-EAR99 shipment should, rather than printing a classification the
  form means to omit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- A181 resolved one packed-with target for the whole package, so a box
  holding two chemistries relabelled the first and left the second
  printing the description the provision says does not apply to it. It
  now matches each contained-in entry against a packed-with entry of its
  own UN number.
- A181 is not raised for a package already held for mixing Section II
  with fully regulated goods. Two blocking instructions pointing opposite
  ways — describe it as packed with equipment, and take it apart — is
  worse than either alone.
- A802 is listed against exactly the entries the packaging check holds to
  it, instead of being gated on the section while the check gated on
  something wider.
- The renderer deduplicates the party-block warnings where they are
  raised rather than over the whole list, so two genuinely different
  truncated table rows can no longer collapse into one. And the CEVA
  boxes trim before comparing, so a stray space cannot split one
  classification into two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The declaration already matched packed-with against contained-in per UN
number. The check did not: it measured the package's whole battery mass
against the lowest limit anywhere in the box. A box holding 3 kg of
UN3481 packed with equipment beside 3 kg of UN3091 contained in
equipment was blocked at "6 kg against 5 kg" — two entries, each well
inside its own allowance, neither of them held both ways, and nothing
could be generated for a package that complies. An unrelated standalone
line in the same box was added into the total as well.

The check now gathers, per UN number, the entries A181 actually speaks
about, and holds each total to the lowest limit among those entries. The
packaging's own authorization is no longer folded in: it is measured
against the whole package by dg.package-authorization, and capping this
total with it as well reported one failure twice.

The shared-packaging warning counted declaration groups before A181
merged the contained-in entry into the packed-with line, so a package
that prints exactly one line was told it produces two. It now counts
through the same two functions the renderer uses, in the same order —
and applyA181 moved next to the checks so there is one of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The battery mark exception reads "no more than four cells or two
  batteries" — one allowance or the other. Read as additive, a box with
  four cells *and* two batteries in it was told the mark was not
  required, on the strength of a permission the exception never gives. A
  mixed package is no longer offered it: an unnecessary mark costs a
  label, a missing one is an undeclared package.
- The DGD renderer counted clipped rows once per column, so a two-row
  entry could report four rows lost. Counted over rows now.
- Downloading a keying sheet clears the warnings the way generating an
  SLI does. A form-fill warning left standing beside a keying sheet
  describes a box on a form that download does not produce.
- The Omron CI reader walks the commodity table by its LN column. A
  revision that renamed or dropped that heading ended the table before
  its first block and returned no lines and no warning, which is
  indistinguishable from a blank form. It now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
Three of these let a package through a blocking check on a number that
described nothing:

- A stated battery count of zero satisfied the four-cell/two-battery
  allowance, so a package holding a kilogram of batteries was told the
  lithium battery mark was not required. Zero is unstated data, and is
  now refused alongside blank.
- A net battery weight of zero passed dg.net-weight, and box 15 of the
  declaration filed "1 Fibreboard box x 0 kg" with every limit in the
  assessment measured against it.
- A package count only had to be at least one, and the input has no step,
  so 1.5 boxes reached the declaration. Whole packages only.

The other two state something that is not known:

- With no watt-hour or lithium content rating there is no section, and
  the two a standalone battery could be — IB at 10 kg, or 2.5 kg under
  PI 968, against IA at 35 kg — differ by more than three times. The
  limit fell through to the Section IA figure and cited it by name. It
  now states the lower of the two, the same conservative reading the
  packaging section takes, and says the rating is what settles it.
- The address band was located by "a cell starting SHIPPER and a cell
  starting CONSIGNEE", which the header grid's own SHIPPER EIN / TAX ID
  and CONSIGNEE EORI / USCI / VAT row satisfies between them. It worked
  only because the band is printed first. The two are now told apart by
  the form's label list, in the workbook reader and the PDF reader
  alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- An overpack's wording is written once, after the last entry belonging
  to it. With A in #A001, B in #A002 and C in #A001 entered in that
  order, #A001's identification landed after C with #A002's between it
  and A — so box A's entry carried no overpack identification at all and
  #A002's mark sat among entries that are not in it, which is the
  box-against-paper mismatch dg.overpack-identifier exists to prevent.
  Packages sharing an overpack are now emitted together, at the position
  of the first of them; everything else keeps its order.
- An overpack count was never held to being a whole number of overpacks,
  though a package count was. The number of packages on the declaration
  is the product of the two, so 1.5 overpacks printed "1.5 Fibreboard
  box" and "Overpack used x 1.5", and reached the two-package battery
  mark exemption as a fractional number of packages.
- The retention record write is awaited, so a failed IndexedDB write
  reaches the generate handler's catch instead of going out as an
  unhandled rejection under a PDF that reported itself saved.
- Downloading the checklist clears the warnings, as downloading the
  keying sheet now does. They come from drawing the declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The record write moved inside the try in the last commit, which put a
rejected write on the same path as a rejected render — so the panel
showed "Saved to …" beside "The declaration could not be generated",
and the file it says could not be generated had already been written,
delivered and opened. Someone reading that goes and produces a second
one.

The write is now reported for what it is: the artifact was saved, and
what is missing is the record that it was prepared, which is the
two-year obligation and worth saying plainly. Both buttons take the
same path.

Also drops a leftover sentence in the PDF calibration comment that
contradicted the one below it — the guard holds only the anchors that
place a cell, which is what the line under it already said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The packing-bench checklist grouped a package's entries without
  applying A181, so a box holding one UN number both packed with and
  contained in equipment gave the packer two entries to check against
  two allowances while the declaration in the same envelope printed one
  merged line. It goes through the same function now, as the declaration
  and the shared-packaging count already do.
- A failed read-back of the retention list no longer reports itself as a
  failed write. The list on screen is refreshed on the next load; the
  record is written either way, and saying it was not would send someone
  to duplicate evidence already on file.
- `effectiveLimitKg` lost its only reader when the A181 check moved to
  per-UN-number groups with their own instruction limits. Removed, with
  the helper behind it, rather than left computed for every package and
  read by nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- Every standalone battery is forbidden on passenger aircraft, and each
  has a provision naming the approval that relieves it: A201 for lithium
  metal, A334 for lithium ion and sodium ion. A334 was nested inside the
  guard written for the state-of-charge provision A331, which is a
  rechargeable-chemistry rule, so UN3090 came out with no passenger
  provision at all — under a blocking check that cites both numbers.
- The special provisions were computed for every classification and read
  by nothing, which is how the above went unnoticed. They now appear
  beside the entry they belong to. The checks cite an individual
  provision where one decides an answer; the list itself is what a
  shipper reads a classification for, and what a state or operator
  variation is written against.
- The strong-packaging note joined its two capability clauses as though
  the second could only follow the first, so a package needing the stack
  test but not the drop test read "...outer packaging and of a 3 m stack
  test". That is the commonest excepted shipment there is: batteries
  contained in equipment, Section II.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
Batteries contained in equipment are offered at 30% of rated capacity
*or* an indicated battery capacity of 25%. The check compared every
reading against the 30% figure whatever it was measured on, so 26–30%
on indicated capacity passed as "within 30%" against a basis that caps
five points lower — and the basis guard stayed quiet, because indicated
capacity is a permitted basis for that entry. The limit now follows the
basis.

A rated *design* capacity was accepted in silence by every entry: the
guard only looked for indicated capacity, though its own text says one
of the three bases satisfies the entry and none of these rules is
written against that one. It belongs to the vehicle entries. The check
now names the basis that was picked, and asking for a basis nobody
recorded is unchanged.

Also: the additional-handling-information overflow was warned about
inside the per-sheet draw, so a two-page declaration reported the same
overflow twice. It is one box, repeated on each sheet, and one thing to
shorten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- A check title stated the package's own count, which is per overpack,
  while the declaration line, the checklist heading and the consignment
  totals all state the product of that and the overpack count. Two boxes
  per overpack across three overpacks read "2 × Fibreboard box" in the
  titles beside "6 Fibreboard box" on the declaration — both reproduced
  in the same checklist, reading as two packages rather than one
  described twice.
- Box 18 took whatever the shipper typed and drew it unmeasured, so a
  long emergency-contact line went over the red hatched margin and off
  the edge of the sheet in silence. It is measured now, like every other
  free-text block on the form, and warned about once for the
  declaration rather than once per sheet.
- The wrong-basis state of charge check was hardcoded blocking while the
  no-basis branch beside it took the rule's own strength. On an advisory
  entry that made picking the wrong item from the dropdown stop
  generation, while clearing it back to "not recorded" — strictly less
  information — did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The box 18 warning said the line "may be clipped when printed" while
  nothing clipped it: the full string was still drawn, past the right
  edge of the box, over the red hatched margin and off the sheet. It is
  cut to the box now, with an ellipsis so a shortened instruction reads
  as shortened, and the warning quotes what was printed.
- `applyA181` merges every entry; the A181 check gathered only the ones
  with a stated energy rating. A UN3481 pair whose contained-in line had
  no watt-hour figure was therefore printed as one 5.5 kg line on the
  declaration and the checklist while no check measured it against the
  5 kg ceiling. `dg.energy` blocks that shipment either way, but the
  preview and the check list were describing two different packages, and
  the whole point of the shared function is that they cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
Three of these are one root cause: `text()` took any string and drew it
wherever it was told.

- A character outside WinAnsi — a consignee in Yokohama, a signer with a
  Cyrillic name — made pdf-lib throw, and the whole declaration failed
  with an opaque message about an encoding. Unprintable characters are
  substituted and reported now. Embedding a Unicode font is the real
  answer and is a larger change than this; the substitution at least
  produces a form, and says which value to write by hand.
- `lines()` warned that an address line "may be clipped when printed"
  and then drew it in full, over the air waybill column beside it.
  `fitLines` condenses on line count and never on width, so nothing was
  clipping it. It is cut now, with the ellipsis the party blocks and box
  18 already use.
- The airport of destination, the shipper's reference and the signer's
  name, title, place and date were drawn unbounded and unreported. Every
  one of them has something printed past its right edge — the WARNING
  box, the air waybill column, the hatched margin.

Separately: `dg.energy` blocked a standalone sodium ion consignment for
want of a watt-hour rating that changes nothing. PI 976 has no sections
and one quantity limit, and `sectionFor` short-circuits on that pair
before it reads the band — the classification is identical either way.
It is informational there, under wording that does not cite reliefs
PI 976 has never had. Everywhere else it still blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
Making `dg.energy` informational for standalone sodium ion left two
places still reading an unstated rating as "not yet classified", which
for that one pair it is not.

- The mixed-regulation guard dropped the entry, so an unrated UN3551
  line beside a Section II battery in the same box passed every blocking
  check and produced a declaration stating the sodium line alone — half
  the box undeclared. That is precisely the hole the comment above the
  filter describes, reopened from the other side. It is keyed on whether
  the rating decides anything now, not on whether one was typed.
- The entry summary said "the section follows from the energy content,
  which has not been stated" and hid the limit, contradicting the check
  beside it for a consignment the tool will file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The store version went 4 to 5 with no `blocked` or `blocking` handler.
  IndexedDB will not upgrade while an older connection is live, so a
  second tab left open on the previous version parked the open request
  forever — and because the promise is memoized, that is every store
  call in the application. Panels empty, Generate stuck disabled, and
  nothing said, because the `finally` that clears `busy` never ran. The
  old tab lets go when told it is blocking, and a blocked tab now fails
  with a sentence someone can act on.
- The six restore reads were one `Promise.all` with no catch, so a
  single rejecting store discarded all six and the retention panel read
  "Nothing prepared yet" for consignments still inside their two-year
  window. Settled individually now, and what could not be read is named
  above both tabs — an empty panel is not evidence that nothing was
  saved.
- Downloading the declaration and then the checklist wrote two
  indistinguishable retention rows for one preparation of one
  consignment. An unchanged consignment keeps its identity, so the
  second write lands on the first. Edit anything and it is a new
  preparation with its own row.
- The workbook reader matched the commodity headings case-sensitively
  while every other label on the form, and the whole of the PDF path,
  matches uppercased. A title-cased revision was refused as "not the
  Commercial Invoice form" while its own printed PDF parsed cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The blocked/blocking handling added in the last commit treated two
opposite situations the same way, and made a third problem out of it.

- Being *blocked* is temporary: the tab holding the old connection may
  close at any moment, and the open completes when it does. The open is
  left running now and only the wait on it is bounded, so a caller is
  told rather than hanging — and once the handle arrives every later call
  is served from it, instead of the banner staying up over a database
  that has been working for some seconds.
- Being *superseded* is not temporary. That tab is a version behind and
  can only ever ask for the version the browser now refuses, so clearing
  the memo meant reopening into a VersionError on every call for the life
  of the page — swallowed by the `void` writes, which is edits vanishing
  in silence. It is terminal and says so once: nothing typed here is
  being saved, reload the page.
- Clearing the memo on any failure also broke the invariant the profile
  autosave rested on. A failed read no longer implies a failed write, so
  the first keystroke over an empty form would have saved a near-blank
  profile on top of the one that could not be read. Nothing is written
  back until something was read, and a failed write is reported instead
  of discarded.

Separately, the retention dedupe was a component-local ref, and this
panel unmounts every time the other tab is shown. It matches against the
records on file now, which survives a tab switch and a reload — which is
exactly when someone comes back to print the checklist for what they
generated earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- `hazardCommunication` was the one consumer still grouping the raw
  entries. A package holding UN3481 both packed with and contained in
  equipment generated cleanly, printed one declaration line reading
  "packed with equipment", and handed the packer a tick-list asking for
  both proper shipping name marks on the same box — contradicting the
  instruction `dg.a181` had just given.
- `clearAll` had no catch around either call. Now that `db()` rejects
  rather than hanging, a superseded tab threw straight out of the
  callback: nothing deleted, no panel reset, and a confirmation dialog
  that had just said the data was gone. It says what happened instead.
- The keying-sheet layout restore and save had no catch either. Those
  stay quiet on purpose — a layout is a convenience, the default is a
  complete answer, and a banner about column widths would bury the one
  that matters — but they are handled rather than left as unhandled
  rejections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The PDF reader reassembled a column's text items by x alone. A
description that both wraps to a second printed line and is split into
word items — the two pdfjs behaviours this function is written around,
each already covered by its own test, never together — came out
interleaved: "Robot 5 m shielded cable assembly," from two lines reading
"Robot cable assembly," and "5 m shielded". That string is what gets
filed as the commodity description. Items sort by baseline first now,
with a tolerance so a line nudged a fraction by the renderer still sorts
across.

Also: `handleGenerated` writes the shipment history record after the
form has been generated and opened, with no catch. `db()` rejects rather
than hanging now, so a tab superseded by a newer version in another
window lost the audit record while the panel went on saying "Saved
to …". The record is reported when it fails; the consignee autofill and
the on-screen list are not, because both re-read on the next load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- Matching a preparation against the records on file fixed the tab
  switch but matched on content alone, so preparing the same consignment
  again months later inherited the first row's date — back-dating the
  new preparation, overwriting the earlier evidence, and shortening the
  window both are kept for. Only a record from the last half hour is the
  same preparation as this one: long enough for the declaration and the
  checklist, far short of coming back next quarter.
- The overrides, the per-part values, the item library import and its
  removal were still bare awaits inside void-ed callbacks. That was
  survivable while a store that could be read could be written; a
  superseded tab breaks it, and an import that disappears without a word
  is the worst of the set. They go through one helper now, which also
  takes the message back down when a write succeeds — a complaint about
  a failure that has since cleared is its own kind of wrong.
- A failed profile *read* is reported separately and stays up, because
  nothing will write the profile back for the rest of the page's life
  and a successful write elsewhere must not appear to have cleared it.
- The reading-order tolerance was half a text row, which would swallow a
  tightly wrapped description and put its two lines back into one — the
  fixture cleared it by 2pt. Items are grouped into lines against the
  highest baseline of the line they join and then read across, rather
  than sorted by a comparator mixing both axes, which was not an
  ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The single storage banner conflated two reports that answer different
questions and clear at different times. A write succeeding says nothing
about the reads that failed at load, and the panels those reads would
have filled are still empty — so folding them together erased the
load-time message within the first second of every affected session,
because the profile autosave fires 400 ms in. They are separate now, and
only the write report is withdrawn on a success.

- The list read-back after a write is no longer inside the attempt that
  decides the message. A committed write whose refresh failed was
  reported as "could not be saved" — the misreport already fixed for the
  retention record and for `clearAll`.
- The shipment history refresh no longer shares an attempt with the
  consignee write, which is optional convenience data: a failure there
  skipped the refresh after a record that had gone in.

And two boxes on the declaration were still unbounded:

- The Airport of Departure block was told its box had no floor, so an
  airport named at length wrapped over the "Airport of Destination:"
  caption directly beneath it. It is the one value in that box that can
  run to several lines.
- The air waybill number was the last free-text value in its box drawn
  without a width, and ran into the hatched margin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The quantity column and the overpack wording under it were the last
free-text values on the declaration drawn without a width. The model
wraps that column by character count, which is a proxy for width rather
than a measurement of it: thirty-four characters of capitals is much
wider than thirty-four of the fixture's lowercase, so a packaging type
or a set of overpack marks typed in capitals fitted the model's wrap and
still ran across the Packing Inst. box beside it — in silence, because
nothing was clipping.

They go through the same measured path as the rest of the form now. The
existing points-not-characters invariant test measured only the
fixture's own lowercase wording, which is what gave the column a clean
bill of health; there is a test for the shouting case too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- Reading the address from the bottom fixed a PO box line above the city
  and walked into the line below it. `Phone 5551234` ends in digits too,
  and its last six went to the courier as a City of `Phone 5`. A contact
  line is skipped by name, and a run of digits longer than a postcode no
  longer matches its own tail — neither is a rule about what a postcode
  is; both are about what the lines around it are not.
- The marks list gave an unrated standalone battery the Section I and IA
  set, while the limit for that same battery is already stated as the
  conservative Section IB figure. Section IB carries the lithium battery
  mark, so the two disagreed about the same package — and the panel is
  read while the box is being made up. PI 976's null section is a
  different answer and keeps the marks it had.
- The collapsed-compliance-row resync is a workbook behaviour: a writer
  that omits empty rows shifts every following block. On the PDF path
  the compliance row is synthesized, one per block, and its first cell
  is the country of origin — so a numeric country code fired the resync,
  threw away that line's whole export-control row, and filed the
  compliance row again as goods of its own. It is scoped to the path
  where a row can actually be missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
Typing 0 into the watt-hour or lithium-content field was read as a
stated rating, and it is the most permissive one there is: Section II,
no declaration, adequate instruction in place of dangerous goods
training, the battery mark alone for hazard communication — under a
blocking check that passed, affirming "0 Wh per battery, against a
100 Wh threshold — small by air".

No cell is rated at nothing, so the figure is a placeholder or a
mistyped field, and it joins the absent rating in the third answer this
module keeps for exactly this: unknown. The sibling net-weight check
already refuses zero for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
Skipping a line because a keyword appeared anywhere on it threw away
`Tel Aviv 61000`, `Mobile, AL 36602` and `Fax Islands 12345` — all
cities, and since both fields read one line, the postcode went with the
city. The keyword has to *label* the number now: stand immediately in
front of it with only punctuation between, which is the whole of what
distinguishes `Phone 5551234` from a place that happens to be spelled
that way.

The digit-length guard beside it is gone, and the postcode itself now
runs to eight digits rather than six. Israel writes `3109601` and Japan
`1500001` unhyphenated; bounded at six, `Haifa 3109601` gave the courier
a postcode of `109601`, and refusing to match a longer run at all — the
guard I added last round — lost the city with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
claude added 29 commits August 12, 2026 03:48
`onOrAboutDate` means the *later sailing* date on the vendor layouts,
and box 2 of the SLI deliberately takes the invoice date instead — the
filed evidence for all three historical shipments. Putting this form's
SHIP DATE into that field would have moved the date of exportation on
those layouts too, through the one line in `buildDraft` that reads it;
and it did not move it on this one, because nothing reads the field for
this format. The comment I attached said otherwise. Whether the form's
own ship date should date the SLI is a question for the people who file
them, and answering it needs a field of its own.

Separately, `blockEndsAt` returned the block's own start when neither of
its anchors was in range — the degenerate floor the branch above exists
to avoid. That is exactly what a block carried across a page break looks
like from there: its order, line, classification and figures on the
previous sheet and only its description on this one, so the row below
could be read as the next commodity's heading. The floor goes to the end
of the range instead, which is the rule the rest of the function
applies. The page-break shape could not be reproduced here — the
two-page fixtures are gitignored, and those suites skip — so this is
reasoned from the code rather than from a failing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
…ops early

- The subtotal was the rightmost number on the SUBTOTAL row. That
  worked by luck of the present layout: the totals band shares its rows
  with `# OF PIECES`, `NET WT (KG)` and `GROSS WT (KG)`, so a shifted
  template — or simply an empty SUBTOTAL cell — would have handed a
  piece count or a weight to the blocking total-value check, which would
  then reconcile the shipment against it instead of failing. It is read
  from the AMOUNT column the table's own headings give, on the workbook
  path where the cells sit in their real columns.
- Both ways the commodity table can end early returned a short list with
  nothing said. A blank LN cell mid-table dropped every line below it,
  and a short list that looks complete is the one failure this reader
  must not produce in silence. Ending anywhere but the totals band is
  reported now.
- The keying sheet's saved layout is restored asynchronously, and a
  click inside that window produced a sheet in the default layout while
  the panel went on to render the saved one — a sheet that does not
  match its own description on screen. The download waits for the
  restore to settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The input coerced a blank or zero to one, so `dg.overpack-count` could
never see the mistake it exists to refuse — and the consignment was
under-counted in silence. The number of packages is the package count
multiplied by the overpack count, so six packages read as two: the
two-package battery mark exemption was granted, and the lithium battery
mark came off the marks list and the bench checklist for packages that
require it. The declaration also printed "Overpack used" in place of
"Overpack used x 3", with the package and net weight totals to match.

Zero, like the package count input beside it, which is written that way
for exactly this reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The party band is located on SHIPPER and CONSIGNEE alone, so an
  extractor that split "BILL TO / SOLD TO (IF DIFFERENT)" across items
  still found it — and with only two titles to map onto, every bill-to
  address item folded into the consignee's cell. That is a wrong
  CONSIGNED TO address on the SLI with nothing said. An address block
  that cannot be mapped onto the form's three columns is not mapped at
  all, and the empty blocks are reported the way any unreadable band is.
  Also switched from a bare regular expression to the same `isPartyTitle`
  the rest of the file uses, so a header-grid label can never title a
  block here either.
- The README said a clean checkout runs 336 tests. It runs 652.
- And it said a lithium ion state of charge over 30% blocks, flat. It
  blocks where the materials write the rule as a requirement —
  standalone, and cells over 2.7 Wh packed with equipment — and warns
  where they write it as a recommendation, which is batteries contained
  in equipment and the cells the 2.7 Wh note relieves. Someone reading
  that list to know what will stop a shipment was being told the wrong
  thing about the commonest excepted case there is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The three-columns-or-none guard added last round is right not to
  guess, but it emptied the shipper and the consignee along with the
  bill-to, in silence. Nothing downstream demands a consignee — the SLI
  fills its box with whatever is there, including nothing — so a band
  that could not be read has to say so. Both readers report it now, on
  the PDF path where the titles could not all be located and on the
  workbook path where the band was never found.
- The state-stripping rule took any trailing all-caps word, so every
  city written in capitals lost its last one: `SAO PAULO 01310-100` came
  out as `SAO`, `LOS ANGELES 90001` as `LOS`. A state is either
  comma-separated at any length, which is how `Bangalore, KARNATAKA` is
  printed, or a short code after a space, which is how `Singapore EX`
  is. Neither describes the second word of a city.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
`LA PAZ`, `AL AIN` and `HA NOI` are indistinguishable from `Springfield
IL` by shape alone, and the narrowed rule still took the short word off
each of them — losing it outright, since the State box is not filled
from what was stripped. A short code after a space is now stripped only
where the rest of the line is *not* itself in capitals; a comma still
separates a state at any length, which is how the filed shipments print
one. Leaving a state code in the City box, where whoever keys the
shipment can see it, is better than deleting a word.

The README's state-of-charge paragraph also still had it wrong: the rule
is a requirement for every standalone lithium ion and sodium ion
battery, and for everything packed with equipment except a cell at or
below 2.7 Wh — not only for the two cases I named last round. And the
test count is gone rather than corrected again; it has now been stale
twice in three commits, and `npm run check` prints it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- `applyA181` ran after the declaration-required filter, and every other
  reader merges first. A package whose entries A181 joins was therefore
  partitioned here differently from the way the checks, the checklist
  and the marks list partition it, and the two artifacts on screen
  disagreed about the same box — a quantity row and a checklist reading
  6 kg beside a declaration line printing 3 kg.
- The filename strip took one leading run of dots, so `. .hidden.pdf`
  came out as `.hidden.pdf` — still a hidden file, one space later. It
  takes dots and spaces from both ends now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The commodity table ended on a run of more than four blank rows. That
  count was an arbitrary third rule beside the two real ones, and it cut
  both ways: on a template whose unused slots are wholly blank it
  dropped every line below the gap, and on another it cried truncation
  on every ordinary import. Blank rows are skipped however many there
  are; the table ends at its totals band, or at a row that carries
  something and is not a line, and only that second ending is reported.
- The same-preparation window had no floor, so a record stamped in the
  future — a clock since corrected, a record restored from a machine set
  ahead — satisfied "less than half an hour ago". A genuinely new
  preparation then reused its id and overwrote the earlier retention row
  with a back-dated one.
- An unrated battery was told to find a UN specification mark. Its two
  candidate sections are IA, which requires one, and IB, which A802
  expressly excepts — and the quantity limit for that same entry is
  already stated as the lower candidate, so the two halves of one
  classification were assuming different sections. It is asked for once
  the rating settles which; until then `dg.energy` is the one blocking
  check, which is the whole of what is wrong. PI 976's null section is a
  different answer and still requires the packaging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- `AIRPORT_CLEARANCE` was 4pt measured baseline to baseline, against
  7.5pt type. At the block's own computed capacity the last Airport of
  Departure line still put its descenders through the capitals of
  "Airport of Destination:" — it fitted, and overlapped the box beneath
  it anyway. A full line now.
- An entry's fixed columns — UN number, class, packing group, packing
  instruction and the authorization field — were drawn with no row-fits
  guard, so an entry starting below the foot of the table printed them
  across the Additional Handling Information box, under a warning saying
  those rows had not been printed. Nothing is drawn there at all now.

Not changed: the keying sheet leaves a state code in the City field for
an address written wholly in capitals. `SAO PAULO SP` and `LA PAZ` are
the same shape, and each rule tried against the first has taken a real
city's word off the second — five rounds of counterexamples say the two
cannot be told apart. Keeping a state code where the operator can see it
is the lesser harm; deleting half a city name is not recoverable by
anyone reading the sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The panel stores zero for a cleared overpack box so `dg.overpack-count`
can refuse it — and the consignment total floored that back to one, so
four packages read as two, the two-package battery mark exemption fired,
and the lithium battery mark came off the marks list. That panel sits
above the checks and is read at the packing bench, which is the whole
reason the input was changed to store zero two rounds ago.

No overpack is still a multiplier of one; an overpack whose count is
zero is not. And the exemption now needs a positive package count rather
than merely one at or below two: a consignment of no packages is not a
consignment of two, and an exemption measured against a number nobody
has stated is not an exemption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
An unrated battery has no section, and its two candidates disagree about
packaging: Section IA requires UN specification packaging, Section IB is
expressly excepted from it by A802. Conservative therefore points in
opposite directions for the two halves of the same entry — the *lower*
ceiling for the quantity, the *stricter* requirement for the packaging —
and the last two rounds of this file each picked one of them.

So it picks neither. Demanding a mark blocks a second time on the fact
`dg.energy` is already blocking for; saying none is needed is a positive
permissive claim about a section nothing has decided, and it printed as
one on the review screen and on the bench checklist. The check now says
that the rating decides which applies, names both, and tells the reader
not to take it as either. The moment a rating is entered it resolves to
the real answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The count inputs take whatever is typed, and two descriptions of 1 and
0.5 give a consignment of 1.5 — which sits inside "no more than two
packages". The battery mark came off the list at the packing bench under
wording reading "in a consignment of 1.5 packages". `dg.package-count`
and `dg.overpack-count` refuse counts like that; until they pass, the
exemption says nothing. The per-package cell and battery counts are held
to the same rule for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The early return added two rounds ago stops the fixed columns being
drawn across the box beneath the table, but it also discarded the
clipped-row warning — so an entry beginning below the table foot would
be missing from the declaration with nothing reported. That is not a
formatting problem: it is a sheet short an entry, which nobody may
discover by reading it. It says so now, and says not to offer the sheet.

The paginator's budget and the table's real extent were also two
unconnected numbers, which is the only thing keeping that branch
unreachable. The geometry is stated rather than accumulated — the boxes
above and below the table are what fix it — and a test walks every page
shape the paginator can produce, gaps included, against the floor the
row guard holds them to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
- The consignment's classifications were built from the raw entries,
  bypassing the merge every other reader goes through. They are what the
  air waybill statement is made of — the entire hazard communication for
  an all-Section II consignment — so a package described as packed with
  equipment on the declaration, the checklist, the marks list and the
  quantity checks still named PI 967 on the air waybill.
- A packaging authorization of zero was taken as a real ceiling of
  nothing, blocking the consignment for ever under a quantity message
  that reads like a packing problem and names a remedy that can never
  clear it. The box is optional, so an unusable figure is treated as the
  unstated value it is — and refused on its own terms, the way every
  other numeric field on this screen refuses zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
`write()` reported the failure and resolved anyway, so the item library
panel — which says "N added / M updated" and throws the staged file away
on the strength of the call returning — did both after a write that
never landed. Nothing on disk, an unchanged count on screen, and no file
left to try again with. It returns whether the write happened, and the
panel reports and discards only when it did.

Also restores the any-length state code after a space. The capitalised
guard is what protects `LA PAZ` and `AL AIN`; narrowing the length to
two or three as well cost `Bangalore KARNATAKA` and `Guadalajara
JALISCO` and bought nothing on top of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
…answer

- The shared-packaging count filtered the declaration-required entries
  and then merged, the reverse of the order the declaration itself uses
  and of what its own comment claims. Filtering first can drop the
  packed-with entry a later one would have merged onto, so a package
  printing two lines was counted as one.
- The bench checklist's packaging section was still two-way while the
  check beside it became three. An unrated battery got a flat "strong
  rigid outer packaging" — a permissive claim about a section nothing
  has decided, printed on the sheet that goes to the bench. It says the
  two candidates disagree and to settle the rating first, as the check
  does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
The undetermined branch was keyed on the energy band alone, so it fired
for every configuration. Batteries contained in equipment never take UN
specification packaging in any section — the equipment is its own
enclosure — so nothing about that requirement is in doubt for them
whatever the rating turns out to be, and the branch named two sections
the goods cannot be in while withholding the requirement that does apply
to them: a strong rigid outer packaging, and the stack test whose line
went on printing underneath.

For batteries packed *with* equipment the ambiguity is real but it is
Section I against Section II, and A802 has nothing to do with it — that
exception is written for Section IB of PI 965 and PI 968. Only standalone
is IA against IB. The check and the bench checklist name the pair that
actually applies, or say nothing and let the ordinary requirement print.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
A802 is written for lithium. A standalone sodium ion battery travels under
PI 976, whose packaging requirement is its own, so citing A802 against it
pointed at a provision that does not mention the entry. A181 governs a
package holding batteries both packed with and contained in equipment; it
was pushed unconditionally and so sat in the column M list of every
standalone entry.

Four other corrections in the same pass:

- The declaration dropped "Total quantity per Overpack" whenever the
  overpack held one package, so a single overpack spanning several package
  descriptions had its total printed nowhere. It now prints wherever the
  line quantities do not themselves add up to it.
- The restore banner said what could not be read but not what to do about
  it. The restore effect runs once, so a database that recovers later
  leaves the panels empty for the page's life; the banner now says to
  reload.
- The Commercial Invoice reader warned that the table "ended before its
  totals band" for any non-blank row, including a note line under the last
  item. It now warns only where the row that stopped it carries a part
  number or a quantity, which is what an unread goods line looks like.
Three readers, each mistaking a print habit for content.

The Commercial Invoice header grid had no guard against pdfjs splitting a
label across text items, though the table headings a few functions up are
guarded for exactly that. A split `INVOICE DATE:` was two ordinary cells,
so it ran on to the end of the invoice number — `INV-123 INVOICE DATE:
08/10/2026`, which goes to the SLI, the keying sheet reference and the
output filename — and the date itself was never found. A label is now
matched across up to three items, with a whole single item still winning
first, so an unsplit row reads exactly as it did.

`isTotalsRow` requires a digit on the label's own baseline, which a footer
printed as a bare `TOTAL:` above its numbers does not have. The block slice
then ran past it and the carried block reported the page totals as its own
quantity and value — the failure the function's comment calls the worse
one, because it passes every check. A `TOTAL:` and nothing else is now a
totals row; no commodity is named that.

The keying sheet stripped a trailing state only from a line that was not
itself in capitals, and these address blocks are printed in capitals
throughout, so `CLEVELAND OH 44114` was keyed with a City of `CLEVELAND
OH`. The casing contrast still decides a mixed-case line; on an all-caps
line the token must be a recognised subdivision code, which keeps `LA PAZ`
and `AL AIN` whole.
`sectionFor` read the energy band before it read `prepareToSectionI`, so a
package the shipper recorded as prepared to Section I came back with no
section at all whenever the rating was blank — although both bands it could
fall in lead to Section I: small because that is what was prepared, large
because that is where it belongs.

Five things followed from that one null on the same compliant 20 kg cargo
package:

- `dg.limit` blocked on a 5 kg ceiling against a real 35 kg allowance, and
  offered A99 as the remedy for a figure well under the 35 kg mark.
- `dg.un-packaging` never fired and A802 was absent from column M, a
  permissive miss on a section that does require Packing Group II
  performance packaging.
- `dg.packaging` and the limit's own citation named Section II as a
  candidate the goods cannot be in.
- `dg.energy` blocked generation for a figure that could change none of it.
- `dg.mixed-regulation` dropped the entry as unclassified, so it could
  share a package with Section II goods without the blocking check firing.

`sectionUndetermined` — the existing distinction between a section nothing
has decided and a packing instruction that has none — is now a field on the
classification rather than something four consumers re-derive from the band.
Re-deriving it was what made them disagree: being unrated and having no
section are not the same question. `undecidedPackagingSections` takes the
classification for the same reason.

`dg.energy` gains a third wording, because "nothing waits on this figure"
reads very differently when the packing instruction has no sections than
when the shipper has already chosen one.
…ed it

`dg.mixed-regulation` and the declaration's Section II note both read the
raw entries, so a package holding UN3481 both packed with and contained in
equipment — which A181 makes one fully regulated entry of, and which the
declaration prints as one line — was described by them as a mixture of two
sections. The check blocked generation and told the shipper to split out
Section II batteries the merged package does not have; the note, had the
check not masked it, would have printed directly beneath the line that
covers those batteries to say they travel unlisted.

A181 declines to relabel onto a laxer classification, so a genuine mix —
a fully regulated contained-in entry beside a Section II packed-with one —
still fails, as does a package whose two configurations carry different UN
numbers, which A181 does not join.

`dg.un-packaging` also hard-coded A802 as the source of the Packing Group II
requirement. A standalone sodium ion battery needs that packaging under
PI 976's own terms, and `specialProvisionsFor` deliberately leaves A802 off
UN3551 because the provision is written for lithium — so a blocking check
cited a provision the column M list beside it excludes. It is now named only
where the entry that needs the packaging carries it.
…a no-op

Last round's split-label healing was wired into the party band's terminator
as well, and `labelSpanAt` also matches the cells that delimit the *totals*
band — SUBTOTAL, TAX, FREIGHT. Those are ordinary words: a consignee at
`NIPPON EXPRESS FREIGHT KK`, split into word items by pdfjs, ended the
address band on its own first line, and all three addresses came back
empty. The terminator now counts header-grid labels only, split or not.

`clearAll` cleared six stores under one `Promise.all`, so one rejecting left
the other five erased while the caller reported "Nothing was deleted" and
returned before resetting a single panel — the user told their data was
intact after part of it was gone, over a screen still listing rows that
were not there any more. It now settles each store, resolves with the ones
it could not clear, and rejects only where nothing was deleted at all;
`App` resets the panels whenever anything went and names what is left.

And a successful wipe left the three sticky banners up — they are
deliberately proof against a later write, so nothing else takes them down,
and a cleared machine went on saying it had not recorded a shipment and
that an empty panel was not evidence nothing was saved. The deletion path
clears them.

The outcome decision is split out as `clearOutcome` so it can be tested:
nothing in the test environment provides IndexedDB, and partial-versus-total
is the distinction the screen turns on.
`labelSpanAt` returned the first match it found, so a name that is a prefix
of a longer one won: a split `FREIGHT CHARGES:` matched the totals cell
`FREIGHT` on its first item and stopped, the label was never reassembled,
`freightTerms` came back null and a PREPAID invoice would have been filed
as COLLECT. `TAX` sits inside `SHIPPER EIN / TAX ID` the same way. The
longest match now wins, with a header label beating a totals cell of equal
length, and the span reaches six items so that the longest label on the form
can be put back together at all.

`clearAll` withdrew the profile banner without setting `profileLoaded`, so
the autosave guard stayed in place with nothing on screen to explain it and
every profile edit made after a wipe was discarded in silence. The pair
moves together now, and only where the profile store is one of the ones that
actually cleared. The restore banner comes down only once the retention
records have been re-read: that list is the one panel a wipe does not
refill, and it is evidence for a two-year obligation.

`cityFrom`'s all-caps branch gains the Brazilian state codes and the two
Australian ones it was missing, so `SAO PAULO SP` and `ADELAIDE SA` key the
way their mixed-case forms already did. A subdivision spelled out in full on
an all-caps line still stays in the City box, and the comment now says why
that is the error to prefer.
A partial `clearAll` blanked every panel, including ones whose store had
survived. For the profile that is worse than showing stale rows: the
autosave stays armed against a screen the message beside it says was not
deleted, so the first keystroke writes the empty form over the profile that
is still there. Each panel is now reset only where its own store cleared,
and the profile banner and its `profileLoaded` guard move together.

`isPartyTitle` refuses `SHIPPER EIN / TAX ID:` because that string is a
header label — but only while the extractor leaves it whole. Split into
words its first item is the bare word `SHIPPER`, and the header grid then
answers to the address band's own test, which the band's comment says must
not rest on the order the form prints its rows in. The search now runs
through the same healing the rest of the grid gets; `titlesPartyBlock` is
exported so the separation can be asserted on the split form as well as the
whole one.

`cityFrom` also recognises the placeholders these CIPLs print where a
country has no subdivision — `Singapore EX 498781`, `'s-Hertogenbosch NA
5234`, both cited by the postcode comment a few lines above. They belong in
the City box no more than a real state does, and on an all-caps line the
closed list was the only thing standing between them and the courier's
sorting field.
…ents

"Total quantity per Overpack" was withheld whenever one overpack held one
package description, on the reasoning that it would restate the quantity
column. It does not: the column gives the count and the per-package figure,
not their product, so an overpack holding three 10 kg boxes had its 30 kg
total appear nowhere on the paper. It is now withheld only for the case the
reasoning actually described — one overpack, one package, one entry — and
the package count is accumulated alongside the entry count to say so.

The declaration brings packages sharing an overpack together, because the
overpack wording is written once after the last entry inside it. The bench
checklist numbers its sections and was still following input order, so for
a consignment whose overpacks interleave, the two documents in one envelope
disagreed about which box section 2 described. The ordering moves to
`overpackOrder` beside `applyA181`, for the same reason that one lives
there: both documents need it and they must not disagree.

Two more guards on the deletion path, matching the panels beside them. The
"form generated but not recorded" notice is about a row missing from the
shipment history, so it stands while that store does; and the restore banner
lists stores that could not be *read*, so a store that has just refused to
be cleared is not evidence that its earlier failure has passed.
…eps its type

The editor still numbered packages in entry order while the declaration and
the checklist number them in `overpackOrder`, so for a consignment whose
overpacks interleave, "Package 2" on screen and "### 2." on the bench sheet
were different boxes. `overpackOrder` is now generic in what a package is,
and the editor orders the consignment's own packages through the same rule
the two documents order the assessed ones through.

`energyBand` refused a rating of zero or less but not one that is not a
number at all. NaN fails every comparison, so it went past that guard and
past `stated <= threshold` as well, and came out *large*: Section IA, a
35 kg ceiling, and a check that passes affirming "NaN Wh against a 100 Wh
threshold". It is unknown, like a rating nobody entered.

`safeFileName` returned the bare fallback for a reserved device name,
throwing away the extension the truncation branch a few lines below goes to
some trouble to preserve — a declaration named `CON.pdf` was saved as
`document`, which the operating system cannot open by type. The name is
replaced; the extension is kept.
`overpackOrder` reached the editor, the declaration and the bench sheet last
round and missed the marks-and-labels panel one component over, so for a
consignment whose overpacks interleave it went on numbering in entry order.
That is the worst of the four places for the numbering to disagree: it is
the panel that says which marks go on which box.

Swept the rest at the same time — the only two places that put an ordinal on
a package are these two panels, and `checklist.ts` and `dgd.ts` were already
ordered. The check panel labels packages by their description rather than by
number, so nothing there depends on the order.
…cture

`isHeadingRow` tested every row of the header region, so any row carrying a
bare column name was dropped entire and in silence — `AMOUNT`, `QTY`, `UOM`,
`COO` and `LN` are all ordinary words, and a consignee line reading `Amount
Tower` or a freight term reading `PREPAID AMOUNT AGREED` simply vanished.
Only rows inside the heading band are headings now, and the band reaches
back a row above `PART #` only where that row is a heading row: taking it
unconditionally cost the last line of every address on a form with nothing
printed between the two.

`INCOTERMS`, `PAGE`, `SIGNATURE` and `DATE` are labels on this form and
words everywhere else, and pdfjs hands back a printed run one word at a
time. `3000 Page Mill Road` offered `Page` as a label, which ended the
address band on the consignee's own street and emptied all three blocks with
nothing said; `CARRIER / AGENT: Page Aviation` lost its carrier the same
way. The form prints every one of them with a colon and no value word
carries one, so for a label that is a bare word the colon is what makes it a
label.

And the address band now ends at the commodity table as well as at the first
header-grid label. Stopping on a label alone rested on there being one below
the addresses — the print order the band's own comment says the search must
not rest on — and without one the band ran to the end of the sheet and filed
`PART #` and a part number as the consignee's street.
`missingDeclarationFields` asked for the date and not the place, so
`dg.declaration-fields` passed affirming that every box the shipper is
responsible for had a value — over a declaration whose signature block
printed a bare date. The renderer draws whichever of the two it has, and
the caption above it asks for both.

The field's own comment called it optional, and the panel's hint said so;
both now name the box it belongs to.
@JoelA510
JoelA510 merged commit ca5e797 into main Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants