uid: T-209 title: Company document signing β open the chop/signature pipeline beyond WOPC and IR56M, gated on appointed signers status: doing area: documents-signing created: 2026-09-14 updated: 2026-09-14 owner: girafeev1 assignee: Records (Infrastructure) related: T-196, T-204, T-047, T-089, T-022, T-192
T-209 β Sign other company documents with the company chop¶
Why (owner, 2026-09-14)¶
"I'd like to utilize the existing company chop stamping and signing pipeline and open it up for signing other company documents⦠As far as I'm concern, Our web app's RBAC should require dedicated personnel for document signing, please look into it and make sure that our new company document signing pipeline oblige to that."
Two asks: (1) a general signing pipeline for company documents, reusing the chop/signature machinery; (2) it must be gated on dedicated personnel, per the RBAC model.
What this lets the company do (plain words first, per AGENTS.md)¶
The situation before. The company's digital chop β the round seal β and the signing flow existed, but only two documents could use them: the payment confirmations issued to people being paid (WOPC), and the annual employer's tax return (IR56M). Every other company document β a contract, an authorisation letter, a bank form β had to be printed, chopped by hand, and scanned back in. The web app could not touch it.
What a person can do now. Someone who has been appointed as a signer opens a new Company Documents page, uploads a finished PDF (drafted wherever they normally draft it), and says what kind of document it is. They then see the document on screen and click the spot where the seal should go β the chop appears under the cursor at its true physical size, tilted slightly so it reads as hand-applied. They type their two-factor code, and the app presses the seal onto the PDF, files the signed copy in the company's Drive, and writes down who did it, when, and from what device.
Who is allowed to do it. Not a job title, and not "administrators". Only people named individually as allowed to sign that particular kind of document β the same principle the owner set for moving money: specifically appointed people, not a role. Being allowed to sign contracts does not make someone allowed to sign bank forms. An administrator grants it person by person, kind by kind, on that person's Contacts record. And because the person who uploads is the person who signs, uploading is itself restricted to appointed signers β someone who can open the page but holds no appointment sees an explanation instead of an upload button.
What is still NOT true. - Nobody can sign anything yet. No one holds an appointment until the owner grants them on the Contacts record, and the gate refuses everybody until then. This is the first thing to do. - Only ERL can actually be chopped β MEL and EPL have no seal images uploaded, so a document filed under them can be uploaded but there is no seal to press. - Voiding works but has no button β the ability is built and reachable, with nothing in the interface to trigger it. - None of it has been driven in a browser. This environment has no app credentials, so the work is proven to compile and pass its tests, not proven to work on screen. The first real use should be a document that would not matter if it went wrong.
Research β what already exists (2026-09-14, Records (Infrastructure))¶
The pipeline has already been generalised ONCE. There are two parallel implementations:
| WOPC | IR56M | |
|---|---|---|
| Module | lib/wopc/signingRequests/* |
lib/taxHK/ir56m/signingRequests/* |
| API | pages/api/wopc-signing/* |
pages/api/ir56m-signing/* |
| Collection | aote-system/wopcSigningRequests/{id} (+/events) |
aote-system/ir56mSigningRequests/{id} (+/events) |
| Size | 604-line operations Β· 472 repo Β· 201 types | 331 Β· 307 Β· 99 |
The architecture is already decided by precedent. IR56M's own header states it: "Parallel to the WOPC signing-request model but on a SEPARATE collection β¦ so the live, audit-critical WOPC signing system is untouched β the 'parallel-but-shared-utilities' approach the task recommends over generalizing the WOPC model in place. The generic signing utilities β¦ are REUSED from the WOPC module rather than duplicated." A third consumer follows the same pattern.
Already generic β reusable with no refactor:
- lib/wopc/signing/composition.server.ts β composeChopOntoPdf, overlayStampOntoPdf,
CHOP_DIAMETER_PT (20 mm exact), pickVoidStampPlacement, transparent-padding trim. Nothing in
these is WOPC-shaped; they take a PDF + an image + a placement.
- lib/wopc/signing/placement.ts β parseChopPlacement / parseSignaturePlacement validators.
- lib/wopc/assets/secureAssets.server.ts β getChopImageBytes; the chop lives in a private
bucket and is streamed through an auth-gated route because it is a legal company credential.
- lib/wopc/signingRequests/types.ts β the state machine (draftβassignedβsentβsigned|rejected|
withdrawn, signedβvoided), SignatureSource, SignaturePlacement, ChopPlacement,
SigningRequestEvent. IR56M already imports these rather than copying.
- lib/wopc/signerResolver.server.ts + eligibleSigners.server.ts β resolve a signer id to
identity/emails; eligibility = registry directors βͺ authorised Individuals, minus the payee.
- lib/security/totpStore.server.ts β verifyTotpForAction(userId, code), the T-204 step-up.
The RBAC picture (this is the part the owner asked to check):
- Live enforcement is role + Permission (ROLE_PERMISSIONS). The T-022 badge model is
dormant β its own header: "Nothing here is wired into a live gate yet β role remains the
enforced field." So a new gate must use the live permission model, not badges.
- Signing authority today is already appointment-based, not role-based: a person may sign only if
they are a registry director OR their Individual carries
system.authorizedSignature.authorized === true (T-196). No role tier grants it.
- Granting that authority is the wopc_signers:manage permission (super_admin + admin only).
- T-204 is the governing precedent for grave actions. Owner, verbatim (2026-08-28):
"who can move money⦠it's not the matter of role, but the matter people who are specifically
appointed." That shipped as lib/payments/appointedPayers.ts β a fail-closed code constant,
deliberately outside lib/rbac/ so role-model changes can't silently widen it, checked in
addition to role + active status, with step-up TOTP and an audit log layered on top.
Conclusion: the "dedicated personnel" requirement is already satisfied in shape by
authorizedSignature + wopc_signers:manage. What this task must decide is whether that single mark
is the right granularity once the pipeline signs documents other than a WOPC.
Open questions (owner) β asked 2026-09-14, build gated on the answers¶
- What is a "company document" here β an uploaded finished PDF routed for signature, an in-app generated document (as WOPC/IR56M are), or both?
- Authority granularity β is
authorizedSignature.authorizedone global "may sign anything", or does authority need to be per document kind? - Step-up TOTP at sign time β money moves require it (T-204). Does applying the company chop?
- Independence rule β WOPC enforces signer β payee. What is the analogue for a generic document (signer β preparer?), or none?
A Firestore structure gate (new collections) follows once 1β4 are answered; per AGENTS.md no schema code is written before that approval.
Owner decisions (2026-09-14) β all four answered¶
- Authority is PER DOCUMENT KIND. Being authorised to sign a WOPC does not authorise signing a contract.
- Documents are UPLOADED finished PDFs, not generated in-app.
- Step-up TOTP on every signing β the T-204 money-grade control.
- Signer = preparer, and both are the same appointed person. Owner, verbatim: "Let's do signer = preparer for now, but signer/ preparer must be authorized personnel to sign it" β¦ "I'm not making it for documents directed to one director, but document that needs any director's authorization on behalf of the company (for now)". So no four-eyes gate β the documents in scope carry no self-dealing risk, which is what the WOPC signer β payee rule actually controls.
Structure gate presented whole-document and approved ("100% agree, go!").
Built β Phase 1 (server pipeline + the gate), 2026-09-14¶
Decision 4 collapsed the design: the assign β send β await β reject/withdraw machinery in WOPC/IR56M exists ONLY because the signer is a different person who must be emailed. With signer = preparer there is no hand-off, so there is no second signing-request collection β one document record carries its own status and audit trail.
lib/documents/documentKinds.tsβ fail-closed kind registry as a CODE constant (T-204's reasoning: a governance fact should take a reviewed change and a deploy, and must never fail open because a config doc went missing). Six seed kinds, expected to be revised.lib/individuals/types.tsβauthorizedSignature.kinds?: Record<kindId, {grantedBy, grantedAt}>added beneath its parent. The legacyauthorizedboolean is untouched and explicitly documented as WOPC-only, so no existing grant silently widened.lib/documents/signingAuthority.server.tsβ the gate. Authority = a per-kind grant OR being a director. The legacy WOPC flag is deliberately not consulted. Fails closed on unknown kind / unknown caller / directory error.lib/documents/companyDocuments.server.tsβ the store. Reuses the WOPC state machine,SignatureSource,ChopPlacementand event shape by IMPORT. Signing goes through a transaction with a status precondition, so two concurrent signs cannot both win..create()(not.set()) for fail-loud ids.lib/documents/drive.server.tsβ filing. β οΈ The one thing chosen without the owner: the existing numbered tree is an auditor-facing convention, so rather than invent a number inside it these file under a plainly-named lazy-createdCompany Documents (Signed)/{YYYY}. One constant to change.- API β
kinds(only what the caller may use),index(GET list / POST upload, gated + SHA-256 + pdf-lib parse before storing),[documentId](+ events +canSign),file(auth-gated stream,no-store),chop-image(chop is a company credential β restricted to someone who could actually sign THIS document),sign,void(VOID-stamped so a voided doc never downloads clean). sign.tsenforces four layers: signer = preparer Β· per-kind appointment re-checked at sign time (a grant may be revoked after upload) Β· mandatory TOTP (replay-proof: the time step is consumed) Β· immutable audit event recording how authority was held, frozen because a grant can later be revoked.- RBAC labels β
wopc_signers:managenow reads "Authorise who can sign WOPCs and company documents". The id is unchanged on purpose: renaming it would orphan stored role records.
Verified: unfiltered tsc (pinned 5.9.3) clean Β· vitest 1008 passed,
including 13 new cases pinning the gate β the sharpest being a regression guard
asserting the legacy WOPC flag grants NO company-document authority. The single
suite failure (receiptNormalise truncated-HEIC) is pre-existing on clean
main, verified by stashing this work and re-running.
Not deployed. Phase 2 (the /Records UI: upload form, chop placement, TOTP prompt, per-kind grant editor in Contacts) is not built yet.
Phase 1b β the appointment path, end to end (2026-09-14)¶
Without a way to appoint anyone, the gate built above refuses everybody, so this closes the loop:
lib/documents/grantIntent.tsβresolveAuthorizedSignatureUpdate, the pure resolver behind the individuals PATCH route. Extracted from the route precisely so its invariant could be pinned by tests.- Latent bug found and fixed on the way. The pre-T-209 route collapsed
system.authorizedSignaturetonullwheneverauthorizedwas falsy. Once per-kind grants live on that same field, the first "un-tick WOPC signer" would have silently stripped every company-document appointment the person held. The two authorities are now resolved independently: revoking one never revokes the other, and the record is nulled only when NEITHER remains. - Grant trails stay honest: an existing kind keeps its original
grantedBy/grantedAtacross a re-save; only newly-added kinds are stamped, from the acting user, never from the client. Unknown kind ids reject the whole edit. - Contacts UI (
IndividualsContent.tsx) β one chip per registered kind in edit mode forwopc_signers:manageholders, and a "Signs: {kind}" duty chip per grant in view mode carrying its own granted-by/on tooltip.
Verified: unfiltered tsc clean Β· vitest 1017 passed (22 new across the
two T-209 suites). The single failure (receiptNormalise truncated-HEIC) is
pre-existing on clean main β verified by stashing this work and re-running.
Still to build (Phase 2): the document surface itself β upload form, PDF chop-placement page and TOTP prompt. The API is complete and gated; there is no UI to drive it yet.
Phase 2 β the document surface (2026-09-14)¶
/company-documentsβ list + upload. The kind picker offers only the kinds the caller may sign (signer = preparer, so uploading is a signer-level act); someone appointed for nothing is told so plainly instead of being shown a form the server would refuse./company-documents/[documentId]β review + sign in ONE step, because there is no hand-off to wait on. Mirrors the proven WOPC/IR56M chop mechanics: seal follows the cursor / taps to place, placement captured as normalised page fractions so server geometry is independent of render scale, and the on-screen seal is drawn at the same calibrated 20 mm the server stamps β what you place is what you get. Multi-page documents page through; changing page clears the placement rather than silently relocating it. Signing requires the TOTP code.
Surface choice (mine, flagged): this is its own route, not a Records tab.
components/records/RecordsApp.tsx is ~3,600 lines and this is a self-contained,
governance-sensitive surface; promoting it into Records later is presentational,
not a rewrite.
Verified: unfiltered tsc clean Β· eslint clean on every new/changed file Β·
vitest 1017 passed. Not runtime-verified β this environment has no app
credentials, so the UI is type- and lint-clean but has not been driven in a
browser. First real use should be a low-stakes document.
Known gaps, deliberate: chop variants exist only for ERL (MEL/EPL are not incorporated), so a document under those subsidiaries can be uploaded but not chopped β the picker will have no seal. Voiding has an API route but no button yet.
2026-09-14 β Owner round 2: one category, the sealed copy in hand, and a slash bug found¶
What changed for a person using the app
- One category. A document is just a "Company Document" now β no choosing between contract / letter / resolution (owner: "generalize those company documents into one category β¦ for the time being"). The per-kind machinery is untouched underneath, so splitting it later is adding rows to a list, not a migration, and today's appointments stay valid.
- You get the sealed document immediately. Signing now ends with the sealed PDF on screen and three buttons: download the sealed copy, open it in a new tab, or download the unsigned original. Previously the page just quietly re-rendered and you had to go looking for what you had just signed.
- Records has a Company Documents tab. A register of everything the company has sealed β what it was, who signed it, when, who uploaded it β with View and Download on every row. Read-only: uploading and signing stay on the signing surface because both are restricted to appointed signers, while this tab is visible to anyone who can open Records.
- Filed properly in Drive. Signed documents land under
95. Seals & Signatures / Signed Company Documents / {YYYY}, named to the house convention the Drive audit established βERL_Doc_NDAAcmeLimited_20260914_Signed.pdf. β οΈ That folder is my choice, not the owner's; the audit proposes no home for general sealed documents, and its final place should be settled by the reorganisation (one constant to change).
A bug found in existing code, fixed
The Drive audit's single "this will actively break something" finding is 34
signed WOPCs named ERL-WOPC/2025-001_β¦.pdf. macOS silently rewrites the slash,
Windows refuses the file, and it breaks zip archives, Drive for Desktop sync and
any script touching the path β worst on exactly these files, because
cost-of-services records are what an auditor downloads in bulk.
The app is still generating them. safeRefForFilename listed / among its
ALLOWED characters, with a comment saying so deliberately. The app had already
met the symptom once β the bulk-download path flattens / for ZIP entry names,
because inside a ZIP a slash is a folder separator β and patched that one call
site while leaving the cause. Fixed at the source: new files are
ERL-WOPC_2025-001_20260629.pdf. Both shapes still parse, so nothing reading the
Drive cares which convention a file was written under, and renaming the 34
existing files is now safe to do without it recurring.
(The audit suggests ERL_WOPC_β¦; I kept the hyphen so the filename still reads
as the reference number ERL-WOPC/2025-001. One character β say the word.)
Verified: unfiltered tsc clean Β· vitest 1018 passed Β· eslint clean on
every new file, and unchanged (15 = 15) on the two shared files I edited.
2026-09-14 β Owner round 3: slash kept, and you choose where documents are filed¶
Reverted at the owner's direction. The WOPC filename fix is undone β owner:
"I'd like the slash naming to stay, if it ever breaks anything, then I'll fix it
then." Names are back to ERL-WOPC/2025-001_20260629.pdf, and the reason is
recorded in the code comment so a future agent does not "fix" it again. No
files had been renamed, so there was nothing to undo on the Drive side. The
ZIP-entry flattening stays, because inside a zip a slash genuinely is a folder
separator and that one does break.
You now choose where a document is filed. The upload form has a Drive folder browser: start at the shared-drive root, click through the tree, and either pick a folder or leave it on the default. The signed copy is then filed into the same folder as the original, rather than wherever the default happened to point at sign time.
This works because the app never depends on where a file sits β it streams the bytes by Drive file id through its own authenticated endpoint, so viewing and downloading behave identically wherever it is filed, as long as the service account can reach it. (Owner asked exactly this; the answer is yes.)
Firestore, one field added β under its natural parent, on a collection that has never been deployed and holds no production documents:
file:
driveFileId: string unchanged
+ folderId: string | null β the Drive folder chosen at upload (null = default tree)
filename / byteSize / sha256 / pageCount unchanged
listChildren in the Drive client gained an optional
purpose so the accounting shared drive can be browsed (it defaulted to the
receipts identity, which cannot see those folders).
Verified: unfiltered tsc clean Β· eslint clean on every new file Β· vitest
1018 passed (the lone receiptNormalise HEIC failure remains pre-existing on
clean main).
2026-09-14 β Records is now the entry point, and voiding is out of scope¶
Owner: "Let's proceed with the records page work."
What changed for a person using the app. Uploading a document for signing used to mean knowing
about a separate /company-documents page; Records only showed a read-only list with a link to it.
Now Records β Company Documents is where you start. The upload button is on the tab, and a
document waiting to be signed carries a Sign button that takes you straight to it. Signing itself
still happens on the document's own page, because placing the seal needs the PDF on screen at its real
size β the same way WOPC and IR56M send you to a dedicated signing screen rather than trying to do it
in a table row.
One upload form, not two. The form moved into
components/records/CompanyDocumentUploadModal.tsx and both surfaces use it, so the Drive folder
browser, the kind picker and the PDF dropzone cannot drift apart. The standalone page lost about 260
lines in the process. With only one document kind registered, the kind picker now hides itself and
pre-selects it rather than asking a question with one answer.
The buttons no longer guess. The register asks the server, per row, whether this viewer could
act on this document, and shows the button only then. The rule β you uploaded it, and you still hold
the appointment for its kind β lives in one place (lib/documents/viewerActions.ts) so the register's
answer and the signing route's answer cannot disagree. Before this, the only way to find out you were
not allowed was to place the seal, type your two-factor code, and be refused.
No void action β owner decision, 2026-09-14. A void button was built and then removed at the owner's direction:
"the purpose of this function is to utilize the page's capability to sign documents not issued by our company ONLY, so voiding an already submitted document is pointless."
That is the scope of this pipeline: putting the company chop on documents someone else issued β a
counterparty's contract, a bank's form. Withdrawing such a document from standing is not the company's
to do, so a void is meaningless. (WOPC keeps its void, because a WOPC is the company's own
instrument.) This replaces the earlier "voiding has an API route but no button yet" line β that was a
gap; this is a decision. The reasoning is recorded in lib/documents/viewerActions.ts so it is not
re-added as a fix.
Left standing: POST /api/company-documents/[documentId]/void still exists and still works,
gated on the preparer holding the appointment. Nothing calls it. It can be deleted β say the word.
Verified: unfiltered tsc clean Β· eslint clean on every new and changed file Β· vitest 1033
passed, 8 new cases pinning the capability rule. The lone receiptNormalise HEIC failure remains
pre-existing on clean main. Not runtime-verified β this environment has no app credentials.
2026-09-14 β A 20 mm seal on a document that never stated its size¶
Owner: "if the uploaded document isn't a computer generated file but a scanned file that could be scanned in different dpi β¦ how do we then still ensure that the stamp is still rendered as 20mm visually and relatively?" β followed by: add a tool to trim an uploaded document to its paper, or freehand with the size declared.
The problem, plainly. A PDF states its size in points, and a point is a fixed physical unit, so the number is supposed to be the physical size. Generated files are honest. Scanners often write one point per pixel instead, which is only correct at 72 DPI. A 300 DPI scan of A4 then claims to be a sheet 875 mm wide, and an honest 20 mm seal on it reads as 4.8 mm against the paper a reader sees. At 600 DPI, 2.4 mm. The aspect ratio still looks like A4, so nothing appears wrong.
And it cannot be deduced. Every A-series sheet is the same shape and the sizes step by two, so a 300 DPI A4 and a 600 DPI A6 written raw are identical bytes. The app therefore does not guess: it offers ranked candidates (A4 first β a prior, not a deduction) and a person settles it.
What a person does now. On a document whose size the file did not state, the signing page opens on a trim step instead of the chop. They drag on the page to cut away the scanner border; the trim box is locked to the shape of the paper size they picked, so dragging can only ever produce a true A4. If it is not a standard sheet they choose "something else" and type what the area measures. Everything outside the trim dims, so what is kept reads at a glance. Signing is refused until this is settled β the app will not stamp a seal at a size nobody chose.
What happens at signing. The page is rebuilt so it means what it looks like: rotation baked into real geometry, trimmed to the paper, declaring its true size. Content is carried across as a form XObject β nothing is rasterised, text stays selectable. Then the seal goes on at 56.6929 pt, which is now genuinely 20 mm of genuine paper. The signing audit event freezes the geometry, so the record can say not just that a 20 mm chop was applied but 20 mm of what.
Three bugs fixed on the way, all found by tests rather than by reading:
- The preview lied about size. It read the page width after scaling β the number it had just passed in β so the ratio was always 1 and the seal drew at a fixed 56.69 px however large the page was rendered: ~28% too small on a wide desktop, ~56% too big on a phone. The saved PDF was always correct; only the preview misled. It is now pure proportion (20 mm of a 210 mm sheet is 9.52% of however wide the page is drawn), which is right at every zoom.
- Rotation was ignored. Scanners routinely flag a page "display this turned 90 degrees"; viewers obey, pdf-lib does not. A click on what looked like the bottom-right would have stamped somewhere else entirely. The bake is verified against what a viewer actually displays β the test renders known marks through pdf.js and checks they land in the same viewport position before and after. That caught the 90-degree case drawing content clean off the page.
- A blank page crashed the whole document. A page with nothing on it has no content stream and the embedder refuses it outright. The back of a single-sided sheet is exactly that, and scans are full of them. They are now carried across as blanks.
Firestore structure gate β approved by the owner before any schema code was written. file.page
records what the PDF declares (a measurement, beside sha256/byteSize/pageCount); paper records
what a person decided it really is, and is null until settled. Kept apart deliberately: when a
seal's size is questioned later, what the file said and what someone asserted must be tellable apart.
Two things stayed inside the approved shape rather than being widened quietly: an updatedBy
field was drafted and removed, and the paper decision writes no audit event, because the shared
signing-event vocabulary belongs to the audit-critical WOPC pipeline and has no member for it β adding
one is a schema decision of its own. Where the geometry matters it is frozen in the signing event. A
dedicated "paper set" event would need that new event type; worth doing, not done unilaterally.
Verified: unfiltered tsc clean Β· eslint clean on every new and changed file Β· vitest 1069
passed, 34 new across three suites. The lone receiptNormalise HEIC failure is pre-existing on clean
main. Not runtime-verified β no app credentials here.
2026-09-14 β You can actually sign it now, the code is gone, and the trail shows¶
Owner, on using it for the first time: "I seemed to only seal the document without being able to sign it⦠Is it that I could actually proceed to sign the document right after I chopped? It's a bit confusing."
That was a real gap, not a misreading. The sign API has accepted an optional hand signature since Phase 1. The page never offered one β it only placed the chop, so "Sign & chop" only ever chopped. Anyone reading the button would expect otherwise.
Fixed by reusing the existing canvas rather than building another:
components/wopc/Ir56mDrawCanvas was already written as a shared component (it drives both the IR56M
wizard and its test harness, with side-effects injected as callbacks), so it drops straight in. Draw
your signature, click where the chop goes, and the preview shows the signature exactly where the
server will put it β just above the seal, at the size the server composes it. The button now says
what it will do: Sign & chop when a signature is drawn, Chop without signing when not.
TOTP removed. Owner: "Remove TOTP as I now find it unnecessary." This reverses their own decision of earlier the same day (the T-204 money-grade control, recorded above), and the reversal is defensible on the scope this pipeline ended up with: these are documents someone else issued, carrying none of the self-dealing risk the money-grade control actually guards. The appointment gate and the audit trail are untouched. WOPC and IR56M keep their TOTP.
Audit trail β structure gate approved, then built. companyDocuments/{id}/events/{id}.type gains
one permitted value, paper-set. Defined locally:
The shared vocabulary in the WOPC module is untouched, because its events live in a different collection and must never gain a member they cannot produce. The entry records the size chosen, the trim, what the file had declared, and what the previous answer was β so a document trimmed three times shows three entries, where a field on the record would have shown only the last.
And the trail is now visible. It was being fetched on every page load and thrown away. The page shows it as a timeline in plain words β "Uploaded", "Paper size confirmed", "Signed and sealed" β with who and when, and the geometry on the entries where it matters.
Verified: unfiltered tsc clean Β· eslint clean on every changed file Β· vitest 1077 passed.
2026-09-14 β The signing page, rebuilt around a toolbar¶
Owner, after using it: the banners were shouting one line of status across the full width, the page turner was buried below the document, and the chop controls were a row of labelled buttons.
One toolbar, above the document, replacing all of it.
- Far left β the paper. "A4" and a page icon. The real measurements are a tooltip, not permanent text, because the number only matters when someone doubts it: it follows the cursor on a mouse and, on a touch screen where there is no hover, a tap shows it for three seconds and it puts itself away.
- Centre β the page turner, only when there is more than one page.
- Right β two icon buttons that ARE the state. Nothing chopped yet: the chop icon pulses and the pen is dim, because there is nothing to sign around yet. Chop placed: the pen starts pulsing. Either can be clicked afterwards to redo that mark. The blink is stilled for anyone whose system asks for reduced motion, which gets an outline instead.
"Different chop" and "Move chop" are gone. Re-chopping is pressing the chop button, which is what the owner asked for and is one fewer concept: the button means "place this mark", every time.
The signature can now be placed. Drawing one puts the page into signature mode, and it follows the cursor until clicked down β the same interaction as the chop, rather than being silently pinned above the seal. Left unplaced it still defaults to just above the chop, which is where it always went; the server takes a position when given one and falls back otherwise.
The whole document fits on screen. The page is drawn to the height actually available β measured, not assumed, since the header grows with the title β capped by width so a landscape page cannot overflow sideways to gain height. β οΈ The seal preview had to follow: with fit-to-height the rendered width is derived from the height, not from the width cap, and sizing the preview against the cap would have quietly reintroduced the very bug fixed earlier today.
The company has a name. The page showed "ERL". It now shows Establish Records Limited (and the
Chinese name where there is one), read from the subsidiary's own _metadata/config via
fetchSubsidiaryInfoServer β the same registry every other surface reads, not a constant in the code.
Verified: unfiltered tsc clean Β· eslint clean on every changed file Β· vitest 1077 passed.
Still not runtime-verified β no app credentials here, so the layout is type- and lint-clean but
undriven in a browser.