Skip to main content

Portal parity

Measured 2026-08-15 on a disposable Nextcloud 34 instance (portaliq-p2-rig, :8321) running portaliq, openregister and nldesign from the working tree. Both bundles built with NODE_ENV=production.

This page exists because ADR-084 says parity must be measured, not asserted, and because the honest answer is more interesting than the one I expected.

What is actually being compared​

The two renderers are not two versions of one screen. They render different content models against different data:

React portal (/portal)Vue site renderer (/site)
AudienceAn authenticated supplier/citizenAnonymous public visitor
AuthBearer session (portaliq_token)None
ContentSubject-scoped OR collections, actions, inboxPortal CMS: menus, pages, glossary
Source/portal/api/contributions/api/content/*
LayoutLinear stack of typed blocks12-column manifest grid, or markdown

So a pixel diff between them would be a large number that means nothing. The screenshots in tests/e2e/visual/ are for human review; what is compared numerically is below.

Size — the result I did not expect​

BundleRawGzipped
portaliq-portal.js (React)181,188 B56,099 B
portaliq-site.js (Vue)162,907 B56,316 B

Uncompressed the Vue bundle is ~10% smaller. Compressed it is 217 bytes LARGER. Gzip is the number a visitor pays, so the honest summary is: the two are the same size, and any claim that moving to Vue made the public bundle smaller would be wrong.

That is worth stating plainly because the raw figure was the one I reached for first, and it flattered the change. The compressed figures differ by 0.4% — noise.

What the Vue bundle buys is not bytes. It is that the grid, the markdown renderer and (once nc-vue chain links 1–2 land) the whole communal widget catalog come from the shared library instead of being reimplemented per front-end.

Re-measured 2026-08-20 — the figures above were stale by 84%​

The table above is kept as written because it is what was true in August and because the conclusion it draws still holds. The numbers are not current. Re-measured on development at 59486c3, NODE_ENV=production:

portaliq-site.jsRawGzipped
recorded 2026-08-15162,907 B56,316 B
measured 2026-08-20, before this change358,538 B103,209 B
measured 2026-08-20, with federatedSearch386,945 B111,254 B

The baseline grew 83% gzipped in five days, and no entry in this file recorded it. That is the failure this page exists to prevent: a parity document whose numbers are not re-measured asserts parity rather than measuring it, and it does so in the confident voice of something that once was checked.

The federatedSearch block accounts for +8,045 B gzipped (+7.8%) of the current figure. That delta was measured by building the same tree twice with only the block's registration in WidgetGrid.vue reverted — not by subtracting an estimate, and not against the stale August baseline, which would have attributed the whole 55 KB of intervening growth to this change.

Anyone touching this file: re-measure both bundles in the same run. A row here is worth exactly as much as the date beside it.

Re-measured 2026-10-02, after the React portal retired​

Built from the retirement branch with npm run build, NODE_ENV=production. There is no portaliq-portal.js any more.

BundleRawGzippedBudget
portaliq-site.js (entry)335,094 B101,083 B412 KiB (webpack.site.js)
portaliq-embed.js148,092 B36,508 Bits own, in webpack.site.js

Every signed-in page is an async chunk loaded on its route, so the entry did not grow with the ported screens.

Parity checklist, measured 2026-10-02​

This replaces the "what each renderer can do" table of 2026-08-15. That table compared a public renderer with a signed-in one. Since then every signed-in screen of the React portal was ported to the site (site-reaches-portal-parity, slices a to f), and slice g ported what was still missing and retired /portal.

One row per capability in openspec/changes/site-reaches-portal-parity (inventory rows a1 to g6, requirement REQ-SRP-051). The checklist of the validation programme (parity-checklist.md, rows A1 to E) maps onto these rows in the last column. Status:

  • ported: slices a to f built it; the file and the node test are named.
  • newly ported: slice g built it, because it was still missing.
  • dropped: deliberately not ported; the reason is given.

Every node test named here runs in npm run check:specs.

Shell: session, sign-in, navigation, branding​

#CapabilityVue portEvidenceStatusChecklist
a1Boot from runtime configsrc/site/main.js, lib/contentApi.js, templates/site.phpsite-head.spec.mjsportedA1
a2Bearer from #token=, one store per tab, session readlib/authApi.js adoptSessionTokensite-auth.spec.mjsportedA9
a2The React portal's localStorage portaliq_token taken oncelib/authApi.js adoptLegacyTokensite-auth.spec.mjs (adoptLegacyToken)newly portedA9
a3Sign-in screen, provider buttons, failed sign-in, "no login method", dev login and its refusal, 401 authentication_requiredcomponents/AccountArea.vue, BrandHeader.vue, App.vue (signInNeeded, devLogin)site-signed-in-shell.spec.mjs, broker-login.spec.mjs, site-auth.spec.mjsportedA5, A6, A7, A8
a3A provider the organisation routes to the brokerThe site links to /session/oidc/start; SessionController::oidcStart sends a broker-routed provider on to /session/broker/startPHPUnit SessionControllerTest (broker start)ported (on the server, not in the button as the React portal did)A6
a4Silent sign-in once per browser sessionApp.vue with shared/idleSession.js silentSignInUrlidle-session.spec.mjsportedA10
a5Idle window: refresh on activity, warning, hard expiry, sign-out with reasonlib/idleTracker.js, components/IdleWarningDialog.vueidle-session.spec.mjsportedA11, A12, A13, A14
a5Cross-tab sync of the idle windownonen/adropped: the site keeps its bearer per tab (sessionStorage), so no other tab shares the session to followA14
a6Sign-out at the edge and the brokerApp.vue signOut, shared/idleSession.js logoutTargetidle-session.spec.mjsportedA15
a7Navigation from contributions, fixed entries, unread badge, own address per entryshared/portalNav.js, lib/shellData.js, components/SiteMenu.vuesite-signed-in-shell.spec.mjsportedA25, A26
a8Tab title and header carry the portal's nametemplates/site.php, BrandHeader.vue, App.vue sessionLabelsite-head.spec.mjs, site-shell-blocks.spec.mjsportedA2, A3, A4
a9One translator, nl and ensrc/shared/i18n/npm run check:l10n-js, key checks in each page specportedA18
a10Notices above every page, closable for the visitcomponents/SiteNotices.vue, shared/notices.jsnotices.spec.mjsportedA19
a10Notices for signed-in residents (surface portal)PortalPageController::sitePortalNotices, shared/notices.js noticesFornotices.spec.mjs, PHPUnit PortalPageControllerTestnewly portedA19
a11eHerkenning branch: switcher, branch in effect, refusalcomponents/BranchSwitcher.vue, shared/branch.jsbranch-choice.spec.mjs, branch-in-effect.spec.mjsnewly portedC19, C20
a12Loading in a polite status regionrole="status" on every loading line, the shell's includedportal-live-regions.spec.mjsnewly ported (shell line)A16
a13Tokens only, no theme.csscss/site-theme.css, NL Design classesnpm run stylelintported

Contribution pages, forms and actions​

#CapabilityVue portEvidenceStatusChecklist
b1Page blocks in orderpages/collections/ContributionPage.vue, pageBlocks.jssite-collections.spec.mjsportedB1
b2Collection table: columns, formatters, keyboard rows, row buttonscomponents/collections/CollectionTable.vue, cells.jscollection-table-keyboard.spec.mjs, array-cells.spec.mjsportedB3
b3Load and reload collections, unread refreshpages/collections/collectionLoader.jscollection-loader.spec.mjsportedB18
b4Detail card with file upload and downloadcomponents/collections/DetailCard.vuemy-dossiers.spec.mjsportedB9, B10
b5Rich text, no raw HTMLcomponents/collections/RichTextBlock.vuerich-text.spec.mjsportedB2
b6Record timelinecomponents/collections/TimelineList.vuecase-timeline.spec.mjsportedB14
b7Item list with removecomponents/collections/ItemList.vuemy-dossiers.spec.mjsportedB12
b8#open= record links, kept across sign-in, "not in your list"shared/openRecord.js, App.vue keepOpenTarget, pages/collections openRecordEntryopen-record.spec.mjsportedA27, A28, A29
b9crossRefsserver onlyPHPUnitn/a: a server guard
b10groupBynot in the React portaln/an/a
c1Schema formcomponents/c/SchemaForm.vue, SchemaField.vueschema-form.spec.mjsportedB15, B16
c2File fields, create then upload, retryshared/fileFieldSubmit.jsschema-form-file-field.spec.mjsportedB17
c3Inline action fieldsfolded into SchemaForm.vueschema-form.spec.mjsportedB19
c4Propose a change, proposal queuecomponents/c/ProposeChangeForm.vue, ProposalQueue.vuepropose-change.spec.mjs, proposal-queue-leaf.spec.mjsportedB11
c5Row status transitionContributionPage.vuerow-action.spec.mjsportedB4
c6Endpoint row action: confirm, notice, answer linkmodals/c/RowActionConfirm.vue, shared/rowAction.jsrow-action.spec.mjsportedB5, B6, B7
c7Endpoint and cta actionscomponents/c/ActionBlock.vue, ActionButton.vuerow-action.spec.mjsportedB8
c8Attached actionscomponents/c/AttachedActions.vueattached-actions.spec.mjsportedB13
c9Sign and decline a documentmodals/c/SigningDialog.vue, DeclineDialog.vuesigning-dialog.spec.mjsportedC21, C22

Fixed screens​

#CapabilityVue portEvidenceStatusChecklist
d1Inboxpages/inbox/InboxPage.vue, shared/messageBox.jssite-inbox-pages.spec.mjs, message-box-channel.spec.mjsportedC8
d2Notification settingscomponents/inbox/NotificationSettings.vuemessage-box-channel.spec.mjsportedC9
d3Messages with a language pickerpages/inbox/MessagesPage.vue, MessageLanguagePicker.vuesite-inbox-pages.spec.mjs, news-item-translation.spec.mjsportedC11
d4News and newsletter archivepages/inbox/NewsPage.vue, NewsItem.vue, NewsletterArchive.vuenews-title-and-newsletter-translation.spec.mjs, newsletter-title-translation.spec.mjsportedC12
d5Machine translation noticecomponents/inbox/TranslatedText.vuetranslated-message-notice.spec.mjsportedC13
d6My tasks, from the inboxpages/inbox/TasksPage.vuetasks-page.spec.mjsportedC10
d7Timed taskscomponents/timed-task/TimedTaskView.vue, TimedTaskItem.vuetimed-task.spec.mjsportedC23, C24
e1My account, contact prompt, #confirm-email=pages/e/AccountPage.vue, components/e/ContactPrompt.vue, AddressList.vueaccount-page.spec.mjsportedA23, A24, C14, C15
e2My details (BRP, KvK)pages/e/RegisteredDetailsPage.vueregistered-details.spec.mjsportedC16, C17
e3Access to casespages/e/AccessRequestsPage.vueaccess-request-asker.spec.mjsportedC18
e4My casespages/e/MyCasesPage.vue, shared/myCases.jsmy-cases-page.spec.mjsportedC1, C2
e5Acting forcomponents/e/ActingForSwitcher.vuemy-cases-acting-for.spec.mjsportedC3
e6Citizen case and its documentscomponents/e/CitizenCase.vue, CaseField.vuecase-documents-screen.spec.mjs, case-type-portal-header.spec.mjsportedC4, C5, C7
e7Withdraw a requestmodals/e/WithdrawCaseConfirm.vuecase-withdraw-screen.spec.mjsportedC6

Installability and embed​

#CapabilityVue portEvidenceStatusChecklist
f1Web app manifest, start_url on /sitePortalManifestController, templates/site.phpPHPUnit PortalManifestControllerTestportedA22
f2Service workersrc/shared/serviceWorker.js, lib/pwa.jsservice-worker.spec.mjsportedA21
f3Install offercomponents/f/InstallBanner.vueinstall-banner.spec.mjsportedA20
f4Embed frame, refusals, heightsrc/embed/ (EmbedFrame.vue, EmbedForm.vue)embed-frame.spec.mjs, embed-copy.spec.mjs, embed-height.spec.mjsportedD1 to D4

Retirement​

#ItemWhereEvidenceStatus
g1/portal and /portal/{path} answer 302 to /site with the query string; the browser keeps the fragmentPortalPageController::index, catchAllPHPUnit PortalPageControllerTest; headless Chrome kept #token=, #open=, #signin=failed and #confirm-email= across the 302newly ported
g2Server links: OIDC callback and its default, broker callback and every failure, broker logout return, mail linksSessionController, BrokerSessionController, PortalDeepLinkBuilderPHPUnit SessionControllerTest, BrokerSessionControllerTest, PortalDeepLinkBuilderTest, NotificationDispatchJobTest, PortalTaskDeliveryJobTestnewly ported
g3/site reads ?org=PortalPageController::requestedPortalSlugPHPUnit PortalPageControllerTestnewly ported
g4React portal deletedsrc/portal/, webpack.portal.js, templates/portal.php, React dependenciesnpm ls react empty, npm run build emits no portaliq-portal.jsdone
g5Tests off React; Playwright specs on /sitetests/*.spec.mjs, tests/e2e/no test compiles JSXdone

What this does not prove​

The node tests render the Vue components and drive their methods. They do not run a browser against a live instance. Line 4 of REQ-SRP-051 (each screen as the guardian on the integration instance, with a screenshot) was checked per slice by the coordinator on :8090. It is not repeated here, because this change was not deployed there.

Requests on a first visit (2026-08-15)​

The Vue renderer makes four content calls on first paint (site, menus, pages, glossary) plus one per page navigation. All are public, cacheable GETs; the anonymous variants carry public, max-age=300, must-revalidate.

The React portal makes two (session, contributions), but neither is cacheable — both are per-subject.

What was verified, and how (2026-08-15)​

Twelve scenarios in tests/e2e/scenarios/portal-phase-two.md, run as 16 tests across four spec files. All green.

Four of them were mutation-tested — the implementation was deliberately broken and the test had to fail:

MutationTestResult
Add files to the public widget allow-listS10passed — the test was blind
Same, after fixing the gateS10fails, as required
Render markdown without the shared sanitiserS9fails, as required
Drop the status: published query filterS4fails, as required
Disable cache invalidation on writeS8bfails, as required

The first row is the finding worth keeping. WidgetGrid.vue held a PUBLIC_WIDGET_KEYS set and a hard-coded widgetKey === 'markdown' check, so the allow-list decided nothing — adding a widget to it changed no behaviour. The gate looked present and was not, and the e2e test could not tell. The map is now the gate, and the same mutation fails.

Correction: per-site theming does not yet render​

An earlier version of this table claimed the Vue renderer does per-site theming. It does not. Measured 2026-08-15: open-tilburg and open-venray carry different theme classes (vng-theme, venray-theme), --pq-heading-color is UNSET on both, both compute rgb(26,26,26) for the heading, and zero elements carry NL Design classes. The two sites render identically.

The theme is a class with nothing behind it — the same defect phase one hit on a Tilburg deployment. It is tracked as gap 2.1 in the programme's gap analysis and specified in openspec/changes/portal-theme-application.

The test that should have caught it (S6b) asserted only that the API returns different theme STRINGS, and its name and my description of it implied more. Both are corrected.

Known gaps (2026-08-15)​

  • Per-site theming renders nothing (above). Until portal-theme-application lands, no visual comparison here supports a parity claim against a themed Tilburg deployment.
  • Per-portal authentication is declared in the schema and enforced nowhere. Every site currently behaves as public read-only, which happens to match the specified fail-closed default — a coincidence, not an implementation.
  • Domain verification enforces the verified flag but nothing performs the DNS TXT lookup that sets it; in this rig it was set by hand.
  • There is no editorial surface — content is created by curl. cms-handover removes OpenCatalogi's UI, so portal-cms-admin-ui must land first.
  • 1 of 40 widget types renders. That is the correct default-closed interim posture, but "widgets work" would overstate it.
  • The widget allow-list is a local stand-in. It becomes a filter over the shared registry's public flag when nextcloud-vue chain link 2 lands, and nothing already allowed may change behaviour then.
  • CnPageRenderer / CnAppRoot are not yet used. The installed @conduction/nextcloud-vue 2.2.0-vue3.16 exports both, but booting them outside Nextcloud is chain link 1 (host: 'public'); until it exists this renderer composes library helpers rather than the manifest runtime.
  • Supplier-facing capability (collections, inbox, actions, uploads) is not ported. /portal stays until it is.

Structural comparison against :8306, run 2026-08-15​

⚠️ Read what :8306 actually serves before comparing to it. The container is twu-themes2 — the tilburg-woo-ui codebase — but it is serving Softwarecatalogus content ("342 gemeenten", "336 leveranciers"), not a Tilburg WOO deployment. So CONTENT is not comparable and any claim of the form "the new portal matches :8306" would be comparing two different products. Structure and chrome are comparable; that is what was measured.

Surface:8306 (tilburg-woo-ui)new rendererverdict
Skip linkyesyesparity
Header + site nameyesyesparity
Two-level navyesyesparity
Footeryesyesparity
Glossaryyesyesparity
Browser tab titlethe site's own name"Nextcloud"DEFECT — fixed
Searchyesnogap, not started
Sign-in affordanceyesyes, when declaredparity

The tab title was a real defect and is fixed. A white-label portal whose entire purpose is that a visitor never learns what it runs on was filing itself in every bookmark, history entry, window-switcher and search result under the name of the hosting platform. It appears in no screenshot, which is why it survived a visual review. Now Page - Portal, asserted by S22.

Search is a genuine gap and is NOT started. The old portal searches its publications; the new one has no search of any kind. It is listed here rather than in a spec because no change owns it yet.