vc-issuance-{0,1,2,3,4}.html)Read this before changing any vc-issuance-* page, its bundle, or css/sd_jwt_vc.css. Two things here are counter-intuitive: the layout constraints are measured and mutation-tested, so a plausible CSS simplification is caught; and Credential History is a log of every attempt, of which the held credentials are only a navigable subset.
The SD-JWT VC issuance workflow (vc-issuance-{0,1,2,3,4}.html) reuses the OIDC Authorization Code flow on oauth2_oidc_1.html / oauth2_oidc_2.html: the ?sdjwtvc=1 query parameter marks the flow active, and oauth2_oidc_2.html returns to step 2 once it has the tokens. Both debugger pages behave exactly as before without that parameter. Step 1’s authorization-server pane deliberately writes the same localStorage keys the debugger pages read.
That sharing runs both ways, and one of the two directions is a trap. oauth2_oidc_1.html’s metadata pane defaults to an OpenID Connect Discovery URL (https://localhost/oidc/.well-known, a placeholder it writes on a browser that has merely loaded the page) and stores it under oidc_discovery_endpoint — the same name step 1’s RFC 8414 pane uses. Read back unfiltered, that turns “Metadata Endpoint URL” on vc-issuance-1.html into an openid-configuration URL, or into a placeholder that resolves to nothing, for every browser that has visited the OAuth2 / OIDC workflow once — and it looks like a wrong default rather than like a value from another page. asMetadataUrlAtLoad() in client/src/vc_issuance_1.js is the filter: at load, a stored value is this pane’s own only if it carries /.well-known/oauth-authorization-server (the RFC 8414 section 3.1 issuer-path form included) or is the empty string a Clear leaves behind; anything else loses to rfc8414MetadataUrlDefault from the client env — https://localhost:8081/.well-known/oauth-authorization-server, the mock STS, on local.js. Nothing is written back, so the debugger page keeps its own URL. It is the same rule defaultAuthorizationServerUrl() already applied to what it found in the field after a credential-issuer retrieval.
Step 0 chooses which OID4VCI Appendix H use case to run. The list lives in one place — USE_CASES in client/src/sd_jwt_vc.js — and both the chooser’s cards and the badge every other page shows are generated from it, so they cannot disagree; setUseCase() redraws the badge itself, because an arriving Credential Offer changes the use case after the page has already been laid out. H.6 (wallet-initiated) is what the workflow has always done. H.1 (issuer-initiated) starts at the issuer’s own page, comes back to step 1 with a Credential Offer in credential_offer or credential_offer_uri, and carries the offer’s issuer_state into the authorization request (oauth2_oidc_1.js appends it as a custom authorization parameter). H.2 (cross-device) and H.3 (deferred) start at the issuer’s QR screen, arrive through step 1’s Receive a Credential Offer pane (the wallet is on another device, so nothing navigates it), and use the pre-authorized_code grant — no authorization request, so step 1 hands off straight to step 2, which owns the Token Request and the tx_code. H.3 adds the deferred pane: a 202 Credential Response with a transaction_id, polled at deferred_credential_endpoint until the credential arrives.
Step 4 (vc-issuance-4.html) refreshes a credential the wallet already holds — OID4VCI section 14.5, Refreshing Issued Credentials — in the two calls that mechanism is made of: grant_type=refresh_token at the token endpoint (RFC 6749 section 6, so nothing about it is OID4VCI-specific), then the same Credential Request step 2 makes, which is why both pages build it with vci_wallet.js. Section 14.3 is the reason the refresh half is optional rather than required: the Credential Endpoint may be asked again with an access token that is still valid, which is the only route left after the pre-authorized code grant (H.2/H.3), since that grant issues no refresh token.
Every page of the workflow opens with the same row of links to every step — partials/vc_steps.html, included at the top of the container, above the page title, with each page marking its own vc_step_N as current from its bundle. It is deliberately one row: ol.vc-steps is flex-wrap: nowrap with items that may shrink (flex: 1 1 0; min-width: 0), because with the wrapping it had before, adding step 4 pushed the fifth link onto a second line. Captions in the partial are kept short for the same reason, and stepLinksOnEveryPage() in tests/sd_jwt_vc_issuance.js asserts geometrically — all five items on one top edge, the row one item tall, above the title, no horizontal page scroll — on all five pages.
Layout trap on these panes. Everything they display is base64url with no break opportunity in it, and bootstrap.css sets code { white-space: nowrap } — so a long c_nonce or access token used to run off to the right of the pane, and in the Approve pane’s list it pushed the whole page sideways. sd_jwt_vc.css fixes it at the root: .dbg-pane code overrides the nowrap, .vc-token-table is table-layout: fixed (the cell decides the width, as the disclosure tables already did), and pre.vc-json / textarea.vc-token are both width: 100%; box-sizing: border-box so a <pre> and a <textarea> in one pane come out the same size. panesContainTheirContent() in tests/sd_jwt_vc_issuance.js plants an over-long value into every <code> on all four pages and fails if anything is wider than its pane or the page scrolls horizontally.
Step 1 is laid out for one screen, and the row of metadata panes is what pays for it. That page carries four discovery panes — Credential Issuer Metadata, Issuer DID Document, Authorization Server Metadata, DID Configuration — and each is a URL, a few buttons and a status, so stacked full-width they spent 1,234px on content that fits in a quarter of the width. They now sit in <div class="vc-pane-row">, a repeat(auto-fit, minmax(270px, 1fr)) grid with align-items: start, four across at 287px each on a desktop container and folding to 3+1 / 2+2 / one-per-line as the window narrows. The rest of the page was compressed the same way: each pane’s prose is folded into <details class="vc-hint"> (a <details> needs no script, which matters because these pages set script-src tightly), the page intro and the Configuration Parameters note likewise, the legends are short, the Return to home link is merged onto the title, and Receive a Credential Offer moved below the row. Empty page 6,569px → 2,920px; the four panes, the use-case badge, the collapse toggle and most of the offer pane are above an 839px fold.
Four things about that row are load-bearing, and three of them were regressions in the first cut:
| A quarter-width pane needs its content stacked, not tabled. Each pane’s own layout table is two columns of label | control, which at 287px left a ~120px control column and put each pane’s button group on a different line from the next (these tables split them across two cells, Retrieve/Upload |
Clear). So inside .vc-pane-row the pane’s fieldset > table becomes blocks with each <tr> a flex line: a cell holding a label, a field, a hint or a filled status claims the whole line (flex: 1 0 100%) and everything else shares one. Scoped to fieldset > table deliberately — it must not reach the retrieved-document tables nested in <div>s below. |
.vc-pane-row .discovery_info_table is therefore max-height: 300px; overflow: auto, the same way #vc_history_table is bounded, so the row’s height no longer depends on what was retrieved (773px with all four loaded). The readable full-width rendering of the same values is the Configuration Parameters pane below; the pane’s own table is the raw document beside its controls. This is invisible on an empty page — every geometry check passed before the ceiling existed, because nothing had been retrieved — so stepOneFitsInOneRow() fills the panes before it measures.#config_rows is a <div class="vc-config-groups"> (column-count: 3, 2 at 1100px, 1 at 700px) holding one self-contained .vc-config-group per document, each with an h4.vc-group-heading. Multi-column rather than a grid because it balances uneven blocks, and break-inside: auto on the group rather than avoid because one group holds the RFC 8414 document’s remaining 36 members and is ~1,200px on its own — atomic, it set the column height and left the other two columns half empty, so the columns cost height without buying density. h4.vc-group-heading carries break-after: avoid, since a heading stranded at the foot of a column is the only part of a break that reads as a bug. 1,771px → 1,398px.id_token_encryption_enc_values_supported is 40 characters with no break opportunity; in a third of the width it was painted straight over the input beside it and both were unreadable. .vc-config-table td:first-child gets overflow-wrap: anywhere. Note the tables are table-layout: fixed, so the cell box never moves — only the glyphs overflow — which is why stepOneFitsInOneRow() measures a Range over the cell’s contents rather than comparing the two cells’ bounding boxes. A box-versus-box check sees nothing and passes, which is exactly what removing the wrapping rule proved.stepOneFitsInOneRow() in tests/sd_jwt_vc_issuance.js covers all four, and it is mutation-tested: dropping the ceiling (row 23,339px), turning the grid back into a block (four lines), and removing the name-wrapping rule are each caught. Two rules the first cut added were caught by the same exercise as doing nothing and were removed — a table-layout: fixed and a word-break restated for the row’s document tables, which debugger.css’s .discovery_info_table block (max-width: 100% plus overflow-wrap: anywhere) already enforces, and a display: table restoring a display the stacking rule never took away. A rule that claims to hold a layout together while holding nothing is worse than no rule. Separately, note that vc-config-table is applied by vc_issuance_1.js and did_tools.js in generated markup and was for a while styled only via .vc-config-group table — defined in no stylesheet, which is precisely what checkStylesheetsLoaded() in tests/navigation.js fails a run over. Style the class the markup carries.
Step 4 also carries a Credential History pane, the counterpart of oauth2_oidc_2.html’s Token History — recording rather more than oauth2_oidc_2 does: every attempt, one row each, newest first. sd_jwt_vc.js holds the store; a row has a kind (issuance / token_refresh / credential_request / deferred_poll), an outcome (success / failed / deferred / pending / kept / discarded) and a detail saying what the issuer actually said. recordHistoryEntry() appends one row per attempt and returns its id; updateHistoryEntry() resolves it later (pending → kept or discarded), so a retry, a refusal and a discarded credential are all on record without any of them becoming a second row for the same call. Every row is numbered in # by the order the attempts were made — there is no unnumbered row — while Gen carries the generation number, which only the kept rows with a credential have: those are the generations (heldGenerations() numbers them, activeGeneration() says which is in hand) and ◀ Older / Newer ▶ / Oldest / Latest move over that subset (a log row is not a place to be, and says log only). The list is the newest HISTORY_LIMIT (100) attempts in a fixed-height scrolling box with a sticky header (#vc_history_table in sd_jwt_vc.css): a 100-row log would otherwise push the panes below it off the screen and the pane’s height would jump on every attempt. Trimming at HISTORY_LIMIT drops the oldest log rows first and only touches a generation when nothing else is left, so an audit trail never costs the ability to go back to a credential. HISTORY_INDEX holds the active row’s id, not an index, so a log row appearing between two generations cannot shift it. A credential the issuer has just returned shows up there immediately as a vc-history-pending row (outcome pending) marked not kept yet, with Keep/Discard on it — the pane has to react to the retrieval, not only to keeping, or it reads as broken; and keeping deliberately does not navigate (replaceCredential() calls reloadHeldCredential() in place and offers a Verify in step 3 button), because the pane the holder acted in is the pane that has to show what the action did. Activating a generation is a real state change, not a highlight: it writes CREDENTIAL/CREDENTIALS/CREDENTIAL_META and that generation’s holder key pair, because a credential whose cnf key the wallet no longer has cannot be presented at all — which is also why each entry stores its own key pair, and why Clear History writes an empty list rather than removing the key (hasCredentialHistory() distinguishes “cleared on purpose” from “never recorded”, and only the latter backfills the credential in hand as generation 1). A discarded refresh stays in the log as a discarded row — the attempt happened — but its credential and private key are stripped, because discarded has to mean discarded, and it is not a generation: the wallet never held it. Three things the page must get right, all of them wallet behaviour the spec leaves open: the refreshed credential is kept apart from the one in hand until the holder chooses (section 14.5 warns about a wallet holding two credentials of the same type and not knowing which is current); what the issuer changed is read off the two credentials rather than assumed, because the issuer decides whether to update the signature only or the claim values too — and the salts, Disclosures and digests are new either way, so the comparison is of claim values; and choosing to bind a new holder key must not overwrite HOLDER_PRIVATE_JWK until that credential is kept, or the credential still in hand loses the key it needs to be presented (the pending pair parks in REFRESHED_HOLDER_*, and the replaced credential’s pair in PREVIOUS_HOLDER_*).
Steps 3 and 4 each end with a pane offering the next thing to do with the credential — step 3 offers both Refresh It (step 4) and Present It, step 4 offers Present It. Two things about that hand-off are load-bearing. First, it copies nothing: the two workflows meet at the storage keys described under Key Implementation Notes in the repo-root CLAUDE.md and the presentation bundles read CREDENTIAL / HOLDER_PRIVATE_JWK directly, so the credential is already where the other workflow looks — a hand-off that duplicated it would be a second copy that goes stale, and presentationHandoff() in tests/sd_jwt_vc_issuance.js asserts the click adds no new key holding those bytes. What the offer owes the user is therefore not a transfer but an honest answer to will anything happen if I click this, which is presentationReadiness() in sd_jwt_vc.js — one implementation, because it has to reach the same verdict renderRequest() in vc_presentation_1.js will reach a page later. It keeps that function’s three-way distinction: no credential or an unparseable one blocks; a holder key that is missing while saving is on blocks, because it was never generated here, there is nothing to paste and step 1 refuses; a holder key absent by choice does not block, because presentation step 2 has a field for it. Collapsing those last two to “no key → blocked” is the plausible simplification and it strands the user two pages before the only field that fixes it, so the test asserts they come out differently.
Second, step 4 is the one page where “the credential” is ambiguous, and the offer has to say which one would go. It names the generation in hand (and whether it is the newest — going back to an older one is a supported thing to do there), and it explicitly disowns a refreshed credential that has been retrieved but not kept: that one is the newest thing on the screen and is not in storage at all, so it is not what a Verifier would see. The offer is rendered from renderHistory(), which is the pass every state change goes through — keeping, discarding, activating, stepping, clearing — but the call sits at the top of that function rather than the tail, because the empty-history early return is itself a state the offer must describe. Putting it at the tail leaves a fresh wallet with a blank, still-enabled offer, and it is invisible unless the test clears the history as well as the credential — which is exactly what the mutation testing caught.
Step 1 carries a Claims to Request pane: one row per claim the chosen credential configuration says it can carry, every row checked, and clearing a row asks the issuer to leave that claim out. The rows are built from the issuer’s own metadata — credential_configurations_supported[<id>].claims, an array of claims description objects (OID4VCI Appendix A.2) — so the wallet asks in the vocabulary the issuer published rather than in one invented here, and an issuer that publishes no claims member gets a pane that says so rather than an empty table.
The member does not go where almost everybody looks for it first. OID4VCI 1.0 puts the optional claims member in the authorization_details entry of type openid_credential (section 5.1.1). The Credential Request has no such member: section 8.2 defines credential_identifier / credential_configuration_id, proofs and credential_response_encryption, and nothing else — the 1.1 editor’s draft still does. So the selection is made where the issuance is authorized, and by the time step 2 builds the Credential Request it has already been granted; what step 2 shows in its approval pane is what was asked for, not what the metadata offers. Adding a claims member to the Credential Request body instead would be ignored by a conforming issuer (“the Credential Issuer MUST ignore any unrecognized parameters”), which is the worst possible failure here: the credential comes back carrying everything and nothing says why.
That places two constraints on the pane, and both are visible in it:
scope, and a scope request has nowhere to put a claims member — so a selection made on the default route travels nowhere, the issuer returns everything it is configured to issue, and the whole thing reads as the issuer ignoring the request. That is exactly how it was first reported, one day after it shipped, and the fault was entirely on this side: the pane said “9 of 10 advertised claims will be asked for” while the authorization request carried none, and the only warning was a sentence appended to the hand-off pane’s note, which is not where anybody looks. So the verdict now lives in the claims pane itself — a vc-bad status reading This selection will NOT be sent, naming both halves of the reason, with an Ask with authorization_details button beside it that flips the select in one click. claimsCannotTravelOnAScope() in tests/sd_jwt_vc_issuance.js holds it, checks that the authorization request really carries nothing in that state, and is mutation-tested against forcing the verdict back to “deliverable”. The lesson generalises past this pane: a control whose effect depends on a setting somewhere else on the page has to report on that setting where the control is. That notice failed a second time on 2026-08-19, and the default moved instead. The verdict renders into vc_claims_status, a span at the end of the Select All / Select None button row, in the pane you have just finished using, about a select further up the page — so it reads as a caption rather than a stop sign, and the second reader missed it exactly as the first had. A notice that has failed twice at its only job is not made to work by writing it a third time. So defaultRequestMechanism() in vc_issuance_1.js now offers authorization_details whenever the chosen configuration advertises claims, and the note under the select says the page chose it rather than the user. Four things bound that: a STORED choice still wins (this fills in a blank, it does not overrule an answer), a configuration advertising no claims still defaults to scope so the plain flow is byte-for-byte what it was, the default is never written to storage — storing it would freeze today’s answer onto a page whose credential configuration changes under it — and, added the same day after it broke the suite, the authorization server has to be able to redeem the request. RFC 9396 section 10 has the server publish authorization_details_types_supported; the mock issuer’s own AS advertises openid_credential and Keycloak’s RFC 8414 document omits the member altogether. Keycloak accepts the authorization request and issues a code, then refuses it at the Token Request with invalid_authorization_details (“Unsupported type ‘openid_credential’ … Supported values: []”), which stopped the workflow on oauth2_oidc_2.html and failed the Keycloak leg of tests/sd_jwt_vc_issuance.js — a timeout waiting for step 2, naming neither the mechanism nor the parameter. So asAcceptsAuthorizationDetails() gates the flip: a document not yet retrieved is unknown and the credential decides (which is what the pane reports), a document in hand that does not name openid_credential keeps scope, and the note under the select says authorization_details would have been the default and why it is not. It is re-decided from updateHandoffSummary() rather than only when the claims pane is built, because the two documents are retrieved in that order and the AS one arrives last. authorizationDetailsIsTheDefault() in tests/sd_jwt_vc_issuance.js holds all three, and is mutation-tested against forcing the default back to scope. The vc-bad verdict and its button stay, for the reader who chooses scope deliberately.authorization_details in the Token Request in both flows, and that is where step 2 puts it — but only when the pane asks for a strict subset. That condition is not squeamishness: introducing the parameter changes nothing about the claims when everything is selected, and it changes a great deal about the request, because a token response that grants credential_identifiers forbids credential_configuration_id in the Credential Request (section 8.2). So the default flow is byte-for-byte what it was, and asking for fewer claims switches those two things together.Three decisions inside the pane that are easy to get wrong the other way:
sdjwtvc_requested_claims, one entry per credential configuration in client/src/vci_claims.js). Storing the included set would make “all checked” depend on a list written at some point in the past: a claim the issuer starts advertising tomorrow would arrive unchecked for anybody who had ever touched the pane, and nothing on screen would say why the wallet stopped asking for it.claims member. Asking for all of them is exactly what omitting it means, and the pane says so — a note reading “10 of 10 requested” would describe a member that is not there. Nothing checked sends none either, because Appendix A.1 requires a non-empty array: a wallet cannot ask for a credential with no claims, and the pane says that rather than sending [] for the issuer to refuse.mandatory stays checkable. In the metadata mandatory means the issuer includes the claim whether or not it was asked for (Appendix A.2), so unchecking it changes nothing — but this is a debugger, and being able to send that request is how you find out whether an issuer means it. The row says what will happen instead of preventing it. Note the two mandatorys are different statements: the one a wallet sends (A.1) means it will refuse a credential lacking the claim, which is a refusal this workflow does not make, so nothing here ever sends it.Step 2 describes what was ASKED, not what was ticked, for the same reason: its approval pane reads the claims out of the token response’s granted authorization_details (RFC 9396 section 7), or — before a pre-authorized Token Request has been sent — out of the request it is about to make. Reading step 1’s stored selection instead is what made that pane agree with the lie above; the wire cannot.
The mock issuer honours the selection end to end — the authorization endpoint validates the paths against what it advertises, the granted details ride inside the access token, and the credential endpoint builds only the claims that were asked for — and refuses an unadvertised path, a repeated claim (Appendix A.3) and a malformed pointer with invalid_authorization_details. claimsSelection() and preAuthorizedClaimsRequest() in tests/sd_jwt_vc_issuance.js follow one selection from the pane through the authorization request, the token response and into the credential’s Disclosures, and read what the issuer advertises off the wire rather than assuming it: which claims that mock carries is /admin/vc configuration and it survives between tests. Both are mutation-tested — dropping the claims member from the client and dropping the filter from the issuer are each caught, the second by a message naming the claim that should not have been there.
vc-issuance-0.html is a chooser, and a chooser that scrolls cannot do its job — you cannot compare four options by scrolling between them. It did not start that way: four full-width cards stacked in a 1100px column came to 757px of a 1450px page against a 839px viewport (what a 1512x982 display leaves after the browser’s chrome), while all the horizontal space beyond the cards sat empty. Everything the page offers now ends 795px in, 44px above the fold — it was 808px until the shared VC Tools pane grew a second tool, which is the story at the end of this section.
Two changes got it there, and the split matters. .vc-usecases is a grid — repeat(auto-fit, minmax(400px, 1fr)), so two readable columns in the container and one on a narrow screen with no breakpoint — which is what uses the horizontal room. And the rest is a denser vertical rhythm scoped to .vc-fit on this page’s container: the return link rides on the title’s line, the intro drops the 1100px reading cap (a wider paragraph is fewer lines, and here height is the scarce thing), and the card padding, inner gaps and pane legend are each trimmed a few pixels. Scoped, because the other eight workflow pages are meant to be read top to bottom and may legitimately scroll — tightening .vc-title globally would be a decision about all of them.
Measurement notes, both of which flatter the result if got wrong: align-items: start on the grid, or every card in a row stretches to the tallest and the grid quietly gives the height back; and setRect() sets the outer window height, so the window is opened at 982 to get 839 inside it — measuring the window rather than the viewport overstates the space by ~143px, and headless has no toolbar to correct for.
chooserFitsOnOneScreen() in tests/sd_jwt_vc_issuance.js defends all of it: a grid rather than one card per row, columns still at least 380px wide (squeezing in more columns buys height only by making every card taller — measured: four across saved just 46px), everything above the fold, and no sideways scroll gained in exchange. Run against the pre-change build it reports “still one card per row; below the fold by 397px”.
The pane that breaks this page is the one that belongs to no step. The shared VC Tools pane (partials/vc_tools.html) sits at the foot of all nine workflow pages, and it grows: it gained a second tool on 2026-08-17 (the Certificate Authority & X.509 page), which put the page at 871px against the 839px viewport and failed that check by 32px — a page nobody edited, broken by a partial two directories away. Note what it does not look like: the assertion names step 0’s last pane, so the first thing it suggests is the chooser, which was fine.
The 32px came back from height that was never content, and the page now ends at 795px, 44px above the fold — deliberately more than one tools row, so the next tool does not repeat this:
| Reclaimed | How | Worth |
|---|---|---|
| The pane legend’s line box | .vc-fit .dbg-legend had its padding trimmed already, leaving one 14px word in a 40px line box |
23px |
| The second tool’s description | Shortened to roughly the DID tool’s length — this table’s row is as tall as its longest cell | 19px |
| The title’s line box | 24.5px of text in a 40px line box | 9px |
| The tools rows’ leading | .vc-fit #pane_vc_tools td at line-height: 1.3; 12.6px note text does not need 20px lines |
3px now, ~11px per future row |
| Doubled whitespace | The step row’s bottom margin and the card grid’s top margin were both spent on the same gap | 12px |
So a third tool costs about 33px of the 44 remaining, and a fourth needs another decision — the obvious next one being that this pane starts collapsed on .vc-fit, since its legend already toggles it. The trims are all scoped to .vc-fit (step 0 and step 1); only the shortened description is shared, and it is shorter on every page for the same reason. Checked against stepOneFitsInOneRow() as well, because that page carries the same class: its four metadata panes each lose the same 21px of legend, stay on one row, and the populated row measures 772px against its 1100px ceiling.
One thing it does not require is the footer above the fold: that is 200px of shared site furniture on every page in the project, so demanding it would be a constraint on the footer rather than on this page. Related and pre-existing: every VC workflow page overflows sideways by exactly 182px below about 1200px of width — the five-item steps row’s floor, present on untouched pages too and absent from oauth2_oidc_1.html and the landing page.