Skip to content

docs: move the guide content into the site - #83

Merged
soimy merged 31 commits into
masterfrom
docs/content-migration
Oct 1, 2026
Merged

soimy merged 31 commits into
masterfrom
docs/content-migration

Conversation

@soimy

@soimy soimy commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Phase C of #81, stacked on #82. Review order: #82 first — this branch is based on it, so its diff will be rebased onto master once #82 merges (and only then does CI run here; the workflow triggers on PRs into master).

What moves

The README was doing four jobs at once, and CONTRIBUTING described a Jest/CommonJS suite with figures three revisions stale. The detail now lives where navigation can reach it:

Was Now
README: install, tutorial, API list, options, rotation, tags, oversized, logic, algorithm docs/user/: overview, getting started, options, packing, rotation and tags, repacking, persistence, troubleshooting — README is 93 lines again
AGENTS.md + CONTRIBUTING.md: development, testing, architecture, compatibility, release procedure docs/contributor/: the same eight topics as site pages

CONTRIBUTING.md keeps the rules a first-time contributor needs and links out for depth; its stale facts are fixed (vitest/ESM instead of Jest/CommonJS, 8 spec files / 126 passing / 100% coverage instead of 6 / 66 / ~95%, and docs/ is source now rather than something never to commit). AGENTS.md gains the documentation map and the proposal's routing rules, and its clone invariant is condensed to the rule plus a link to the full contracts page.

Claims measured before they were written

Writing the guide against the bundle caught four wrong statements, all fixed:

  • the rotation example claimed rot: true for a rect that already fits — measured false 800 200, because the packer leaves a rect that fits unrotated alone. The example now uses a rect that only fits rotated (512×1024 bin, 1000×300 → true 300 1000);
  • next() returns the index of the next bin, it does not return a bin;
  • addArray() returns nothing, so the documented destructuring could never have worked;
  • bin.repack() returns the rects it could not place, and packer.repack(false) returns early when nothing is dirty.

Examples can no longer rot

verify-package.mjs now runs every block marked <!-- docs-example: name --> inside the temporary consumer that has the real tarball installed — README, getting started and rotation. It caught a missing import in the rotation snippet on its first run, which is exactly the class of breakage that used to be invisible.

Verification

npm test 8 spec files / 126 passed / 2 skipped; cover 100% on all four metrics; lint 0/0; format:check; typecheck (TS 7.0.2); verify:docs; docs:build (29 pages, dead-link check included, spec and plans excluded from the page tree and the search index); verify:package including the three examples.

Content audit — 15 claims were wrong and are now fixed

Every guide page was checked line by line against src/, the specs and measurements on the built bundle, by a reader with no stake in what it said. The passages that did not survive, each now a corrected sentence:

Page said Code does
load() replaces the bins; what you packed is gone Writes over them by index and does not clear the rest — 3 bins that load 1 still hold 3, surplus rects intact
No supported way to rotate one rect against allowRotation: false add(w, h, { allowRotation: true }) builds a Rectangle whose own _allowRotation place() honours — the "Per rectangle allow rotation" spec pins it
A rect that already fits unrotated is left alone Both orientations are scored; 800×300 in a 1000×950 bin comes back rot: true, 300×800
The tag is read from rect.data.tag, "not a property of the rect itself" rect.tag is a supported fallback
Same tag ⇒ same bin A group too large for one bin opens more bins with that tag; in non-exclusive mode addArray() tags new bins not at all
Ties broken by "a hash of the dimensions" A caller-supplied hash property; nothing is computed
bin.clone() copies "the rects, the tag and the data" OversizedElementBin.clone() re-derives data from its rect
Larger than maxWidth × maxHeight ⇒ oversized Only when it fits in neither orientation (640×256 rotates into a 512×1024 bin)

Plus the site home no longer claims a minimum bin count, and EDGE_MAX_VALUE is documented as the default edge size rather than as unused (the AGENTS.md correction on #82).

Changes that live on another branch of this stack are named by their commit subject rather than by hash: the hashes move on every rebase, the subjects do not.

Review round: three more claims did not match the code

Each review finding is fixed in its own commit:

Page said Code does
add()/addArray() both "return that same object" addArray() has no return statement — its return type is void. The contract separates the two, and the AGENTS.md invariant was corrected in the same commit
maxWidth/maxHeight are the bounds "every bin stays inside" new MaxRectsPacker(1024, 1024).add(2000, 2000) leaves an OversizedElementBin whose width, height, maxWidth and maxHeight are all 2000. Both pages now scope the bound to a normal bin
load() restores every bin at its index, options and tag included A saved bin whose maxWidth/maxHeight exceeds the packer's is appended as a placeholder OversizedElementBin of the saved width × height, carrying no options and no tag

Documenting the third one turned up a real loss, recorded in docs/plans/deferred-work.md instead of fixed here: a 1024×1024 packer holding one bin that loads [saved 2048-wide bin, saved 512-wide bin] ends with two MaxRectsBins and no placeholder at all — the second entry assigned bins[1], where the first had just been pushed. A save()/load() round trip silently losing a bin is a behaviour change with its own PR.

Greptile round: five findings, all confirmed and fixed

Greptile reviewed this head with executed reproductions. Every finding held up when I re-checked it against the source, and each fix carries its own measurement:

Finding What was wrong Fix
The persistence example fails It called packer.save() on an undeclared packer and never imported MaxRectsPacker, so the first line threw Two labelled, runnable halves — and the block is marked now, so verify:package executes it against the tarball
Do not re-add saved rects The guide told readers already-placed rects "have to be re-added by the caller", which double-books the space the restored free list has already excluded Rewritten with my own measurement: with 4x10@6,0 free, a new rect fills it in 1 bin while the old rect added again needs 2. The README's save/load block did exactly that, with the same input it had just packed; it loads into a fresh packer now
Guide examples escape verification The example gate searched three named files, so a marker anywhere else was skipped while looking covered It walks the published pages: ✓ documentation examples run (readme-usage, getting-started, persistence, rotation) — four instead of three, and removing the persistence import exits 1 on ReferenceError: MaxRectsPacker is not defined
Build steps are overstated The table credited this layer's docs:build with writing the redirects and checking its output, which arrive with the deployment layer Two steps here, four there — the split development.md already had
Minimum count is not guaranteed The overview said the library "makes a minimum number of images under a maximum size" "aims for a small number of images under a maximum size — a heuristic target, not a guarantee". The old claim survived because the earlier correction only touched the site home

Maintainer review round

  • The README carried the same minimum-count promise, thirty lines above its own "does not promise the fewest possible bins": it creates a **minimum number of images under a maximum size** is now it aims for a **small number of images under a maximum size**. That was the one of the four findings still open when the review landed; the persistence example, the re-add guidance and the hard-coded example list had been fixed in the round before it.
  • The docs:build comment is a per-layer matter, as the review notes: this layer's table says the two steps it runs, the deployment layer says four.
  • The symbol-level companion measurement is in the phase-A report too: 134 documented symbols on the old site, 124 still present, and the 10 that are gone are all private in src/.

@soimy
soimy force-pushed the docs/content-migration branch 2 times, most recently from 058e31b to 087204f Compare September 30, 2026 07:44
@soimy
soimy force-pushed the docs/content-migration branch 2 times, most recently from 31c932c to 6b6c5e5 Compare September 30, 2026 08:24
@soimy
soimy added this pull request to stack #86 September 30, 2026 08:41
@soimy
soimy force-pushed the docs/content-migration branch from 384f0e7 to caf7b14 Compare September 30, 2026 10:32

@soimy soimy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stack #86 review of current head caf7b14b87e480237e50e4e3a69091e8e9466425.

The documentation migration is broadly coherent and the Node 20/22/24 CI matrix is green, including verify:docs, docs:build, coverage, and verify:package. I found three documentation-correctness issues below. They are all in the canonical behavior/user documentation introduced by this PR, so I recommend fixing them before this layer is marked ready.

Comment thread docs/contributor/behavior-contracts.md Outdated
Comment thread docs/user/options.md Outdated
Comment thread docs/user/persistence.md
Base automatically changed from docs/vitepress-structure to master September 30, 2026 10:53
The README was doing four jobs at once — project entry, install, tutorial and API
reference — and CONTRIBUTING carried a Jest/CommonJS description of a vitest/ESM
suite plus test and coverage figures that were three revisions stale. The detail
now lives where the navigation can reach it:

- `docs/user/` gets the guide: getting started, options, packing, rotation and
  tags, repacking, persistence and troubleshooting. The pages were written against
  the source and the built bundle rather than copied: the rotation example now uses
  a rect that only fits rotated (the old one claimed `rot: true` for a rect the
  packer leaves alone — measured), the `next()` description no longer promises a
  bin it does not return, and `save()`/`load()` say plainly that they carry free
  space only.
- `docs/contributor/` gets development, testing, architecture, behaviour
  contracts, compatibility, documentation and releasing, which is the material
  that used to be spread over AGENTS.md and CONTRIBUTING.md.
- `README.md` is an entry point again: what it is, install, one runnable example,
  and links to the guide, the API reference and the contributor pages.
- `CONTRIBUTING.md` keeps the rules a first-time contributor needs and points at
  the contributor pages for depth; the Jest/CommonJS description and the stale
  baseline are gone.
- `AGENTS.md` gains the documentation map and the routing rules from the proposal
  (user details to `docs/user/`, designs to `docs/spec/YYYY-MM-DD-<topic>.md`,
  plans to `docs/plans/`, never edit generated API markdown), and its clone
  invariant is condensed to the rule plus a link to the full contracts page.
- The site navigation covers the guide, the contributor pages, releases and the
  generated API sidebar.

The three principal examples are now checked: `verify-package.mjs` runs every
block marked `<!-- docs-example: name -->` in the installed tarball's consumer,
so a guide snippet that stops working fails the package gate instead of rotting
on the README and the site.
The check runs the command under test in a disposable fixture and compares
inventories of every source file, so the guide says that instead of describing
the sentinel-per-directory it used to watch.
`docs:build` also writes the legacy redirects and runs the output check now, so
the command table and the boundary section say so.
The home page was a hero and one sentence. It now says what the library
optimizes and links into the guide, the API reference, the contributor pages and
the release notes — the same job the README does for the repository, with the
site's own navigation.
An adversarial pass over every guide page, checking each claim against `src/`,
the specs and measurements on the built bundle, found seventeen passages that
were wrong or overstated. The measured ones:

- `load()` does not replace the bins: it writes over them by index, so a 3-bin
  packer that loads 1 still holds 3 (`[0, 1, 1]` rects) — the surplus keeps
  what it had.
- Per-rect rotation *is* reachable — `add(w, h, { allowRotation: true })`
  builds a `Rectangle` whose own `_allowRotation` `place()` honours, which the
  "Per rectangle allow rotation" spec pins. What stays true is that a plain
  object's field is ignored and that it never overrides the oversized check.
- A rect that already fits unrotated can come back rotated: both orientations
  are scored and the rotated one can win (800×300 in a 1000×950 bin → `rot:
  true`, 300×800).
- The tag falls back to `rect.tag` when `data.tag` is absent, one tag group can
  span several bins, and the bins `addArray()` opens in non-exclusive mode carry
  no tag at all.
- The sort tie-break reads a caller-supplied `hash`, it is not computed from the
  dimensions.
- `OversizedElementBin.clone()` re-derives `data` from its rect and does not
  carry a bin-level `data`.
- A rect only counts as oversized when it fits in *neither* orientation (640×256
  in a 512×1024 packer rotates into a normal bin).

Also: the site home no longer claims a minimum bin count, and two contributor
pages lose stale claims — `EDGE_MAX_VALUE` is the default edge size, not unused,
and `dist/maxrects-packer.d.ts` re-exports five names.
Auditing every JSDoc comment against the code and the bundle turned up nine places
where the generated API page described something the library does not do. The
public ones, each measured before rewriting:

- `MaxRectsBin.clone()` promised "the same size, options, tag, data and rects". The
  copies are re-packed, so a bin holding rotated rects comes back with different
  placements (17x15 with two rotated rects -> 15x18 with none), and an untouched
  16x19 bin made `clone()` throw. The `@returns` and the throw paragraph now say so,
  and `docs/plans/deferred-work.md` carries both reproducers plus a fix direction
  this branch verified through the public API but deliberately did not implement —
  changing what `clone()` returns is its own PR.
- `Rectangle.oversized` read as "bigger than the packer itself". It also flags a
  rect no bin could grow to hold: 1000x2000 fits a 1024x2048 packer and still lands
  in an `OversizedElementBin` under `square`.
- `next()` said it returns a new bin; it returns the index the next bin will take
  and creates nothing (`bins.length` unchanged).
- `load()` said it overwrites existing bins; it replaces by index, appends an
  oversized one, and keeps whatever is past the loaded array.
- `OversizedElementBin.clone()` promised the same `data`; a two-argument source
  reports `null` and its copy `{}`.
- `Rectangle.Clone`'s `@returns` now matches its prose: the source's own values win
  over whatever the class's `clone()` returned.

Three more are source-only (`private`, so off the site): `updateBinSize`'s
`considerRotation` only applies when `options.allowRotation` is on, and `sort()`'s
`logic` is the `PACKING_LOGIC` enum, not the strings "area"/"edge" — following the
old text returned the MAX_AREA order silently.

`docs/contributor/behavior-contracts.md` also stops presenting the replay's
placement fidelity as a guarantee, and the ledger gains the finding with its table.
Two of three CI legs failed on this branch with `Test timed out in 5000ms` in
`test/efficiency.spec.js > combined best of`, while the third passed on the same
commit and re-running the jobs turned all three green. The test measures the whole
candidate table: 3328ms on a green run against vitest's 5s default. Recorded in the
ledger with its own fix (an explicit timeout for that measurement) rather than
changed here, since what a gate tolerates is its own decision.
This page described `scripts/verify-docs-output.mjs` and the three things it
asserts, but the script arrives with the deployment phase — the same
statement-ahead-of-its-branch shape the review flagged on AGENTS.md. The
description moves to the commit that introduces the script, where it can also be
written against what the check actually does today (pages, links and anchors, nav
reachability, search index, base prefix) rather than the three assertions it
started with.
Scanning the built pages for unrendered markdown turned up one real artefact:
VitePress strips `{#custom-id}` from a heading's text but leaves it in the
permalink's `aria-label`, so a screen reader reads the braces out. Measured on
the behaviour-contracts page — nine headings use the syntax, eight of them show
the braces in the label, and exactly one of those anchors is linked from
elsewhere. Recorded with both ways out rather than changed on the spot, because
dropping the eight would turn clean anchors into `_2-tag-grouping`-style slugs.
@soimy
soimy force-pushed the docs/content-migration branch from 5094e97 to dd874d0 Compare September 30, 2026 10:53
The behaviour-contracts page said both "return that same object", but `addArray()` has no
return statement: its return type is `void` and callers get `undefined`. The invariant in
`AGENTS.md` carried the same wording and is corrected with it.
`maxWidth`/`maxHeight` are not a bound "every bin stays inside": an oversized rect gets an
`OversizedElementBin` sized to the rect itself, so `packer.bins[0].maxWidth` can exceed the
packer's width. The options page and the "What a bin holds" list now say normal bin, and
point at the oversized section.
A saved bin whose `maxWidth`/`maxHeight` exceeds the packer's is not restored at its index:
`load()` appends an `OversizedElementBin` of the saved `width` x `height` and ignores the
index, so it carries no options or tag and a later entry can overwrite it. Measured on the
sources; the page states the append and the ledger records the overwrite.
@soimy
soimy marked this pull request as ready for review September 30, 2026 11:02
The hero tagline promised bins "each staying inside maxWidth x maxHeight", which the oversized
placeholder bin does not: it is sized to the rect. The tagline now names that exception, as the
options and packing pages do.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

One more of the same class, found while auditing the contributor pages line by line against the repository: the site home repeated the absolute bound.

The hero tagline read "Packs rectangles into few bins, each staying inside maxWidth × maxHeight", which an OversizedElementBin does not satisfy — measured, new MaxRectsPacker(1024, 1024).add(2000, 2000) leaves a bin of 2000×2000. It now reads "Packs rectangles into few bins within maxWidth × maxHeight — a heuristic, not a minimiser. A rect that cannot fit at all gets a placeholder bin of its own." (commit: docs: scope the site home's size claim the same way).

The rest of that audit came back clean, with the measurements behind it: oxlint/oxfmt really do declare ^20.19.0 || >= 22.12.0; the baseline is 8 spec files / 126 passed / 2 skipped (re-measured on this head); release.yml uploads the four bundles plus dist.zip and has no npm publish step; typedoc's peer range tops out at 6.0.x; test/index.spec.js, the 99/98/99/99 thresholds and verify-coverage.mjs gate exactly what the testing page says; and cz-conventional-changelog does default to maxLineLength: 100, with 91% of the last 200 commits' body lines inside it.

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Low risk] Reorganizes documentation into a new site structure.

No outstanding findings block merging.

Summary

The PR moves usage and contributor guidance into the VitePress site, updates navigation and JSDoc, shortens the README and CONTRIBUTING guide, and checks marked documentation examples against the packaged library.

Reviews (13) · Last reviewed commit: "docs: scope the node10 workaround to Typ..."

Comment thread docs/user/persistence.md Outdated
Comment thread docs/user/troubleshooting.md Outdated
Comment thread scripts/verify-package.mjs Outdated
Comment thread docs/contributor/documentation.md Outdated
Comment thread docs/user/index.md Outdated
The gate looked for `<!-- docs-example -->` markers in README.md and two named guides, so a
marker anywhere else escaped it while appearing covered. It now walks the published pages
(`docs/`, minus the generated `api/` and the excluded `spec/` and `plans/`).

Measured: with the persistence example's import removed, the old list passed and the new one
exits 1 on `ReferenceError: MaxRectsPacker is not defined` in `example-persistence.mjs`.
The persistence page's example called `packer.save()` on a packer it never declared and never
imported `MaxRectsPacker`, so copying it failed at the first line. It is two labelled, runnable
runs now, and it is marked for the example gate.

Both it and troubleshooting said already-placed rects "have to be re-added by the caller", which
is the wrong move: the restored free space already has their area taken out. Measured on a 10x10
bin holding a 6x10 rect (a 4x10 strip left): a new 4x10 rect fills it in one bin, the 6x10 rect
added again needs a second. The README's save/load block did exactly that, with the same `input`
array it had just packed; it loads into a fresh packer and adds only new rects now.
It said the library "makes a minimum number of images under a maximum size" — the claim the
packing and troubleshooting pages, and the repository rules, say it does not make. It aims for a
small number, heuristically, and now says so.
The report's Phase A table adds up to 129 preserved anchors while the deployment page says 105, and
it links here for the inventory, so the two contradicted each other. The mapping stands; the counts
do not: the column is marked superseded, the five criteria tried are listed (none of them yields
129), and a re-measured section states the 105/170 split and the three buckets it falls into.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

All four are addressed. Three of them were already fixed on the head that was current when you wrote this (fd3d7fc was before that round), and the fourth — the README half of the minimum-count wording — was the one still open, so thank you for pointing at it.

# Finding State
1 The persistence example is not runnable Fixed in docs: show the persistence round trip as two runnable halves — two labelled, self-contained runs, and the block now carries a docs-example marker
2 The guidance tells users to re-add placed rects Fixed in the same commit, with my own measurement: a 10×10 bin holding a 6×10 rect saves with 4x10@6,0 free, a new 4×10 rect then fills that strip in 1 bin while the 6×10 rect added again needs 2. packer.rects is named for keeping the old placements, and the README's save/load block (which re-added the very input it had just packed) loads into a fresh packer now
3 The guide promises a minimum bin count — including the README The guide overview was fixed in docs: stop the guide overview promising a minimum bin count; the README still said "it creates a minimum number of images under a maximum size" thirty lines above its own "does not promise the fewest possible bins". Now fixed in docs: stop the README promising a minimum number of images — "it aims for a small number of images under a maximum size"
4 The example gate covers three hard-coded files Fixed in test: run every marked documentation example, not three named files: scripts/verify-package.mjs walks the published pages (README.md + docs/, minus the generated api/ and the srcExcluded spec//plans/). Measured both ways — with the persistence import removed it exits 1 on ReferenceError: MaxRectsPacker is not defined, and on the fixed head it reports ✓ documentation examples run (readme-usage, getting-started, persistence, rotation), four instead of three

On the docs:build comment: agreed, and that reading is what the two layers now say — this branch's commands table describes the two steps it actually runs, and the deployment branch restores the four-step description where the redirects and the output check exist. Same split development.md already had.

I replied to Greptile's inline threads individually rather than restating them here.

The symbol section covered old site -> new site. The other direction had not been measured: of the
76 members the source declares public across the eight exported types, every one has an entry on
its API page, so the reference the site publishes omits nothing the source exposes.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

One more completeness measurement, in the direction nobody had checked. The symbol coverage I reported earlier went old site → new site; the reverse — does the generated reference document everything the source exposes — was unmeasured, and it is the direction where a stray JSDoc tag can hide a public member (the @private on MaxRectsPacker.sort, for instance, is only harmless because the method really is private in TypeScript).

Result: 76 public members across the eight exported types (Bin, IBin, MaxRectsBin, MaxRectsPacker, OversizedElementBin, Rectangle, IRectangle, IOption), optional interface properties included — every one has an entry on its API page. Nothing the source declares public is missing from the reference the site publishes.

The method is written into the phase-A report next to the symbol section (commit: docs: check the reference against the source, not only the old site): read the members out of src/, skip declarations marked private/protected or tagged @private, then look for each name in an id or in the page text of docs/api/, per declaration — so a getter/setter pair counts once. My first pass missed the optional members (?) and reported IOption as empty, which is why the count is 76 and not 68.

…ed it

Running every js block on the published pages, not only the four marked ones, turned up two that
read as complete — each constructs its own packer and prints or places something — but threw
`ReferenceError: MaxRectsPacker is not defined` when copied: the oversized example in
`docs/user/packing.md` and the tag example in `docs/user/rotation-and-tags.md`. Both import the
package now and carry a `docs-example` marker, so `verify:package` runs them: four examples
before, six after.

The other blocks that fail standalone are fragments by design — placeholder names like
`packer.add(width, height, data)`, a signature block, or a one-liner continuing the block above it
— and are left as they are.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Same method Greptile used to catch the persistence block — run the code — applied to every js block on the published pages, not just the four marked ones. Fourteen blocks across seven files; five execute cleanly; the rest are fragments by design, except two that read as self-contained and were not:

Block Result when copied
docs/user/packing.md, the oversized example (const packer = new MaxRectsPacker(1024, 1024) … console.log(packer.bins[0].rects[0].oversized)) ReferenceError: MaxRectsPacker is not defined — it constructs its own packer and prints a value, so it invites copying, but nothing imported the package
docs/user/rotation-and-tags.md, the tag example (new MaxRectsPacker(1024, 1024, 0, { tag: true })) the same — and the page's first block does import, which makes the omission look deliberate rather than contextual

Both now import the package and carry a docs-example marker, so the gate runs them (commit: docs: import the package in the two self-contained examples that missed it). npm run verify:package reports six examples where it reported four: readme-usage, getting-started, oversized, persistence, rotation, tags.

The blocks I left alone fail standalone for reasons that are correct: packer.add(width, height, data) and friends use placeholder names, options.md's first block is the constructor signature, repacking.md's one-liner continues the block above it, and persistence.md's state.json line continues the marked block. One failure was my harness rather than the docs: getting-started.md's CommonJS block (const { MaxRectsPacker } = require(...)) is correct in a .cjs file and only fails because I ran every block as ESM — the package gate already covers that form by package name.

The report had drifted from what the stack does. Its page-mapping table still said
`hierarchy.html` had no successor while the redirect writes one and the re-measured table counts
its anchors; two of its three "open decisions" have since been taken — Pages over Vercel, which is
what forgoes preview deployments for pull requests, and `excludePrivate: true` — and the phase-B
scope never said which PR carried it. The TypeDoc-warnings question stays open, because it is.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

A coherence pass over the phase-A report, since three review rounds have now edited documents around it and the report is what docs/contributor/documentation.md sends readers to. Three things had drifted:

Commit: docs: close the loops the phase-A report left open.

Three of the four references in the ledger had drifted: `clone()` was cited two lines into its own
docblock (this stack's JSDoc corrections added lines above it), the rotation swap pointed at the
`const rotated` read instead of the assignment, and the oversized `load()` branch was cited one
line above its `push`. Each corrected number was verified by printing the line it now names.

The fourth is left alone on purpose: it points at a line that no longer exists, and says so.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Two verifications this round, both of things the stack claims but nothing checked.

The 11 legacy redirects point at the right pages, not merely at existing ones. The build gate can only assert that a redirect's target exists — a table entry pointing classes/Bin.html at api/classes/MaxRectsBin.html would pass it. I read all eleven target strings out of the build: five classes/* → the same-named class page, three interfaces/* → the same-named interface page, enums/PACKING_LOGIC.html → api/enumerations/PACKING_LOGIC.html, and modules.html/hierarchy.html → api/index.html, every one carrying the base prefix. 11/11 correct.

The ledger's line references had drifted. Three of its four file:line pointers no longer named what they claim — MaxRectsBin.clone() was cited two lines into its own docblock (this stack's JSDoc corrections added the lines above it), the rotation swap pointed at the const rotated read instead of the assignment, and the oversized load() branch was cited one line above its push. All three now land on the construct named, verified by printing each line; the fourth is left alone because it points at a line that was deleted and says so (commit: docs: point the ledger's line references back at the code).

Also checked and clean, for the record: every URL in the published README resolves (the one 404 is docs/user/troubleshooting.md, which this branch adds), and the badge endpoints answer 200 apart from bot-protection responses.

The commands table reads as if `docs:preview` stands on its own, but it loads the site config, and
that config imports the generated sidebar: measured on a cleaned tree, it stops at
`Could not resolve "../api/typedoc-sidebar.json"` rather than saying there is no build. The other
commands are self-sufficient — `docs:dev` generates the API itself, verified from the same clean
tree — so the note names the one that is not.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Ran every command the documentation page lists, on a tree that had just been cleaned, because the table reads as if they each stand alone:

Command From a cleaned tree
docs:api, docs:build, docs:clean, verify:docs as documented
docs:dev works — regenerates the API itself, then serves at http://localhost:5173/maxrects-packer/
doc:json works — exits 0 and writes the JSON (the markdown plugin also regenerates docs/api/, which is generated and ignored either way)
doc, doc:clean, doc:serve aliases of the above
docs:preview stops at Could not resolve "../api/typedoc-sidebar.json"

That last one is the documented-but-unstated dependency: vitepress preview serves what the last build wrote, but it still loads the site config, and the config imports the generated sidebar — so after docs:clean the failure is a config-resolution error, not "there is no build". The page now says so and points at docs:build (or docs:dev, which generates the API itself) rather than leaving the reader to rediscover it. Commit: docs: say what docs:preview needs before it can serve.

`docs/user/index.md` still opened with "each staying inside a maximum width x height", which the
oversized placeholder breaks — the same exception the landing page, `options.md` and `packing.md`
already state. The opening now says normal bins and names the fallback; the `square` row in
`options.md` loses its "every bin" for the same reason.

Measured on the built bundle: a 100x100 packer given a 500x50 rect produces one 500x50
OversizedElementBin, larger than its own bound; with `square: true` a normal bin is square (60x10 ->
60x60), and a rect that cannot grow square inside the bounds (100x50 bounds, 60x10 rect) becomes a
60x10 placeholder rather than a non-square normal bin.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Fixed, and I swept the rest of the tree for the same class of claim before touching anything.

docs/user/index.md now reads: "aims to pack rectangles into as few normal bins as possible within a maximum width × height … A rect that cannot fit gets an oversized placeholder bin of its own" — the vocabulary options.md and packing.md already use.

One more instance of the same sentence shape, one table row away: options.md said square → "Keep every bin square". The oversized placeholder is the rect's own size, so it is not square either. It now says "every normal bin square", which I measured rather than assumed: with square: true a normal bin is square (60×10 → 60×60), and a rect that cannot grow square inside its bounds (100×50 bounds, 60×10 rect) becomes a 60×10 OversizedElementBin — not a non-square normal bin. So "normal" is exactly the qualifier that holds.

The measurements behind the opening sentence, for the record: a 100×100 packer given a 500×50 rect produces one 500×50 OversizedElementBin (bin.width > packer.width) — the bound is the normal bins' bound, which is what the sentence now says.

Left alone deliberately: the "aims for a small number of images under a maximum size" sentence (here and in the README). That one is the optimizer's target, already qualified as "a heuristic target, not a guarantee", and it is not a claim about every bin.

Commit: docs: stop claiming every bin stays inside the bound.

The border/padding paragraph derives its conclusion from the free-space formula (`maxWidth + padding -
border * 2`), which reads as if `padding` raised the size the packer takes. It does not: the oversized
check compares against `maxWidth`/`maxHeight`. Measured — a 100x100 packer with `padding: 4` gives its
first bin a 104-wide free rectangle and still reports a 104-wide rect oversized, while a 100-wide one
fits. The sentence now says which of the two the formula describes.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

After your finding I stopped reading the guide and started executing it: every page that asserts behaviour was loaded against the built bundle (dist/maxrects-packer.mjs) and each claim re-measured. Results, so the accuracy is on record rather than assumed:

options.md — defaults. All eight, read off the effective bin.options after a plain pack: smart true, pot true, square false, allowRotation false, tag false, exclusiveTag true, border 0, logic = MAX_EDGE. Effects too: 60×10 in a 100×100 packer → 64×16 with the defaults, 60×10 with pot:false, 100×100 with smart:false, and border:4 → 68×18.

rotation-and-tags.md. The example's comment (true 300 1000) is exact. The per-rect narrowness holds in both directions, on a discriminating fixture — a 70×100 rect leaves a 30-wide gap, then a 90×30 rect is offered:

Form Result
plain {90,30} 2 bins — no rotation
new Rectangle(90,30) 2 bins
new Rectangle(90,30,0,0,false,true) 1 bin, rot: true, 30×90
add(90, 30, { allowRotation: true }) — the form the page names 1 bin, rot: true, 30×90
plain {90,30, allowRotation:true} 2 bins

and a rect that only fits rotated (120×40 in 100×50 bounds) is oversized whatever the per-rect flag says, exactly as the second bullet states. Tags: data.tag beats rect.tag, a bare tag property works, the default keeps hud/world apart, exclusiveTag:false puts both in one untagged bin, and an untagged bin refuses a tagged rect.

repacking.md. All seven setters bump the rect they are written on and the bin and packer follow; packer.dirty === bins.some(bin => bin.dirty); the quick repack rewrites x/y; bin.repack() returned ["200x40"] and left the bin empty; and the plain-object warning is real — after plain.width = 200 the packer is still clean and the quick repack skips the bin, while bin.setDirty() + repack() moves the rect out to a placeholder.

troubleshooting.md — one clarification added. square:true with maxHeight > maxWidth does report a tall rect oversized (50×150 in 100×200; it fits at 200×200), and border:5 does reject a maxWidth-wide rect while a 90-wide one fits. But the border/padding sentence derived its conclusion from the free-space formula, which reads as capacity — and it is not: a 100×100 packer with padding: 4 gives its first bin a 104-wide free rectangle and still reports a 104-wide rect oversized, because the check compares against maxWidth. I misread it that way myself while probing, so the sentence now says which of the two the formula describes (commit: docs: say that padding widens free space, not what the packer accepts).

A CommonJS TypeScript project resolving modules the `node16`/`nodenext` way cannot import the package:
the declarations are ESM (`"type": "module"`, no `exports` map), so the compiler reports TS1479 for a
static import and TS1471 for `import ... = require(...)`. The runtime is unaffected — a JavaScript
`require()` works and the package gate covers exactly that — and `verify:package` cannot see it because
its type fixture is deliberately an ESM consumer.

Measured on the published tarball from a CommonJS consumer: TS1479/TS1471 as above, while
`await import(...)`, `moduleResolution` `node10`/`bundler` and plain `require()` all work, and neither
`skipLibCheck` nor the compiler (TS 6 and TS 7) changes anything. Troubleshooting now lists it with the
workarounds, and compatibility says what the fixture's coverage stops short of.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Found by executing the contributor pages' claims rather than reading them — and this one is a real gap, not a wording problem.

A CommonJS TypeScript consumer cannot import the package under node16/nodenext. Measured against the published tarball, from a .cts file compiled with module/moduleResolution node16:

How the consumer imports Result
import { MaxRectsPacker } from "maxrects-packer" TS1479 — "the referenced file is an ECMAScript module and cannot be imported with require"
import pkg = require("maxrects-packer") TS1471
await import("maxrects-packer") compiles
moduleResolution node10 or bundler compiles
plain JavaScript require("maxrects-packer") works at runtime, and verify:package gates exactly that

skipLibCheck either way changes nothing, and TS 6 and TS 7 agree. The cause is the module format of the declarations ("type": "module", no exports map); the runtime is unaffected.

Why the gate cannot see it: verify:package writes its consumer as ESM on purpose — "so that node16 and nodenext read the fixture as ESM" — which is precisely the shape that compiles. The failing shape is the one thing the matrix does not include. I have not changed the gate: covering it would mean asserting a limitation rather than a property, and the fix is not ours to make here.

What changed (commit docs: name the one consumer shape the type gate cannot cover): troubleshooting gained a section with the table and the three workarounds, and compatibility now says what the fixture's coverage stops short of instead of leaving the matrix to read as "all shapes covered". The fix — an exports map with per-format conditions and declarations — also seals off the deep imports maxrects-packer/dist/... that keep working today, so it belongs to 3.0.0 and is recorded in the deferred-work ledger rather than attempted here.

The rest of that sweep held up, for the record: testing.md's baseline reproduced exactly (8 passed (8), 126 passed | 2 skipped, coverage 100 | 100 | 100 | 100 — the src/index.ts row reads 0% only because the barrel has no statements of its own, 0 statements/0 functions/0 branches in coverage-final.json); development.md's engine range is exactly what oxlint 1.83.0 and oxfmt 0.68.0 declare; the CI matrix is [20.x, 22.x, 24.x]; releasing.md's two squash settings are what the repository actually has; and TS 7 does remove all three options the compatibility page lists (baseUrl TS5102, moduleResolution=node10 and target=ES5 TS5108) while TS 6 only deprecates them.

The page opens with "Each one is pinned by a spec in `test/`", and for contract 1 three of its promises
are not: `add()` returning the same object it was given, `addArray()` returning nothing, and the
multi-argument overload returning an internal `Rectangle`. Searched the suite — the only
`toBe(<a rect>)` calls are six `not.toBe(rect)` isolation checks in `test/clone.spec.js`, and every
test that captures an `add(...)` result passes an inline literal, so it only proves the return is
defined. The in-place half is pinned; a regression that returned a copy would keep every gate green.

The contract now says which half the specs assert and which half is only what the signatures promise,
and the ledger records the three assertions that would close it.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Audited the opening claim of behavior-contracts.md — "Each one is pinned by a spec in test/" — against the suite, contract by contract. Seven of the eight hold up; contract 1 has three details that nothing asserts:

Contract 1's promise Pinned?
add() writes x/y/rot/oversized onto the object handed to it yes — the caller's own object is checked (expect(rect.oversized).toBe(true))
add() returns that same object no
addArray() returns nothing no
the add(width, height, data) overload returns an internal Rectangle, not what you passed no

The search: the only toBe(<a rect>) calls in the whole suite are six not.toBe(rect) isolation checks in test/clone.spec.js, and every test that captures an add(...) result passes an inline literal (const rect = packer.add({ … })), so it can prove the return is defined but never which object it is. The practical consequence: a regression that quietly returned a copy would keep every gate green.

Commit: docs: stop claiming every contract is locked by a spec — contract 1 now says which half the specs assert and which half is only what the signatures promise, and the ledger's "Test infrastructure" section carries the three assertions that would close it (its own PR, since it changes what the suite promises rather than what a page says).

For the record, the other seven contracts map cleanly: tag grouping (23 tag assertions in the packer spec), next() (6), dirty propagation (both specs plus rectangle.spec.js), save/load free space (the dedicated test, plus the ledger item for the placeholder overwrite), size accounting (18 border/padding tests in the bin spec), generics (test/generictype.spec.js), clone isolation (26 tests in test/clone.spec.js).

`add(width, height, undefined)` under `tag: true` throws an internal
`TypeError: Cannot read properties of undefined (reading 'tag')` — the single-argument overload guards
the same read, this one does not. Measured on the built bundle; it was recorded in the agent guide only,
so the user guide now has a section for it and the `add()` JSDoc says the argument has to be an object in
that mode, which puts the note on the generated API page as well.

Whether a bad call should keep reporting an internal message is a separate decision — the ledger's code
changes section carries it: rejecting `undefined` explicitly, or treating it as `{}`, changes what a
caller sees on a bad call rather than what a valid call packs.
@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Continuing the audit into the rest of the contracts — and the first bullet here is a user-facing gap, not just a missing assertion.

add(width, height, undefined) throws an internal TypeError when tag: true. Measured on the built bundle: new MaxRectsPacker(100, 100, 0, { tag: true }).add(50, 50, undefined) is TypeError: Cannot read properties of undefined (reading 'tag'), thrown by rect.data.tag, while the single-argument form guards that same read with && and packs into one bin. It was recorded in the agent guide only, so a user following the guide hit an error message that names nothing they wrote. It now has a section in troubleshooting (with the measurement and the two workarounds) and the add() JSDoc says the argument has to be an object in that mode — which puts it on the generated API page, verified in the built HTML. The question of whether a bad call should keep reporting an internal message is in the ledger's code-changes section, since changing it changes what a caller sees rather than what a valid call packs.

The rest of the contracts held up, with the checks that settled them:

Claim Pinned by
quick repack touches only dirty bins the test that cleans every bin, shrinks one rect, then asserts quick leaves 3 bins where deep repack gives 2
reset(true, true) replaces the options expect(bin.options.tag).toBe(false) after reset(true, true)
generics survive a clone test/generictype.spec.js plus the subclass cases in test/clone.spec.js
next() only affects what comes after the "adds to new bins after next() is called" test

One more unpinned detail, for completeness: contract 2's "a bin whose clone() throws counts as one the group does not fit, so a single uncopyable bin can never fail the whole addArray() call" — the specs assert that bin.clone() throws (four places), but none exercises addArray() in non-exclusive mode against a bin that cannot be cloned, so the survival half of that sentence is unpinned too. It is in the same ledger bullet as the return values.

@soimy

soimy commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Correction to the last paragraph of my previous comment: contract 2's survival claim is pinned, and I misread the suite.

test/maxrects-packer.spec.js (the test around line 357) puts a #private-backed rect with no clone() into bins[0], asserts bins[0].clone() throws with "give its class a clone() method", and then asserts that addArray([…two tagged rects…]) in non-exclusive mode not.toThrow() — with bins[0] still holding its single rect and the group in bins[1]. That is exactly "a bin whose clone() throws counts as one the group does not fit, so a single uncopyable bin can never fail the whole addArray() call".

My grep looked for toThrow assertions and so walked straight past a not.toThrow(). Nothing to add to the ledger for it: the bullet there covers the three return-value promises only, which is what the previous paragraph listed.

Comment thread docs/user/troubleshooting.md Outdated
@soimy

soimy commented Oct 1, 2026

Copy link
Copy Markdown
Owner Author

State of the four items from the stack re-review, all addressed on the current head:

  1. Persistence example — docs: show the persistence round trip as two runnable halves. The block imports both fs functions and MaxRectsPacker, declares packer before save(), and restores into a separate next packer. It is one of the six examples the package gate now actually runs against the installed tarball: ✓ documentation examples run (readme-usage, getting-started, oversized, persistence, rotation, tags).
  2. Re-adding placed rects after load() — the example's later half adds only new rects and says so in the comment; persistence.md's "What is stored" section tells readers to keep their own input list alongside save(), and README.md (the packer.save() / next.load(saved) lines) carries the same correction.
  3. Minimum bin count — the phrase is gone from docs/user/index.md and README.md: grep -rn "minimum number" README.md docs/user docs/contributor returns nothing, and both pages now say a small number of images under a maximum size with a link to the guarantee section.
  4. Example-gate coverage — test: run every marked documentation example, not three named files. The check walks every .md under docs/ (skipping the generated api/, and spec//plans/, which srcExclude keeps off the site) plus README.md, and throws when it finds no example at all. That broadening is what surfaced the broken persistence block in the first place; all six markers now run.

The one finding that arrived after that review — Greptile's "TypeScript 7 rejects node10" — is fixed as well in docs: scope the node10 workaround to TypeScript 6, with the re-measurement in that thread.

Stack state after the fix: #83 273f6d3, #84 6500382 (16 commits above it), #85 91f973a (4 above #84). Local gates on the top layer: lint, format:check, typecheck, verify:docs, docs:build, build and verify:package all pass, the last one reporting 28 intended files and the six examples above.

@soimy
soimy merged commit 7dacecc into master Oct 1, 2026
4 checks passed
@soimy
soimy deleted the docs/content-migration branch October 1, 2026 16:12
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.

1 participant