fix: four findings left open by the previous two reviews - #49
Merged
Merged
Conversation
- 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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Status
FindingsNo issues found in changed scope. Proposed patchesNone CI failure mappingCI is still running (pending). Diagnostic plannpm 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
MetaProcessed at 2026-08-12 14:09:54.465 UTC |
- 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
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Export-control settings leaked between shipments (
App.tsx). The saved-consignee lookup and the settings reset shared onetry, so a rejected read skippedsetSettings(base)entirely.defaultShipmentSettingsblanks 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.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.The retention history read a day short (
features/dangerous-goods.tsx). The prepared column showedpreparedAt.slice(0, 10)— a UTC date — beside aretainUntilcomputed 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 existinglocalDatehelper (whichretainUntilhad been duplicating).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
npm run checkgreen: typecheck, lint, 578 tests, build.App.tsxchange is covered by review rather than a test.🤖 Generated with Claude Code
https://claude.ai/code/session_01GZa293WkqauW7YPZQCzPf1
Generated by Claude Code