Compare commits

...
Author SHA1 Message Date
james.maes 7ff90f1a63 chore(GH-985): integrate ESB acceptance with browser fixes 2026-10-01 18:46:40 -05:00
james.maes 50a935a4a5 Merge pull request #19 from QRun-IO/feature/GH-10-esb-acceptance
fix(esb): finish management and real-broker acceptance (#10)
2026-10-01 18:30:11 -05:00
james.maes 40e987d4d0 Merge pull request #20 from QRun-IO/feature/GH-985-next-tracking
docs(tracking): manage Next UI through the QQQ project
2026-10-01 18:09:28 -05:00
james.maes 71f5a39b09 docs(tracking): centralize Next UI delivery and unreleased fixes 2026-10-01 18:04:34 -05:00
james.maes 45f99f27ce fix(auth): finish logout before allowing another sign-in
Keep authentication loading through cookie expiry and provider departure (QRun-IO/qqq#984).
2026-10-01 17:40:38 -05:00
james.maes d17e880e79 test(widgets): recognize proven missing script responses 2026-10-01 17:25:44 -05:00
james.maes f8fdb8e997 fix(esb): finish management and real-broker acceptance (#10) 2026-10-01 17:23:24 -05:00
james.maes ad154b5b56 test(acceptance): observe browser framing refusal (#981) 2026-10-01 17:22:43 -05:00
james.maes c6a58933a6 test(#979): account for Linux WebKit download filenames
Verify original application filenames and preserve downloaded data checks while matching the pinned libsoup space sanitization.
2026-10-01 17:16:51 -05:00
james.maes 26e3a0d004 test(acceptance): await initial document fonts before redirect
Complete the rendered initial document before NAV016 tears it down (QRun-IO/qqq#951).
2026-10-01 17:08:48 -05:00
james.maes c9d7fcd393 fix(forms): preserve option pointer targets in WebKit 2026-10-01 17:06:35 -05:00
james.maes b3e1ddda8b test(browser): adopt fixed WebKit network library and retain crash diagnostics
Refs QRun-IO/qqq#904, QRun-IO/qqq#960, QRun-IO/qqq#973. Preserve final RC9 tag outcomes separately from current verification.
2026-10-01 17:06:23 -05:00
james.maes 8d4668cdab test(acceptance): await breadcrumb destination iframe
Require the owned chart heading before leaving Greetings App so navigation does not cancel its pending document. Refs QRun-IO/qqq#972.
2026-10-01 16:53:35 -05:00
james.maes 254a0a2050 test(reports): await summary metadata before navigation
Refs QRun-IO/qqq#961.
2026-10-01 16:39:39 -05:00
james.maes 52cc038fa6 test(acceptance): observe transient Firefox feedback reliably
Capture toast visibility before actions and verify navigation-cancelled legacy fonts in the final document. Refs QRun-IO/qqq#951, QRun-IO/qqq#953, QRun-IO/qqq#967.
2026-10-01 16:39:23 -05:00
james.maes 6090f2f9b4 fix(search): preserve focus after early keyboard interaction
Refs QRun-IO/qqq#950.
2026-10-01 16:32:48 -05:00
james.maes 32a9560970 Merge pull request #17 from QRun-IO/feature/rc9-publication-notes
docs: record verified RC9 public delivery
2026-10-01 14:29:27 -05:00
james.maes 96085c72e2 docs(release): record verified RC9 public delivery
Refs QRun-IO/qqq#712. Preserve immutable source and distinguish active tag acceptance from completed checks.
2026-10-01 14:28:27 -05:00
james.maes c029ad2a70 Merge pull request #14 from QRun-IO/feature/next-1.0
Next UI 1.0 RC9 integration: compatibility, verification and issue tracking
2026-10-01 14:12:28 -05:00
james.maes 32a3b281ff chore(release): prepare Next UI 1.0.0-RC.9
Refs QRun-IO/qqq#712 and QRun-IO/qqq#713. Preserve known issue disclosures and the final release hold.
2026-10-01 14:11:48 -05:00
james.maes a0a18cfa76 Merge pull request #16 from QRun-IO/feature/next-issue-tracking
docs: make GitHub the Next UI source of truth
2026-10-01 14:06:09 -05:00
james.maes 354ee2709d docs(tracking): make GitHub the Next UI source of truth
Reconcile the roadmap and issue evidence under QRun-IO/qqq#713; point guides and ledgers to the live tracker.
2026-10-01 07:21:51 -05:00
james.maes 3c7c0e88b7 docs: announce verified RC8 artifacts and known issues 2026-10-01 06:41:25 -05:00
james.maes 0679672aec docs: preserve RC8 secondary browser outcomes 2026-09-30 21:26:58 -05:00
james.maes 6d0862284a docs: record RC8 acceptance and public preview evidence 2026-09-30 21:08:56 -05:00
james.maes cd3d4731d1 docs: record RC8 corrections and process visual comparison (#14) 2026-09-30 19:39:44 -05:00
james.maes 2eb075610e fix: fit API specification labels to application fonts (#14) 2026-09-30 19:38:27 -05:00
james.maes a317f0292d fix: keep search dialog keyboard focus contained (#14) 2026-09-30 19:38:27 -05:00
james.maes 14d69667dc docs: record RC8 sleep interruption and final RC7 results (#14) 2026-09-30 19:01:54 -05:00
james.maes f0f0eae98e chore: prepare RC8 stability candidate with open gate evidence (#14) 2026-09-30 16:21:06 -05:00
james.maes 66ccd34642 fix: validate bounded long values without rounding or throwing (#14)
Gate range checks behind integer validation and preserve exact integer-string bounds. Refs QRun-IO/qqq#728.
2026-09-30 16:13:51 -05:00
james.maes c954005332 test: scope process choice label lookup to its view field (#14) 2026-09-30 16:13:51 -05:00
james.maes 33cfb0b322 fix: keep API specification controls visible in narrow panels (#14)
Scope responsive button rules to the overview while preserving desktop layout. Refs QRun-IO/qqq#724.
2026-09-30 16:13:51 -05:00
james.maes b9b1f3da84 docs: record verified RC7 publication and final RC6 results (#14) 2026-09-30 16:11:44 -05:00
james.maes f65cf6bfbd chore: prepare verified local RC7 candidate (#14) 2026-09-30 15:16:17 -05:00
james.maes d5f275f85e test: check accessible sidebar labels after icons load (#728) 2026-09-30 15:12:20 -05:00
james.maes d4795c80e6 fix: retain second precision in time input blocks (#728) 2026-09-30 15:12:20 -05:00
james.maes b8b361d8da fix: improve API playground theme contrast (#724) 2026-09-30 15:12:20 -05:00
james.maes 12c21a7560 docs: record terminal RC6 candidate browser results (#14) 2026-09-30 15:11:00 -05:00
james.maes d4e3289c61 docs: record verified RC6 publication and visual review (#14) 2026-09-30 15:08:06 -05:00
james.maes 7ddf45fa4d chore: prepare verified local RC6 candidate (#14) 2026-09-30 14:13:40 -05:00
james.maes df33189ab4 fix: render legacy metadata icon names locally (#728) 2026-09-30 14:11:09 -05:00
james.maes bad28d9e21 docs: record final RC5 candidate results (#14) 2026-09-30 13:54:29 -05:00
james.maes 8900d47e28 docs: retain RC5 secondary browser results (#14) 2026-09-30 13:48:46 -05:00
james.maes fcf08769d6 docs: record verified RC5 publication (#14) 2026-09-30 13:23:00 -05:00
james.maes ac22946975 docs: record complete RC5 local verification (#14) 2026-09-30 12:25:19 -05:00
james.maes fbe9d148ec fix: persist widget field clears through record forms (#14) 2026-09-30 12:11:13 -05:00
james.maes 90a456ddcf docs: record RC5 candidate verification (#14) 2026-09-30 11:57:41 -05:00
james.maes 263f92f7bd chore: prepare Next UI 1.0.0-RC.5 candidate (#14) 2026-09-30 11:36:25 -05:00
james.maes 977c1dfda3 fix: keep column menu actions reachable in short windows (#716) 2026-09-30 11:35:38 -05:00
james.maes a8e4d3b210 fix: retain report editing preview when summary is hidden (#722) 2026-09-30 11:17:58 -05:00
james.maes 9fb613d04c fix: restore original record overview spacing (#716) 2026-09-30 10:55:38 -05:00
james.maes b1c0cedbe0 fix: restore original Next table row backgrounds (#716) 2026-09-30 10:46:37 -05:00
james.maes 0316c7a980 docs: publish verified RC4 installation and evidence (#14) 2026-09-30 09:42:17 -05:00
james.maes 3c409f9186 docs: record integrated RC4 candidate verification (#14) 2026-09-29 22:32:54 -05:00
james.maes f2cdf03bf0 fix: keep density menu open after touch focus (#694) 2026-09-29 22:11:31 -05:00
james.maes 3676fe9980 fix: preserve numeric block values while scrolling (#728) 2026-09-29 21:52:51 -05:00
james.maes 1dbe1d91ef chore: prepare 1.0.0-RC.4 candidate (#14) 2026-09-29 21:42:17 -05:00
james.maes 5be45cd0e1 fix: preserve omitted joined fields in report editors (#722) 2026-09-29 21:34:14 -05:00
james.maes b1495c4a44 docs: finalize RC3 browser verification (#14) 2026-09-29 21:28:48 -05:00
james.maes 1283bac07e docs: record RC3 tag Firefox failures (#14) 2026-09-29 21:12:26 -05:00
james.maes b971340c39 fix: honor omitted joins in report previews (#722) 2026-09-29 21:11:52 -05:00
james.maes 5ae0c8c339 docs: retain completed RC3 browser results (#14) 2026-09-29 21:07:31 -05:00
james.maes 10094ee885 docs: distinguish post-RC3 password coverage (#728) 2026-09-29 20:37:52 -05:00
james.maes f7ae67f13d fix: honor explicit password case behaviors (#728) 2026-09-29 20:36:22 -05:00
james.maes cb51aa614e docs: record published RC3 artifacts and validation (#14) 2026-09-29 20:36:21 -05:00
james.maes f1a6a3a168 docs: retain completed pre-RC3 browser results (#14) 2026-09-29 19:49:10 -05:00
james.maes ca9b5374f4 docs: record corrected RC3 acceptance gate (#14) 2026-09-29 19:46:49 -05:00
james.maes 1bd7bdcb94 fix: preserve caret across Unicode case expansion (#728) 2026-09-29 19:41:21 -05:00
james.maes dc640530f9 docs: record RC3 dependency audit (#14) 2026-09-29 19:20:19 -05:00
james.maes ef1f407993 chore: prepare verified RC3 package (#14) 2026-09-29 19:09:35 -05:00
james.maes e71ed784bd fix: honor case behavior in multiline fields (#728) 2026-09-29 19:06:30 -05:00
james.maes 73adda133c docs: record published RC2 verification (#14) 2026-09-29 19:02:07 -05:00
james.maes 9e98626d9d test: verify input block case behavior (#14)
Cover QRun-IO/qqq#728 case conversion, caret preservation, and persisted process submissions.
2026-09-29 18:40:31 -05:00
james.maes 0905cfb0cd fix: preserve parent widget CSV export (#14) 2026-09-29 17:32:16 -05:00
james.maes 542ac47593 fix: restore child export header and recovery (#14) 2026-09-29 17:02:23 -05:00
james.maes 222199916e fix: honor report preview query capabilities (#14) 2026-09-29 16:27:44 -05:00
james.maes defb3abc82 chore: prepare verified RC2 candidate (#14) 2026-09-29 16:14:20 -05:00
james.maes e87fe90d91 fix: restore original Next record layout defaults (#14) 2026-09-29 15:54:25 -05:00
james.maes d346d9bffd fix: expose pinned selection touch targets (#14) 2026-09-29 15:54:22 -05:00
james.maes 201a73901e docs: hold final release for RC1 application testing (#14) 2026-09-29 15:53:28 -05:00
james.maes d937507a2e feat: add native script editor completions (#14) 2026-09-29 15:26:02 -05:00
james.maes bf8fa4e1c2 docs: record merged backend fixes and Chromium gate (#14) 2026-09-29 15:18:35 -05:00
james.maes 95847c25ab fix: expose active filters in report preview headers (#14) 2026-09-29 14:58:03 -05:00
james.maes 303ba61fe1 fix: scope report previews and statistics to backend variants (#14) 2026-09-29 14:33:09 -05:00
james.maes 44c36fc87d fix: restore report saved-view reset behavior (#14) 2026-09-29 14:08:04 -05:00
james.maes eeab664e34 docs: record merged Google Drive backend prerequisite (#14) 2026-09-29 13:45:08 -05:00
james.maes 0fcd28344d feat: integrate configured Google Drive folder selection (#14) 2026-09-29 13:41:05 -05:00
james.maes b9a49ad29d test: deepen report statistics and CSV verification (#14) 2026-09-29 13:00:09 -05:00
james.maes 69766f1902 docs: record verified RC1 publication and browser evidence (#14) 2026-09-29 12:47:32 -05:00
james.maes e901df9101 fix: keep themed process error details readable (#14) 2026-09-29 11:55:48 -05:00
james.maes 49e51158aa fix: keep Maven publication aligned with the RC tag (#14) 2026-09-29 11:46:38 -05:00
james.maes 381cce27e6 chore: prepare RC1 with a separate Chromium gate (#14) 2026-09-29 11:41:37 -05:00
james.maes 1395a8b7d7 fix: keep report columns valid when changing tables (#14) 2026-09-29 11:25:17 -05:00
james.maes 093b8d758c fix(#719): expose persistent appearance preferences 2026-09-29 10:59:36 -05:00
james.maes 654aa28a9f fix(#728): fit widget editors across native fonts 2026-09-29 10:44:05 -05:00
james.maes 9732c56c99 fix(#649): keep query and report errors readable 2026-09-29 10:32:18 -05:00
james.maes 113785600d fix(#649): preserve column editor keyboard focus 2026-09-29 09:52:50 -05:00
james.maes a0d63ae981 fix(#649): restore compact query filter controls 2026-09-29 09:35:37 -05:00
james.maes a03a0ab7da fix(#679): retain timestamps in copies and child edits 2026-09-29 09:21:56 -05:00
james.maes 30db3098ad fix(#728): preserve input timestamp instants and seconds 2026-09-29 09:10:29 -05:00
james.maes e56ef0d7c4 fix(#728): preserve block input focus and Enter values 2026-09-29 08:55:46 -05:00
james.maes 5f47ffc630 fix(#649): make record section tabs keyboard accessible 2026-09-29 08:37:26 -05:00
james.maes 8484779d51 fix(#649): restore compact report input presentation 2026-09-29 08:28:22 -05:00
james.maes 121468b049 fix(#717): synchronize query URLs before immediate reload 2026-09-29 08:14:23 -05:00
james.maes 16dd6a7ba9 fix(#728): bind composite metadata editors without replacing plain inputs 2026-09-29 07:58:18 -05:00
james.maes d969630e50 fix(#649): complete report tab navigation and validation contrast 2026-09-29 07:13:28 -05:00
james.maes 06cf1b0c91 fix(#649): restore inline bulk profile naming 2026-09-29 06:44:43 -05:00
james.maes d1db0cd002 fix(#649): repair menu focus and preferences layering 2026-09-29 06:32:18 -05:00
james.maes 096c55adbe fix(#649): restore bulk profile grouping and status 2026-09-29 06:03:00 -05:00
james.maes 4748b6c594 fix(#649): restore compact filter and chart presentation 2026-09-29 05:40:44 -05:00
james.maes 5077c78afd fix(#649): recover Next visuals and stabilize query controls 2026-09-29 05:20:31 -05:00
james.maes c44261376a feat(#722): complete embedded report query controls 2026-09-29 04:33:43 -05:00
james.maes 6439944150 fix(#711): fit query headings and relative date menus 2026-09-28 21:17:16 -05:00
james.maes 6027e65065 fix(#711): contain query menus and restore keyboard navigation 2026-09-28 20:50:40 -05:00
james.maes 93b7b5a433 fix(#711): align native controls with the selected theme 2026-09-28 20:24:37 -05:00
james.maes 0f96c81dc8 fix(#711): recover filter actions and browser acceptance 2026-09-28 20:14:48 -05:00
james.maes 9d29663fda fix(#711): add interactive read-only report previews 2026-09-28 20:14:29 -05:00
james.maes 0e71bb03c6 fix(#711): contain record tabs and number widget context 2026-09-28 19:32:10 -05:00
james.maes 083db18e40 fix(#711): compact filter and process widget spacing 2026-09-28 19:29:00 -05:00
james.maes 384c360c03 test(#711): wait for query input in shortcut checks 2026-09-28 19:15:11 -05:00
james.maes 5cb2f5a922 fix(#711): keep query menus visible and keyboard accessible 2026-09-28 19:12:22 -05:00
james.maes 75044c1781 docs(#711): reconcile Next guides with restored defaults 2026-09-28 19:00:40 -05:00
james.maes 1af7254bc4 fix(#722): verify versioned reports and editable preview grids 2026-09-28 19:00:14 -05:00
james.maes 214cb9c88e fix(#711): restore phone titles and compact process actions 2026-09-28 18:59:31 -05:00
james.maes 7524057454 fix(#711): restore Next query, record and widget presentation 2026-09-28 18:43:35 -05:00
james.maes bf97d71e22 Lazy load full copy editor to meet bundle budget 2026-09-27 03:48:22 -05:00
james.maes 97dd622fe1 Verify report filter variables and block unresolved queries 2026-09-27 03:34:03 -05:00
james.maes c89da89ce5 Verify cron editor parity across five browser profiles 2026-09-27 03:22:24 -05:00
james.maes 38c75a49eb feat(forms): render WIDGET-adorned filter fields (#722) 2026-09-26 19:42:56 -05:00
james.maes 609d016c50 feat(widgets): add setup entry points and ordered sorts (#722) 2026-09-26 19:28:58 -05:00
james.maes 1694580521 feat(records): render qualified fields from readable joins (#722) 2026-09-26 19:17:34 -05:00
james.maes 658116dd83 fix(records): carry selected variant through record pages and files (#722) 2026-09-26 19:00:25 -05:00
james.maes c14e5aedb5 feat(records): honor record view layout metadata (#723) 2026-09-26 18:47:34 -05:00
james.maes 0aa65e2a26 feat(records): restore audit and T1 parity (#723) 2026-09-26 18:18:06 -05:00
james.maes f4faeeab56 docs(parity): reconcile script developer tools (#724) 2026-09-26 18:05:52 -05:00
james.maes 5d7e76e6d2 docs(parity): reconcile report process screens (#727) 2026-09-26 18:03:03 -05:00
james.maes ca400d5cee docs(parity): reconcile bulk load fidelity (#726) 2026-09-26 18:01:32 -05:00
james.maes c7835b8968 docs(parity): reconcile integrated process widgets (#725) 2026-09-26 17:58:46 -05:00
james.maes 7b2386dc75 docs(parity): reconcile child and widget controls (#728) 2026-09-26 17:54:58 -05:00
james.maes 6407176134 docs(parity): reconcile completed widget behaviors (#728) 2026-09-26 17:52:27 -05:00
james.maes 37bb68e659 fix(query): hide joins through unreadable tables (#696)
Reconcile deletion feedback parity (#717) and stabilize the REC-060 tablet navigation.
2026-09-26 17:50:46 -05:00
james.maes ddda45d7de docs(parity): verify unreachable Material paths (#732) 2026-09-26 17:40:18 -05:00
james.maes 04df7cc7a4 fix(auth): preserve Material Auth0 callback and errors (#732) 2026-09-26 17:35:34 -05:00
james.maes 3358f53db8 docs(parity): reconcile shell, auth and help coverage (#732) 2026-09-26 17:20:48 -05:00
james.maes 7736a6e815 feat(forms): honor inline possible values end to end (#721) 2026-09-26 17:15:22 -05:00
james.maes 642a350e2a docs(parity): reconcile mounted release coverage (#722, #729, #730) 2026-09-26 16:54:30 -05:00
james.maes 6ff2272fa1 feat(records): persist hosted dynamic form values (#722) 2026-09-26 16:54:10 -05:00
james.maes bac4c4e287 docs(parity): reconcile records and widget coverage (#723, #728) 2026-09-26 16:23:58 -05:00
james.maes 2b123fd1b3 fix(query): contain compact grid header actions (#728) 2026-09-26 16:23:43 -05:00
james.maes ed95420e66 test: prove form adjusters against versioned QQQ metadata (#720) 2026-09-26 15:58:52 -05:00
james.maes 15a9918f8f test: synchronize record menu keyboard navigation (#649) 2026-09-26 15:35:27 -05:00
james.maes 84c4846a16 test: cover Material theme against real QQQ metadata (#719) 2026-09-26 15:25:23 -05:00
james.maes 3e4d7c9520 perf: align CSS budget with release CI measurement (#710) 2026-09-26 15:00:10 -05:00
james.maes 3b20c412c5 test: cover release utilities and filter confirmation (#649) 2026-09-26 15:00:02 -05:00
james.maes 3e5bed9c9c chore: prepare Next UI 1.0.0 release metadata (#649) 2026-09-26 14:40:52 -05:00
james.maes f1e92f3be8 test: align release acceptance with Material parity (#649) 2026-09-26 14:40:52 -05:00
james.maes b55bc57f6a fix: show touch section icons and render Material link glyphs (#649) 2026-09-26 14:40:52 -05:00
james.maes 560379501c perf: split release screens and budget bundles 2026-09-26 14:40:52 -05:00
james.maes d11741f2e0 feat: edit saved report filters and columns (#722) 2026-09-26 14:40:52 -05:00
james.maes c46dfc02dc fix: verify HttpOnly session and process permissions 2026-09-26 14:40:52 -05:00
james.maes 7f299e2d03 docs: refresh Next UI guides for integrated release 2026-09-26 14:40:52 -05:00
james.maes a5322e553a feat(widgets): complete row builder editor parity (#727) 2026-09-26 14:40:51 -05:00
james.maes 1a31c75808 feat(widgets): complete pivot table editor parity (#726) 2026-09-26 14:40:51 -05:00
james.maes 803a7ecd77 feat(widgets): complete filter and column setup parity (#725) 2026-09-26 14:40:51 -05:00
james.maes 368d702581 feat(ui): complete metadata shell and widget parity (#715 #723) 2026-09-26 14:40:51 -05:00
james.maes e09ba6f79e fix(security): support HttpOnly sessions and dashboard headers (#733 #734) 2026-09-26 14:39:23 -05:00
james.maes 3437e6d8b2 test(theme): align CSS hook acceptance with form layout (#731) 2026-09-26 12:58:42 -05:00
james.maes d61f12f28f feat(theme): preserve Material CSS hooks (#731) 2026-09-26 12:51:59 -05:00
james.maes 10f0744b86 feat(theme): support Material branding and analytics (#719 #730) 2026-09-26 12:43:59 -05:00
james.maes 0dfc4d9ed2 feat(process): complete bulk load workflow (#734) 2026-09-26 12:41:53 -05:00
james.maes dbf600e894 feat(process): complete process and report workflows (#725 #736) 2026-09-26 12:40:37 -05:00
james.maes 72f5a07ea6 fix(records): honor metadata grid width and current headings (#730) 2026-09-26 12:39:28 -05:00
james.maes 9b4142c1b0 feat(developer): complete scripts and data bag tools (#723) 2026-09-26 12:29:46 -05:00
james.maes 49b3e1720b feat(forms): finish Material form layout and save feedback (#724) 2026-09-26 12:27:48 -05:00
james.maes 4e51b5ddd8 test(records): expect static export process route
The release route helper includes the trailing slash required by the Javalin-served Next export. Focused record action tests pass.
2026-09-26 12:18:58 -05:00
james.maes 2336e39573 feat(records): render metadata driven record actions (#723)
Resolve custom and default menus, run named processes, deliver record files, and preserve phone action behavior. TypeScript, ESLint, license, and 1262 unit tests pass.
2026-09-26 12:17:10 -05:00
james.maes 962d48869c test(acceptance): give the scripts fixture the API columns of the sample's API (#738)
With qqq-middleware-api on the sample's classpath, the scripts model adds
apiName and apiVersion to scriptRevision, so the owned script_revision
table needs api_name and api_version.
2026-09-26 12:17:01 -05:00
james.maes de94de58e4 feat(forms): complete adjusters and editable form sections (#720 #721 #722)
Handle on-load and field adjusters, metadata rules, inline choices, child editors, and editable widget sections. Verified by TypeScript, ESLint, and 1277 unit tests.
2026-09-26 12:08:17 -05:00
james.maes f4b15f4616 feat(forms): form context for editable widgets and widget header controls (#720, #721, #722)
Widgets receive a formContext when they edit inside a record create/edit form
or a process screen (live values, value and association writes, and
validators run before save), and share the header link button and switch
that Material's setup widgets use. Field metadata types carry inline
possible values and supplemental metadata.
2026-09-26 12:08:05 -05:00
james.maes 20a5befbdb fix(forms): accept a null optional BOOLEAN on record edit (#761)
An optional BOOLEAN stored as null, or cycled back to unset, failed client
validation with 'Expected boolean, received null'. The metadata schema now
accepts null for optional booleans and requires true or false, with a
readable message, for required ones, as Material does.
2026-09-26 12:07:57 -05:00
james.maes e9676e97af docs(query): record integrated Material parity and acceptance
Updates the Query parity ledger after the five-profile 475/475 real-server pass. Adjusts the saved-view assertion for the detailed legacy-column diff. Refs #716 #717.
2026-09-26 12:03:30 -05:00
james.maes 4c7918f785 feat(query): complete grid and saved-view parity (#716 #717) 2026-09-26 11:46:35 -05:00
james.maes 19353e6562 feat(query): complete Material filter workflows
Refs QRun-IO/qqq#715 and QRun-IO/qqq#718.
2026-09-26 10:19:20 -05:00
james.maes 510fc6e6dc test: accept additive v1 query settings in process checks (#727) 2026-09-26 09:13:15 -05:00
james.maes 74042c056e perf: allow measured Linux record-view bundle (#710) 2026-09-26 08:51:51 -05:00
james.maes ec04caf2f7 docs: add Next UI developer and agent guides 2026-09-26 08:34:38 -05:00
james.maes 3c4b24758c perf: budget combined ESB and Next UI 1.0 bundles (#710, #713)
The ESB process management merge increased measured static-export JS from 706.6 to 736.2 KB total and from 461.7 to 470.2 KB on /app/_. Set caps to 750 and 475 KB, leaving narrow headroom while retaining the route and largest-chunk gates.
2026-09-26 08:20:07 -05:00
james.maes 39bc6ae4f0 Merge remote-tracking branch 'origin/main' into feature/next-1.0
# Conflicts:
#	src/app/(dashboard)/app/[slug]/dev/page.test.tsx
#	src/app/(dashboard)/app/[slug]/dev/page.tsx
2026-09-26 08:15:29 -05:00
james.maes 89b9708bb2 fix(navigation): preserve export slashes for dotted process routes (#713) 2026-09-26 08:11:27 -05:00
james.maes 6d53c14bff feat(esb): process developer view, ESB overview, management actions (#13)
Closes #9. Part of QRun-IO/qqq#739.
2026-09-26 01:59:50 -05:00
james.maes 9b19cd184a Merge main (ESB developer section) into feature/next-1.0 2026-09-26 01:32:17 -05:00
james.maes f543bfe1f0 docs(acceptance): record the Next UI 1.0 batch 4 gate (#713)
Five projects (chromium, firefox, webkit, mobile, tablet), 2,197 tests,
347 required rows passing, phone coverage 347/347, 0 failures.
2026-09-26 01:30:28 -05:00
james.maes 037d0a86f8 Merge main (ESB developer section) into feature/next-1.0
Keeps the ESB section and the API docs on the table developer view, splits their tests, runs tablet in CI, and expects the sample's ESB 404.
2026-09-26 01:30:19 -05:00
james.maes cf3c3ad6e9 feat(esb): ESB section in table developer view (#11)
Closes #8. Part of QRun-IO/qqq#739.
2026-09-26 01:13:27 -05:00
james.maes e3bea6e6a7 test(acceptance): let the bulk load run start before QRY-034 goes back (#708)
On the iPad (WebKit) QRY-034 went back while the bulk load page was still
loading its table metadata and process init, and WebKit reports API calls
cut off by a navigation as access-control errors, which the diagnostics
fixture rightly counts for API routes. The test now waits for the process
screen, as a user would see it, before returning to the query.
2026-09-26 00:43:36 -05:00
james.maes e42ad2b2bc test(acceptance): run SEC-032 on phones and tablets (#708)
The unknown-authentication-type report is layout-neutral (the sign-in
error), so the row's test now runs in the touch projects too; the mobile
stream's @mobile copy covered the TABLE_BASED form this line supports.
2026-09-25 23:58:05 -05:00
james.maes 622e273dc1 test(acceptance): open the WID-065 phone record without expanding its sections (#708)
The phone coverage made openHost expand every accordion section so that
section widgets render on phones. WID-065's phone test checks the opposite:
only the open section mounts and requests its widget until the user opens
another. It now lands on the record as a user does.
2026-09-25 23:20:13 -05:00
james.maes 3e9960d3a4 Merge the processes, widgets, security and performance touch coverage into the integration (#708) 2026-09-25 23:12:34 -05:00
james.maes c421e37e18 test(acceptance): phone and tablet coverage for the v1, security and performance rows (#708)
Tags the tests for PRC-048, PRC-049, PRC-051, RPT-019, RPT-020, WID-064,
WID-066, WID-067, SEC-038, SEC-040, SEC-042, SEC-043, SEC-044 and
PRF-001 to PRF-005 @mobile, with touch-readiness checks on each screen:

- RPT-019 runs Render Report from the record actions (the phone action sheet);
  SEC-042 and the WID-064 schedule view open every record section (the phone
  accordion).
- WID-064 checks that the schedule editor popovers stay on screen, have no
  overlapping choices and have touch-sized targets.
- PRF-001 times the phone's default card list for the first page, then checks
  all 40 columns in its Table view; PRF-002 and PRF-003 page, sort and filter
  in the Table view on a phone and in the grid on the tablet. The heavy
  dashboard and the long process run unchanged, with no sideways scroll.
2026-09-25 23:02:28 -05:00
james.maes a7601087c7 fix(forms): keep schedule editor popovers on screen on phones and tablets (#708)
With a touch pointer each of the 60 minute choices is a 44 px target, so the
Minutes popover is taller than a phone or tablet screen. Radix placed it
partly off screen (above the top on the iPad, below the bottom on the phone),
where the choices could not be reached. The popover is now capped at the
height Radix measures beside its trigger and scrolls inside.
2026-09-25 23:02:26 -05:00
james.maes fe4297eeff Merge the records touch coverage and phone fixes into the integration (#708) 2026-09-25 23:01:45 -05:00
james.maes a16f4cd519 test(records): phone and tablet coverage for REC-049 to REC-057 (#708)
Tags the layout-neutral record tests @mobile and adds touch checks: label
breadcrumbs, v1 CRUD and possible values, dependent possible values, the
table and record developer views, record view shortcuts with the help opened
by tap, hash links (section anchors open the accordion item on a phone) and
create presets with the create-child dialog.
2026-09-25 23:00:55 -05:00
james.maes 2774be8c26 fix(records): scroll phone section anchors once the section opens, keep test log lines in their card (#708)
On a phone a #section link opens the section's accordion item, whose content
renders only afterwards, so the scroll now waits for the section to appear.
The associated-script Test Log Lines table scrolls inside the output card
instead of widening the page on a tablet with the sidebar.
2026-09-25 23:00:54 -05:00
james.maes cbfa7a9299 fix(records): fit the keyboard help and create-child dialogs on phones, return help focus on iPad (#708)
The keyboard help and the #/createChild= dialog spanned the full width of a
phone; they now keep the 1rem screen margin the other record dialogs use.
Closing the help returns focus to the header help button when the tap did
not focus it (Safari, iPad).
2026-09-25 23:00:52 -05:00
james.maes dbff3b49f7 Merge the navigation and query touch coverage into the integration (#708) 2026-09-25 22:59:35 -05:00
james.maes c7ab4c6699 fix(feedback): keep toasts below the header when a banner appears above it (#708)
The site banner renders once branding loads and pushes the header down
without resizing it, so the toast offset stayed at the header's first
position and toasts overlapped it by the banner's height. The offset now
follows every ancestor of the header, which the banner does resize.
2026-09-25 22:57:39 -05:00
james.maes b79446c65f fix(acceptance): build every acceptance run from a clean .next (#713)
A Turbopack production build that reused .next after the mobile merge
emitted CSS without the new touch-target rules of globals.css, so NAV-038
measured 36 px header buttons that the source makes 44 px. A clean build
has the rules. Acceptance runs now remove .next before building, so a
gate always tests the source as it is.
2026-09-25 22:57:12 -05:00
james.maes f1b58f50e5 test(query): phone and tablet coverage for the parity rows (#708)
Tag QRY-065 to QRY-071 @mobile. Go To and the n/r/f shortcuts use layout-neutral
record locators (cards on a phone), QRY-068 also checks that the phone cards
list the default columns in section order before switching to the grid, and the
Go To dialog is checked for touch readiness.
2026-09-25 22:56:32 -05:00
james.maes 144a340204 test(navigation): phone and tablet coverage for the record search and Material URL rows (#708)
Tag the NAV-031, NAV-032, NAV-034, NAV-035 and NAV-036 scenarios @mobile so the
mobile and tablet projects run them. NAV-032 opens the search dialog from the
phone header button where there is no header box, NAV-035 launches the record
process from the phone action sheet and selects the first card, and the quiet
app home and the phone search dialog are checked for touch readiness.
2026-09-25 22:56:31 -05:00
james.maes 1276df8a4b fix(feedback): start top-center toasts below the dashboard header (#708)
Toasts moved to the top center so they never cover Save, Next or Submit,
but there they could slide over the header's breadcrumbs, search, menu and
help controls. The header now keeps a --qqq-toast-top variable at its bottom
edge plus 8 px, following banners above it and window resizes. The toaster
offsets its desktop and phone positions by that variable and falls back to
sonner's defaults on pages without a header. REC-006 checks the created
toast sits below the header in every project.
2026-09-25 22:52:15 -05:00
james.maes 710ec015c5 Merge the mobile WebKit tooltip fix (ce670a1) into the integration (#708) 2026-09-25 22:49:51 -05:00
james.maes e54650291e test(acceptance): expect the locale-formatted last-page range in PRF-002 (#714)
The pagination range is locale formatted since the Material parity merge
(QRY-069), so the last 250 rows of 10,000 read 'Showing 9,751–10,000 of
10,000'. PRF-002 waited for the unformatted text until it timed out, which
looked like a budget failure. Its timing budgets are unchanged.
2026-09-25 22:37:04 -05:00
james.maes 6bb90cf76b test(acceptance): expect the locale-formatted last-page range in PRF-002 (#714)
The pagination range is locale formatted since the Material parity merge
(QRY-069), so the last 250 rows of 10,000 read 'Showing 9,751–10,000 of
10,000'. PRF-002 waited for the unformatted text until it timed out, which
looked like a budget failure. Its timing budgets are unchanged.
2026-09-25 22:30:33 -05:00
james.maes 0f4ae0a48d Merge branch 'feature/next-1.0-mobile' into feature/next-1.0
Phone and tablet coverage (#708) on the v1-only line:
- mobile's phone-layout test changes keep the integrated v1 routes; the
  mobile-added phone tests that waited on unversioned routes now wait on
  the v1 ones (query-phone bulk edit init, adornment download links)
- the TABLE_BASED "unsupported authentication" test (SEC-032) stays
  removed: TABLE_BASED sign-in is supported on this line (#700)
- saved-report tests keep the create-child dialog flow with mobile's
  openRecord/recordAction helpers and @mobile tags
- processRecords accepts an omitted records list only for a run with no
  records (mobile's rule) and keeps the v1 empty-list handling
- DataGrid tests keep both the row-rendering and the checkbox-label suites
- matrix rows renumbered clear of the integrated line: NAV-030->NAV-037,
  NAV-031->NAV-038, REC-049->REC-058, REC-050->REC-059 (matrix, test
  titles, browser-matrix.md)
- docs/acceptance/browser-matrix.md takes the phone and tablet scope
  draft, with the renumbered rows and the integrated TABLE_BASED, login
  branding and record search rows
2026-09-25 22:28:41 -05:00
james.maes ce670a1499 fix(widgets): hide a tapped widget tooltip on a tap elsewhere in WebKit (#708)
WebKit neither focuses the trigger on a tap nor blurs anything on a tap
on plain content, so with hover, focus and tap tracked separately the
tapped state never cleared. Close a tapped or focused tooltip on a
pointerdown outside it.
2026-09-25 22:26:06 -05:00
james.maes 4b202c4d8f test(acceptance): run the parity links and variant checks against the integrated UI (#714)
REC-056 waited for the unversioned process init, which the v1-only UI never
sends. REC-055 looked for the header search box under its pre-record-search
label. QRY-071 compared the variant id as a number, but the v1 variants
route (and so the stored variant) names ids as strings. Each check still
requires the same thing: the v1 init with the record, typing in the header
search box, and the South store variant on the records read, init and
every step.
2026-09-25 22:20:20 -05:00
james.maes 336ec87be0 Merge the batch 3 gate record into the parity integration 2026-09-25 22:14:00 -05:00
james.maes 8450428445 docs(acceptance): record the Next UI 1.0 batch 3 gate (#713) 2026-09-25 22:13:46 -05:00
james.maes 301c5f6a51 fix(feedback): show toasts at the top center, clear of primary actions (#708)
Toasts appeared at the bottom right, where every viewport keeps its
primary actions: form Save, process Next and Submit, the phone's sticky
action bar and action sheets. A failed save's error toast slid in over
Save, so a tap started on Save ended on the toast and never submitted;
a toast under a resting pointer paused its timer and blocked Next for
good. Move the toaster to the top center on all viewports.
2026-09-25 22:01:44 -05:00
james.maes 35eaaa59e8 fix(widgets): keep a widget tooltip open while its trigger is focused (#708)
The tooltip had one open flag that hover, focus and tap all wrote, so a
mouseleave closed it even while the trigger kept focus. After a tap the
pointer rests on the trigger; when a widget above finishes loading and
pushes the label down, the browser sends a synthetic mouseleave and the
tooltip closed under a still-focused trigger. Track hover, focus and tap
separately and show the tooltip while any of them holds.
2026-09-25 22:00:57 -05:00
james.maes a186853397 fix(processes): read the empty record page of a run with no input records (#708)
The backend omits the null records list when a process run has no input
records ({"totalRecords":0}). processRecords rejected that page as
malformed, so the review step's preview query failed and the global
handler raised a spurious "Something went wrong" toast over the step.
2026-09-25 22:00:21 -05:00
james.maes 6087831f26 test(acceptance): describe the parity rows against the v1 routes (#714) 2026-09-25 21:51:32 -05:00
james.maes 4ba3254468 Merge the batch 3 gate fixes into the parity integration
Brings e9fc79b and e5dba6c (PRC-003, WID-065 and the Firefox image-decode
classification) under the parity merge; processRecords keeps the stricter
object check and both zero-record unit tests.
2026-09-25 21:50:59 -05:00
james.maes 05a1076979 fix(processes): poll and cancel a variant-table run with its table variant (#714)
The v1 process routes now serve a run's state only under the backend
variant the run started with. The step and record reads already sent it,
but the async status polls and the cancel did not, so a long step or a
cancel on a variant table would have been refused.
2026-09-25 21:50:20 -05:00
james.maes e5dba6c896 test(acceptance): tell Firefox's interrupted image decode from a broken image (#713)
The batch 3 gate failed SEC-033 in Firefox on two console errors, 'Image
corrupt or truncated.' for samples-logo.png. The logo had been served
intact (200, then 304; the PNG's chunks and zlib stream check out). The
logout navigation cut off its decode, and Firefox reports that interrupted
decode as corruption. The diagnostics fixture now holds these reports until
the test ends, then has the same browser decode the image on the page's
origin. The report is recorded as an interrupted load only if that decode
succeeds. A really corrupt or truncated image, or one that cannot be checked,
still fails the test. Both outcomes were verified in Firefox.
2026-09-25 21:48:41 -05:00
james.maes e05f3ece06 build(perf): budget the lazily loaded RapiDoc chunk on its own (#714)
RapiDoc, which the table developer view loads on demand for the API docs,
is a 204.5 KB gzip chunk. Counting it in the application's total and
largest-chunk budgets would hide every other regression behind one raise.
perf-budget.json now lists it under lazyChunks: the script finds the chunk
by its custom-element definition, fails if any route loads it on first
load, holds it to its own 215 KB budget (about 5% above the measured
size), and leaves it out of the total (702.3 of 710 KB) and largest-chunk
(93.8 of 100 KB) budgets, which are unchanged.
2026-09-25 21:18:49 -05:00
james.maes 9593bbf2d8 Merge branch 'feature/next-1.0-parity' into feature/next-1.0
Material parity (#714) on the v1-only line (#699):
- possible-value searches send form values as the v1 JSON `values` map
- process init, steps and record reads send the table variant as a v1
  form field or query parameter
- loadMetaData reads the Material dashboard's all-screens processes from
  the v1 metadata (no unversioned /metaData request)
- the developer views use the v1 /table/{table}/{pk}/developer routes;
  only the API middleware catalog stays beside /qqq/v1
- lockfile regenerated with rapidoc; the removed auth SDKs stay removed
- the scheduled-report editor tests follow the create-child dialog
- parity matrix rows that collided with the integrated line were
  renumbered: PRC-048->PRC-050, PRC-049->PRC-051, QRY-065->QRY-070,
  QRY-066->QRY-071, REC-050->REC-056, REC-051->REC-057, WID-066->WID-067
  (matrix, test titles, material-parity.md)
- AGENTS.md, committed by accident on the parity branch, is left out
2026-09-25 21:16:19 -05:00
james.maes e9fc79b5cb test(acceptance): prove zero-record runs and single widget loads against the v1 routes (#713)
The batch 3 gate ran PRC-003 and WID-065 against assumptions from before
the v1 migration (#699): the v1 records route answers a zero-record run
with an empty list rather than omitting it, and widget data now loads from
/qqq/v1/widget/, so the request counter saw no requests at all. PRC-003 now
proves a zero total with no records in either serialization, and WID-065
counts the v1 widget route, so its once-only and not-yet-requested checks
are no longer vacuous. A unit test covers the v1 zero-record shape.
2026-09-25 20:57:41 -05:00
james.maes f46ae2aa09 test(e2e): mock the v1 record, process and saved-view routes the dashboard now calls (#699)
The mocked e2e suite (#709) still served the legacy /data record routes, the registered-route process metadata and one-field-per-value process forms. The dashboard reads and writes records, loads process metadata and runs processes only through /qqq/v1, so the mocks answer those routes with the v1 shapes ({ record }, the process itself, the JSON values field, process UUIDs in step and status paths).
2026-09-25 20:45:37 -05:00
james.maes 3b84e3782d Merge branch 'feature/next-1.0-v1' into feature/next-1.0
The acceptance fixture combines the security stream's Content-Security-Policy violation check with the v1 stream's guard against unversioned API routes. The v1 stream's rows REC-049, RPT-019 and WID-064 collided with rows batch 1 added under the same ids; they are renumbered REC-051, RPT-020 and WID-066 in the matrix and the test titles. CronUIWidget keeps the batch 1 view-only widget, processRecords takes the v1 route (which also reads an omitted empty list as an empty page), and PRC-003 checks the v1 records response.
2026-09-25 20:37:34 -05:00
james.maes 33227986ea docs(acceptance): regenerate the feature matrix from the full four-project gate (#714)
309 rows, 306 required rows passing in chromium, firefox, webkit and
mobile (1145 tests, retries 0); PRC-039, WID-033 and SEC-030 stay excluded.
2026-09-25 20:36:18 -05:00
james.maes 7d34d0b6c9 docs(acceptance): record the Next UI 1.0 batch 2 gate (#713)
Quality (#698, #709, #710) and security (#695, #696) integrated on feature/next-1.0 against the combined sample backend (develop with #737, the security headers and cookie hardening, and the TABLE_BASED hardening). Four-browser gate, first attempts only: 313 required rows passing, 0 failed, 0 missing, 3 excluded; chromium, firefox and webkit 362 passed each, phone 22 passed, no flaky tests.
2026-09-25 20:36:14 -05:00
james.maes df02feff26 fix(records): #/launchProcess= names no table for a hidden table process (#714)
A process missing from the record screen's list is one of the table's
hidden processes (processes added to every screen are always listed), so
the hash launch no longer adds tableName for it (REC-050). REC-051 counts
only the insert POST now that possible-value searches also POST.
2026-09-25 20:06:34 -05:00
james.maes df0712ee05 test(acceptance): the tag-records metadata customizer tolerates a session without a user (#714)
QInstance builds its table paths through MetaDataAction in a temporary
context whose session has no user; TagRecordsAudience threw a
NullPointerException there, so every child record list widget failed to
render (WID-024/026/027/029-032/060, RPT-009..018, SEC-011).
2026-09-25 20:06:33 -05:00
james.maes 7af15dea7f test(acceptance): hover the widget label again if the dashboard was still settling (#666)
In Firefox a layout shift while other widgets finish loading can move the label from under the pointer right after the hover; WID-043 still requires the tooltip to open on hover and close when the pointer leaves.
2026-09-25 20:05:25 -05:00
james.maes 289bc4126a test(acceptance): age the TABLE_BASED last-request record when expiring sessions (#696)
The TABLE_BASED hardening keeps the last request of each session in memory and counts it as activity, so SEC-036 must move that record back as well as the table access time to make a session idle past the timeout.
2026-09-25 20:05:24 -05:00
james.maes a4e51003b7 fix(processes): read a run with no records as an empty preview (#659)
The backend serializer omits empty lists, so the records route answers a run without records with {"totalRecords":0}. processRecords rejected that as malformed and the review step raised a 'Something went wrong' notification, which on WebKit covered the Next button. PRC-003 now asserts the response shape and that the run shows no error notification.
2026-09-25 20:05:23 -05:00
james.maes 555a90444f merge: navigation, query and records phone and tablet coverage (#708) 2026-09-25 19:55:02 -05:00
james.maes 3d3bb6acf5 test(navigation): phone and tablet coverage for every navigation row (#708)
Every navigation test is layout-neutral and now runs on the phone and
tablet projects too (@mobile). New rows: NAV-030, the drawer used from the
keyboard (focus moves in, Tab stays inside, Escape, the close button and a
chosen link return focus to the menu button) at a pinned 412 px touch
viewport; NAV-031, the shell's skip link, header, navigation and user menu
as touch targets. NAV-026 now runs at 412 px with touch and checks the
backdrop tap. Breadcrumbs, sidebar or drawer entries, app homes, the
landing page, search results and not-found links assert 44 px targets and
no sideways page scroll; a table outside the app tree is named by label.
The banner check reads the page banners before opening the modal drawer,
and appNavigation never re-clicks the menu behind an open drawer.
2026-09-25 19:36:36 -05:00
james.maes 01e35477ce fix(navigation): name tables, processes and reports outside the app tree by their label (#708)
A table that opens by direct link but is not in the app tree (for example
qryItem) showed its name in the breadcrumb, which is the phone header's
only title. Its label from the instance metadata now names it there and
in the document title; app-tree labels still win, and such objects stay
out of navigation.
2026-09-25 19:31:52 -05:00
james.maes 979bb06049 fix(navigation): 44 px touch targets for breadcrumbs, sidebar entries, the skip link and not-found links (#708)
Under a coarse pointer, breadcrumb links (20 px), the tablet sidebar entries
and the focused skip link (36 px) and the not-found links (36 px) now take a
44 px target. The trail no longer wraps in the fixed-height header on a
touch screen: it scrolls sideways on its own, starting at the current page,
and the header controls keep their place. Mouse layouts are unchanged.
2026-09-25 19:31:51 -05:00
james.maes 3498f7046f fix(navigation): the phone navigation drawer is a modal dialog that keeps keyboard focus (#708)
Opening the drawer from the keyboard left focus on the menu button, so Tab
walked the page behind it, and Firefox could not reach the drawer at all.
The drawer is now a Radix dialog: focus moves in on open, Tab stays inside,
and Escape, the close button, a backdrop tap or a chosen link close it and
return focus to the menu button. An open user menu takes Escape first. The
menu button reports aria-expanded; the hooks and names are unchanged.
2026-09-25 19:31:43 -05:00
james.maes 1127e2f659 docs(acceptance): mark the integrated parity rows done in the Material parity inventory (#714)
Go To (QRY-067), processes added to every screen (PRC-048), dependent
possible values (REC-052, PRC-049), table API docs (REC-053; the sample has
no qqq-middleware-api, #738) and record associated scripts (REC-054) are
Done. The table-scoped report URL is Partial: the backend report metadata
has no tableName (#732). The in-progress note is gone; row IDs are checked
against the matrix files.
2026-09-25 19:24:56 -05:00
james.maes b7f90cb27a fix(processes): accept a zero-record records response without a records list (#714)
The backend omits empty lists, so a run with no records answers
{"totalRecords":0}. processRecords rejected that and the validation review
showed a "Something went wrong" toast (PRC-003 flake). A missing records
list is now empty; a non-object body, a non-number total or a non-array
records value is still rejected.
2026-09-25 19:21:03 -05:00
james.maes 2a29872394 test(acceptance): renumber parity rows clear of the integrated 1.0 line (#714)
NAV-030/031/032 become NAV-034/035/036 (the 1.0 line uses NAV-030..033
for search and login branding), REC-049 becomes REC-055 and WID-064
becomes WID-066 (the 1.0 line uses REC-049 and WID-064/065).
2026-09-25 19:20:23 -05:00
james.maes 33f7884035 feat(records): developer views with API docs and associated scripts (#714)
- table developer view: API Docs & Playground with API and version selects and
  an embedded RapiDoc reference; "This table is not available in any APIs."
  when the instance has no application APIs (no /apis.json)
- record developer view: per associated script, versions with CURRENT, code of
  the selected version, Edit / Edit and Activate (storeScriptRevision), Test
  (testScript), run logs and script type docs; Create Script for an empty
  field; single-file scripts show their one file as Script.js whatever name it
  was stored under
- actions are offered only when the session has the processes or table edit
  permission
- acceptance: specs/records/developer.spec.ts (REC-053, REC-054)
2026-09-25 19:18:46 -05:00
james.maes 4f833aac39 test(acceptance): script tables on RDBMS and developer view fixtures (#714)
- the standard scripts tables live in the owned H2 database (snake_case,
  identities from 1000), so backend.sql reads what the script processes store;
  WID-032 rows are unchanged
- Script Lab (Records Lab) has an associated greeting script with a Java
  tester, two revisions, run logs and a record without a script
- personas get storeScriptRevision/testScript like other processes (viewer and
  noProcesses do not)
- matrix rows REC-053 (table API docs) and REC-054 (associated scripts)
2026-09-25 19:18:46 -05:00
james.maes a2b3d93856 feat(forms): send form values with possible-value searches (#714)
Material posts the current form values (a form-encoded values JSON) with
every possible-value search, so a field whose possibleValueSourceFilter
uses ${input.otherField} offers only the choices matching the form. Next
sent only the search term, so dependent fields could not narrow.

- possible-values API: new formValues request field, posted as the
  form-encoded values body the legacy routes read (GET otherwise)
- PossibleValueSelect: watches the form, sends its values with searches
  and id lookups, reloads an open list when another field changes
- usePossibleValues: formValues in the request and the query key
- process view fields send the screen values with label lookups
- acceptance: REC-052 (Lab Pick item filtered by category) and PRC-049
  (Specimen Pick filtered by category) with SQL and backend checks;
  REC-045 now selects the record insert among POSTs
2026-09-25 19:18:03 -05:00
james.maes 0caa294929 feat(processes): Material processes added to every query and record screen (#714)
- loadMetaData merges supplementalInstanceMetaData from the legacy /metaData
  route (V1 omits it); fetched on its own only when needed, and its failure
  alone is not fatal
- getProcessesForTable appends the processes named by
  materialDashboard.processNamesToAddToAllQueryAndViewScreens (or, without
  Material instance metadata, runRecordScript), present in metadata and
  permitted, deduplicated, after the table's own processes
- The query Actions menu lists them after a divider in configured order; the
  record actions menu and phone action sheet list them even when hidden from
  navigation, as Material does
- Launches name the launching table (tableName) for processes that are not
  the table's own, and the process page passes it to a table-less process
- Acceptance: PRC-048 with the table-less prcTagRecords fixture process, listed
  only for the sample user casey so other areas keep their exact menus
2026-09-25 19:17:18 -05:00
james.maes 993d79d88c feat(query): Material Go To record dialog (#714)
- Tables whose supplemental Material metadata sets gotoFieldNames get a
  "Go To..." button on the query toolbar and the record view header
- The dialog offers the primary key, then each configured unique key, with
  inputs labelled by field label; Enter submits an option; one match opens
  /app/{table}/{id}, none or several show Material's messages
- A table with GET but no QUERY capability opens Go To automatically, and it
  cannot be dismissed (Material's RecordQuery behavior)
- QTableMetaData gains supplementalMetaData (the v1 key); the legacy
  supplementalTableMetaData key is still read
- Acceptance: QRY-067 with qryBin (code; aisle + shelf) and qryLocker (GET only)
2026-09-25 19:16:59 -05:00
james.maes 9691312b13 test(acceptance): cover Material shortcuts, hash links and table-scoped URLs (#714)
- NAV-030/031: /app/{table}/{process} and /app/{table}/{id}/{process}
  open the run (history replaced, not-found for unknown segments); runs
  launched from a record or a filtered query return there
- REC-049/050/051: record view n/e/c/d/a shortcuts and help text,
  #audit, #/launchProcess=, section anchors, #/createChild= dialog and
  create-page #/defaultValues=/disabledFields= presets, proven by SQL
- QRY-065: query screen n/r/f shortcuts; r re-queries the backend
- RPT-012: Add new opens the create-child dialog over the saved report
  with Saved Report preset and locked
- QRY-034: query launches carry returnTo
2026-09-25 19:16:25 -05:00
james.maes 167e2a3a35 fix(parity): create-child dialog renders widget form sections (#714)
The #/createChild= dialog built its EntityForm without the instance
widgets, so widget-driven sections (the scheduled report cron schedule)
were missing and a schedule could not be created from the saved
report's Add new link. Pass the widgets as the create page does.
2026-09-25 19:14:27 -05:00
james.maes 8fa7d6a046 Merge branch 'feature/next-1.0-security' into feature/next-1.0
The security stream's acceptance rows SEC-034 to SEC-040 collided with the TABLE_BASED rows batch 1 added under the same ids; they are renumbered SEC-038 to SEC-044 in the matrix, the test titles, the acceptance README and the security review. The lockfile keeps the quality stream's upgrades and drops the auth SDK and cookie packages the security stream removed.
2026-09-25 19:10:55 -05:00
james.maes fcf240d800 Merge branch 'feature/next-1.0-quality' into feature/next-1.0 2026-09-25 19:08:00 -05:00
james.maes f108367db6 fix(mobile): 44 px native selects in WebKit (#708)
Safari ignores min-height on a select that has an author border but honors
height, so the coarse-pointer select rule also sets height: 44px. The rule is
wrapped in :where(), so a class height still wins and min-height still lifts
it to 44 px; mouse layouts are unchanged.
2026-09-25 19:04:37 -05:00
james.maes 455c937845 merge: processes, widgets and security phone and tablet coverage (#708) 2026-09-25 19:01:28 -05:00
james.maes b5aac3c9ff test(mobile): security and interaction rows run on phone and tablet (#708)
The list helper matches a card by an element with the exact value (card
text runs together), the navigation helper reuses an open drawer, the stale
state test waits for the record page before reading its heading, dialogs
and sign-in pages assert touch readiness, and a phone keyboard test opens a
card, the record actions sheet and the edit form and saves. The desktop
grid keyboard and grid loading variants stay desktop tests.
2026-09-25 19:00:31 -05:00
james.maes 792f635941 test(mobile): widgets and reports rows run on phone and tablet (#708)
Dashboards assert one full-width widget per row below the large breakpoint
and twelfths above it, wide tables scroll inside their card, and controls,
report run pages, saved report views and the share dialog are touch ready.
Record widgets expand the phone section accordion, record actions use the
phone actions sheet, reports use the navigation drawer, and new phone tests
cover tapping the widget label and help tooltips and the touch-sized
dropdown, date, reload and export controls.
2026-09-25 19:00:30 -05:00
james.maes f1bcddb8f2 test(mobile): every process row runs on phone and tablet (#708)
All process specs run in the mobile and tablet projects. Each screen type
(forms, validation review and summary, record lists, bulk edit, delete and
load mapping, progress, errors, completion, scanner, widgets, the cancel
dialog) asserts no sideways page scroll and 44 px touch targets on touch
screens, and the table-launched run uses the layout-neutral list helper.
2026-09-25 19:00:26 -05:00
james.maes 3cafb4d09a test(records): phone and tablet coverage for every records row (#708)
Every REC test runs on the phone and the tablet (@mobile). Record values are
read from the visible layout only (the phone accordion and the desktop tabs
are both in the page); phone tests open the accordion section a value lives
in, record actions go through the phone action sheet, and lists are read as
grid or cards. The REC-004 tab test stays desktop-only beside a new
layout-neutral section test.

Pinned phone/tablet tests: REC-049 (new row) record header and action sheet;
REC-050 (new row) forms with every editor type; tap equivalents for the
TOOLTIP adornment and field help (REC-036, REC-039); audit history, delete
and unsaved-changes dialogs that fit a phone and move focus in and back
(REC-042, REC-012, REC-008). REC-046 also asserts that a read-only user gets
no phone Actions trigger.
2026-09-25 18:50:28 -05:00
james.maes 2d051dc2ea fix(forms): unsaved-changes dialog takes focus and returns it; dialogs fit a phone (#708)
The unsaved-changes dialog left focus on the page: Radix AlertDialog focuses
its Cancel part on open and the dialog had none, so Stay now is that part.
Staying returns focus to the form's Cancel button, also in Safari, which
does not focus a clicked button. The unsaved-changes and delete dialogs keep
a 16 px margin on a phone and scroll inside on short screens.
2026-09-25 18:50:21 -05:00
james.maes f1589c3421 fix(records): record header, sections and action sheet fit phones and tablets (#708)
- The header wraps its controls (view toggle, Audit, actions) onto their own
  row when they do not fit beside the title; the title was squeezed to a few
  letters under the toggle on a phone and on the iPad with the sidebar, and
  the actions ran past the content edge.
- Long values (URLs, tokens) wrap inside their card; the list view stacks
  label over value on phones. The tab bar scrolls inside itself when many
  sections do not fit.
- The phone action sheet caps its height and scrolls, keeps Tab inside, and
  is not offered to a user without any record action.
- Closing the audit history returns focus to the Audit button, also in
  Safari, which does not focus a clicked button.
2026-09-25 18:50:15 -05:00
james.maes 375901fc34 fix(records): 44 px touch targets for value links, the back link and current-file links (#708)
Record, URL, email and file links on the record view, the Back to <table>
link and the current-file link of upload fields were text-sized (17-20 px
tall). Under a coarse pointer they now take a 44 px tall target; mouse
layouts are unchanged.
2026-09-25 18:50:07 -05:00
james.maes e8675bd7e3 fix(records): tooltips and field help open by tap on touch screens (#708)
Touch screens have no hover and Radix closes a tooltip on every click of
its trigger, so the TOOLTIP adornment, field-label help and form help
icons could not be read on a phone or tablet (a tap on the help icon in
Safari neither focuses nor opens it). A touch or pen tap now toggles the
tooltip; mouse clicks, hover and keyboard focus behave as before.
2026-09-25 18:50:01 -05:00
james.maes 9fedda9ac8 test(acceptance): fail any page request to an unversioned API route (#699)
The diagnostics fixture records page requests to the legacy QQQ routes
(/data, /processes, /widget, /possibleValues, /download, /reports,
/metaData, /manageSession, /logout outside /qqq/v1) on the QQQ servers
and fails the test on any; allow() cannot waive it. This keeps the UI
on the v1 API across every acceptance test.
2026-09-25 18:43:56 -05:00
james.maes 9ca9aa8e3b feat(api): list variants and manage OAuth2 sessions through the v1 API (#699)
Variant options come from GET /qqq/v1/table/{table}/variants ({variants},
string ids), and the OAuth2 code exchange and session resume use the v1
POST /manageSession (QRun-IO/qqq#406). Next makes no request to an
unversioned API route any more.

Acceptance: QRY-066 (the picker lists the stores from the v1 route;
v1 lists every option, none for a table without variants, 403 for an
unknown table); QRY-060 expects the v1 string variant id.
2026-09-25 18:43:48 -05:00
james.maes abe9bd7664 feat(api): run processes and read process metadata through the v1 API (#699)
Process init, step (including isStepBack), status, records and cancel
(now POST) use /qqq/v1/processes/..., and process metadata comes from
/qqq/v1/metaData/process/{name}. Init/step send the process values in
the v1 'values' JSON field (the same text the legacy form fields
carried), the record selection as recordsParam/recordIds/filterJSON,
uploads as file fields (QRun-IO/qqq#543) and stepTimeoutMillis. v1
widget-block process values are read in the block-data shape. Saved
views, sharing, saved bulk load profiles, column stats, reports and
audits run through the same functions. Backend parity (uploads, back
step, min/max input records, block type names, ownership of runs) is
in QRun-IO/qqq#406.

Acceptance: PRC-049 (a bulk load uploads, maps, steps back and inserts
on the v1 routes with SQL proof; unsafe upload 400, unknown run
refused); process and query specs now read the v1 request shapes.
2026-09-25 18:43:41 -05:00
james.maes 262f5a5bc8 feat(query): Material default column order by sections and locale-formatted pagination range (#714)
- default grid columns list each section's fields in section order (Material
  setupGridColumns bySection) instead of the metadata field map, QRY-068
- the shown range is locale formatted like the total (1,001-1,234 of 1,234), QRY-069
- docs/acceptance/material-parity.md: the Material parity inventory
2026-09-25 18:41:47 -05:00
james.maes 010bb4e020 docs(acceptance): record the Next UI 1.0 batch 1 gate (#713)
Search (#701), polish (#702, #706, #707) and auth (#700, #703)
integrated on feature/next-1.0 against the combined sample backend.
Four-browser gate, first attempts only: 301 required rows passing,
0 failed, 0 missing, 3 excluded; chromium, firefox and webkit 346
passed each, phone 20 passed, no flaky tests.
2026-09-25 18:41:39 -05:00
james.maes 423cb3e48e docs(security): record the Next UI security review against ASVS L2 (#696)
docs/security/next-ui-review.md: scope, method, fourteen findings with ASVS
references, severities and dispositions (ten fixed with regression tests,
two follow-ups QRun-IO/qqq#733 and #734, two accepted), what was verified
without findings, and the evidence.
2026-09-25 18:30:14 -05:00
james.maes 465fa77855 fix(mobile): tap tooltips and touch-sized widget, report and share controls (#708)
Widget label and help tooltips open on a tap, stay inside the viewport and
have a 44 px trigger on touch screens. Widget, block and stepper links,
the report download link and the widget, report format and share dialog
selects are 44 px targets under a coarse pointer, and dashboard grid items
may shrink so wide content scrolls inside its card.
2026-09-25 18:15:23 -05:00
james.maes 4b54e026ac fix(mobile): touch-sized process switches, selects and links (#708)
On a touch screen the bulk edit switches get a 44 px label hit area,
bulk load, profile and record-list selects are 44 px tall (WebKit ignores
min-height on bordered selects), and the download, summary record and
result links are 44 px targets. Mouse layouts are unchanged.
2026-09-25 18:15:16 -05:00
james.maes ddec18d8ad test(acceptance): performance area with large-table, dashboard and long-process budgets (#710)
- fixture/PerformanceFixtures.java (define + prime, wired into
  AcceptanceSampleServer):
  - prfWide: a read-only table of 10,000 rows and 40 columns of mixed
    types, generated by one INSERT ... SELECT FROM SYSTEM_RANGE. Every
    value is a function of id and column. prime() keeps an intact copy
    (row count, a checksum and the last name) instead of re-inserting
    400,000 values before every test of every area. It rebuilds
    anything else.
  - prfDashboard: 24 widgets (6 statistics, 6 bar, 4 line, 4 pie, 4
    tables of 100 rows) with deterministic data.
  - prfLongRun: an async job that walks all rows in 40 pages (about
    10 s), reporting progress. It logs rows, total and start/end epoch
    millis to prf_run_log.
- matrix/performance.json: PRF-001..005 (required). specs/performance
  measure wall time from the action to the exact expected content,
  record it as a timing annotation, and check it against
  specs/performance/budgets.ts. Each spec also checks structure: one
  page in the DOM (25 or 250 rows x 40 cells), skip/limit 250 on the
  query, each widget fetched exactly once, status polls never overlap
  and are spaced at least 1.5 s apart after each answer.
- Budgets are about 8x the idle times. A full four-project run on a
  busy machine measured up to 4x, and the first, tighter budgets failed
  that way twice in Firefox (next page 4,191 ms, dashboard 8,841 ms).
- docs/acceptance/performance.md: bundle and runtime budgets,
  measurements, fixes and open observations. README lists the new
  commands.

Full run (chromium, firefox, webkit, mobile) with the fixture: 1004
passed, 2 failed (those two Firefox budget breaches, before the budgets
were raised). Performance area rerun afterwards: 15/15 in chromium,
firefox and webkit.
2026-09-25 18:09:51 -05:00
james.maes 4e100a34f3 test(query): wait for the saved view route before leaving it (#708)
Firefox aborted the next page.goto while the client navigation to the new saved
view was still settling; the test now waits for the view's label first.
2026-09-25 18:08:35 -05:00
james.maes 16fa3cf0c6 feat(api): load widget metadata and widget data through the v1 API (#699)
Widget data is POST /qqq/v1/widget/{name} with the inputs as query
parameters, and the instance metadata no longer falls back to the
legacy /metaData for widget permissions and reports: v1 /metaData now
carries full widget metadata and the reports map (QRun-IO/qqq#406).

Acceptance: WID-064 (v1 metadata lists permitted widgets and reports,
the dashboard renders from POST /qqq/v1/widget, a denied widget is
absent, never requested and refused 403 without rendering); WID-054 now
checks the v1 widget path. Full chromium gate 336/336.
2026-09-25 18:04:48 -05:00
james.maes 58a4b8cbe3 Merge branch 'feature/next-1.0-auth' into feature/next-1.0
Conflicts: the search stream and the auth stream each added a NAV-030
row. Search keeps NAV-030 to NAV-032 (record search); the auth login
branding row becomes NAV-033 in matrix/navigation.json, its test title
and the phone rows of docs/acceptance/browser-matrix.md.
2026-09-25 17:57:36 -05:00
james.maes 50c6c97565 test(query): phone and tablet coverage for every query and relationship row (#708)
Every QRY/REL test runs on the phone and the tablet (@mobile). Column values are
read from grid cells or card values; tests that need grid-only columns, sort
headers or row checkboxes switch a phone to the Table view, and the phone filter
sheet is closed before the list is used. New rows with pinned 412 px touch
tests: QRY-008 card list, QRY-009 filter sheet, QRY-024 column configuration by
touch (phone and tablet), QRY-036 bulk actions from a card selection, QRY-055
saved views and export menus.
2026-09-25 17:56:16 -05:00
james.maes 09b2ebad71 fix(query): card values use the grid's cell formatting (#708)
Cards showed raw values (ISO date-times, unformatted numbers); they now render
each value with DataCell, like the grid, including possible-value links.
2026-09-25 17:56:08 -05:00
james.maes 5b0480dac2 fix(records): open deep-linked accordion sections only in the phone layout (#708)
The desktop tabs and the phone accordion are both in the DOM; opening the
accordion at every width duplicated the tab content. The section now opens
only when the phone media query matches.
2026-09-25 17:56:07 -05:00
james.maes 90615a34f9 Merge branch 'feature/next-1.0-polish' into feature/next-1.0 2026-09-25 17:55:40 -05:00
james.maes 4627ca7198 Merge branch 'feature/next-1.0-search' into feature/next-1.0 2026-09-25 17:55:36 -05:00
james.maes ca0e2cb7e7 feat(api): export and download reports through the v1 API (#699)
Table export posts {format, filename, fieldNames, filter, tableVariant}
to /qqq/v1/table/{table}/export; process and report files download from
/qqq/v1/download/{file}; reports without a process stream from
/qqq/v1/reports/{name}?format=... (the legacy /data/.../export,
/download and /reports routes are no longer used). The v1 routes enforce
the export capability, the process download registry and report
permission (QRun-IO/qqq#406).

Acceptance: QRY-065 (export request, rows, 403 without the capability),
RPT-019 (v1 download and report links with SQL-checked rows; 403 for
unregistered files and a restricted report, 404 unknown, 400 without a
format); RPT-011 now requires the v1 report link. Full chromium gate
333/334 with the one RPT-019 test fixed and rerun green (2/2).
2026-09-25 17:55:01 -05:00
james.maes aeba4f7060 fix(mobile): touch-sized native selects in WebKit (#708)
WebKit ignores min-height on a native menulist select, so on the iPad selects
stayed about 23 px tall. Under a coarse pointer a select now draws its own
arrow (appearance: none) and takes the 44 px box. Mouse layouts are unchanged.
2026-09-25 17:52:39 -05:00
james.maes 23c3073831 test(acceptance): prove the dashboard security headers and review fixes (#695, #696)
The diagnostics fixture now fails a test on any Content-Security-Policy
violation: every frame forwards securitypolicyviolation events (cspViolations
in diagnostics.json). The acceptance application allows its widgets loopback
service through the new withNextDashboardSecurityHeadersCustomizer hook.

New rows: SEC-034 (strict policy on every document, hashes equal the served
inline scripts, framing refused, injected script and handler refused and
reported), SEC-035 (OAUTH2 and AUTH_0 provider origins, other origins
refused), SEC-036 (override hook and metadata-derived origins), SEC-037
(login error codes), SEC-038 (hostile stored HTML), SEC-039 (recent records
bound to the identity), SEC-040 (SameSite/Secure session cookies). SEC-024
gains the normalized-path open-redirect cases.
2026-09-25 17:50:50 -05:00
james.maes a178f66272 chore(security): remove unused auth SDK and cookie dependencies (#696)
@auth0/auth0-react, oidc-client-ts, universal-cookie and ts-md5 were declared
but never imported; the sign-in flows live in src/lib/auth/oidc.ts. They looked
like a second auth stack to reviewers and scanners. A unit test keeps them out.
2026-09-25 17:50:40 -05:00
james.maes d19d522b6d fix(security): bind recently viewed records to the signed-in identity (#696)
Logout cleared per-user browser data, but a session that expired or was
revoked left the recently viewed records and stored identity in localStorage,
so the next person to sign in on the browser saw them. claimClientData() now
clears that data when a different identity signs in (session creation, resume
and the OAuth callback).
2026-09-25 17:50:32 -05:00
james.maes 0d272edd4f fix(security): sanitize all rendered HTML with one hardened profile (#696)
Every HTML sink used DOMPurify's default profile, which keeps style elements,
forms, form controls and position:fixed. Stored HTML could therefore cover the
page with a fake sign-in form or restyle the application. sanitizeHtml()
(src/lib/utils/sanitize-html.ts) keeps the defaults and also removes style,
form, input, button, textarea, select, option, optgroup, datalist and dialog,
and inline positioning other than static or relative. FieldValue, DataCell,
HelpContent, SafeHtml, BlockWidget, HtmlComponent, ProcessStepScreen, Banner
and RichTextField use it.
2026-09-25 17:50:24 -05:00
james.maes 16fe7ed518 fix(security): show only known sign-in error codes on the login page (#696)
/login?error=<text> displayed any text from the link inside the sign-in card
(content spoofing). Known provider and callback codes get their message,
other well-formed OAuth codes are shown as a code, and anything else shows a
generic "Sign-in failed."
2026-09-25 17:50:14 -05:00
james.maes 835acb5c79 fix(security): reject returnTo paths that normalize to another origin (#696)
safeReturnTo checked for // and /\ before parsing, then returned the parsed
pathname. Dot segments (/.//host, /app/..//host, /app/../\host) normalize to
//host, which a navigation treats as another origin. The normalized path is
now checked as well.
2026-09-25 17:50:05 -05:00
james.maes 3b2536b91b fix(reports): drop stray No fields; label pages outside the app tree (#707)
An empty dynamic form without a message renders nothing; tables, processes and reports outside the app tree title pages with their labels. Rows RPT-019 and REC-049.
2026-09-25 17:49:52 -05:00
james.maes be49ac3299 fix(records): mount only the active record view layout (#706)
Section widgets mounted in both the tab and accordion layouts; acceptance row WID-065 proves one mount and one request per widget.
2026-09-25 17:49:42 -05:00
james.maes e5e5b8ea72 feat(widgets): cron schedule editor with live description (#702)
Record forms edit cronUI sections in Basic or Advanced mode with a live description from an in-repo port of the backend CronDescriber; acceptance row WID-064.
2026-09-25 17:49:35 -05:00
james.maes ed98aec576 feat(api): read and write records through the v1 API (#699)
Record get, insert, update and delete use /qqq/v1/table/{table}[/{pk}]
(GET, POST multipart, PATCH multipart, DELETE) instead of the legacy
/data routes, reading the v1 envelopes ({record}, {deletedRecordCount,
errors}). Field downloads the backend writes as /data/{t}/{pk}/{f}/{file}
are served from the v1 record field download route (apiUrl helper).
Backend parity (associations, record-v1, permissions, null values,
empty association lists) is in QRun-IO/qqq#406.

Acceptance: REC-050 (create/view/edit/delete on v1 with SQL proof,
record-v1 associations, required-field 400, viewer 403 on writes); the
record specs now assert the v1 routes. Full chromium gate 332/332
(the two rows added for the next group were not yet collected).
2026-09-25 17:47:06 -05:00
james.maes b2c7e71712 Merge pull request #7 from QRun-IO/fix/ci-unattended-install
ci: unattended Maven publish job; version 0.2.1
2026-09-25 17:42:46 -05:00
james.maes a1c68fdbc7 fix(records): a deep-linked tab opens its accordion section on a phone (#708) 2026-09-25 17:41:49 -05:00
james.maes e48ad3173d fix(query): Escape closes the selection, saved views and export menus and returns focus (#708) 2026-09-25 17:41:47 -05:00
james.maes f3b036b375 fix(query): cards list the grid's columns in the grid's order (#708)
Cards took the first fields of the raw metadata map, so a phone showed other
values than the grid. They now use the grid's query columns, order and
visibility.
2026-09-25 17:41:46 -05:00
james.maes 92851bf8b0 feat(search): search records across tables from the global search (#701)
The header box, the '/' dialog and the search page now list the records
the backend record search (POST /qqq/v1/search) finds, after the matching
pages: record label with the table label, opened by keyboard or click.

- searchableTables() reads the capability from metadata: readable, visible
  tables that list searchFields. With none (older backends, or no
  declarations) no search request is made and the search stays the local
  jump-to from #653, with the same labels.
- useRecordSearch debounces (250 ms), needs two characters, and uses
  TanStack Query; searchRecords() validates the v1 response and replaces
  the unused globalSearch() that swallowed 404s.
- Recent records that were found are not listed twice; searching and
  failures are announced in a polite status line outside the listbox.
- Acceptance: NAV-030 (results, keyboard, search page, @mobile), NAV-031
  (noPets sees no pets, backend refuses them; saved view found by its
  owner and shared reader, not by casey), NAV-032 (security variant
  without search fields makes no /search call). NAV-025 keeps its local
  scenarios; its negative now asserts no search request fails.
2026-09-25 17:38:43 -05:00
james.maes db0c6d86aa test(acceptance): wait for the saved view's URL sync in QRY-051 (#709)
After "Save View As" the app navigates to /savedView/<id> and then
router.replace()s the URL with pageSize and filter. The test matched
the first URL and immediately page.goto()'d away, which in Firefox
could abort the second navigation's RSC fetch ("Failed to fetch RSC
payload", NS_BINDING_ABORTED) and fail the diagnostics check (seen once
in a chromium/firefox/webkit run of the query area). The assertion now
waits for the synced URL. It is stricter than before, not looser.
2026-09-25 17:34:12 -05:00
james.maes e57201ef55 perf(query): skip re-rendering unchanged grid rows on large pages (#710)
A 250-row page of the 40-column performance table is 10,000 cells, and
the grid re-rendered all of them on every query state change: once
for the page number before its request was even sent, again for the
URL sync and the fetching flags.

- DataGrid: body rows are a memoized GridRow. Rows re-render when their
  data, the column definitions, or their selection changes, and are
  skipped otherwise. RecordQuery passes a stable column-stats handler so
  tables with column statistics keep stable column definitions.
- DataCell: date and date-time cells built an Intl.DateTimeFormat per
  cell (toLocaleDateString and the time-zone name); each formatter is
  now built once. Output is unchanged.

Page turn at 250 rows x 40 columns, click to rendered rows (chromium,
M3 Max): 659-669 ms -> 518-561 ms; the time before the request is sent
dropped from about 200 ms to 110-150 ms. DataGrid.test.tsx checks that
unchanged rows skip rendering and that selection, record and column
changes still re-render.
2026-09-25 17:32:17 -05:00
james.maes 0ee169b798 perf(bundle): budget first-load JS per route and keep Recharts off /app/[slug] (#710)
- scripts/perf-budget.mjs (pnpm perf:budget): measures the static
  export (out/) per route: every script and stylesheet the route's HTML
  loads before hydration, gzip -9, plus total JS and the largest chunk
  (Next 16 no longer prints these). Compares them with perf-budget.json,
  writes test-results/performance/bundle.json and exits 1 on a breach.
- perf-budget.json: budgets about 5% above the measured sizes.
- CI: a "Bundle budget (static export)" job runs build:export and the
  check on every PR.
- Worst regression found: /app/[slug] (app home, record query, process,
  report) imported AppHome from the widgets barrel, which re-exports the
  chart widgets, so Recharts + d3 (a 94 KB gzip chunk plus 7 KB) was in
  the first load of every table and process page although WidgetRenderer
  loads charts lazily. The slug and saved-view pages import their page
  components by module path now. /app/[slug] first-load JS:
  562.9 -> 448.2 KB gzip (-20%); total JS 689.2 -> 675.0 KB, 54 -> 51
  chunks. Charts still load on demand on dashboards.
2026-09-25 17:32:06 -05:00
james.maes 5e9f013376 fix(acceptance): report the security variant ready only after startup priming (#700)
The first reset after a variant start could race the startup priming and
fail with a 500 (TABLE_BASED priming hashes passwords, widening the window).
Priming now runs under the reset lock and /acceptance/ready waits for it.
2026-09-25 17:31:09 -05:00
james.maes 40efee5f8b test(acceptance): process launches from the query carry returnTo; quiet app fixture is read-only (#714) 2026-09-25 17:29:36 -05:00
james.maes d3fad8cbda fix(query): phone filter sheet as a modal dialog, on-screen column panel, remembered view mode (#708)
The phone filter sheet is a Radix dialog: focus moves in, Escape and Close
return focus to the Filter button, and its body scrolls inside a sheet that
fits the viewport. The column configuration panel is clamped inside a phone
viewport. A chosen card/table view is remembered per table, so returning from a
record keeps it; the page reports the applied mode in data-view-mode.
2026-09-25 17:23:19 -05:00
james.maes 709edfbf15 fix(records): touch-sized View All and View links in relationship panels (#708) 2026-09-25 17:23:09 -05:00
james.maes d7b2ee8cd7 fix(query): 44 px label targets for grid and card checkboxes, cards follow query selections (#708)
Grid header/row checkboxes and card checkboxes sit inside a label, which the
coarse-pointer rule sizes to 44 x 44 px; tapping it selects without opening the
record. Cards now show all/first-N selections as checked like the grid, name
each value by field (card-field-<name>) and wrap long values instead of
cutting them off.
2026-09-25 17:23:07 -05:00
james.maes 58b952445a feat(parity): table variants in process runs, Material app-home settings, leaf block widgets (#714)
- process init, every step and the run's record requests carry the table's
  stored variant (Material's qqq.tableVariant.<table>), QRY-066
- MaterialDashboardAppMetaData showAppLabelOnHomeScreen and
  includeTableCountsOnHomeScreen are honored on the app home, NAV-032
- a widget payload that is a single leaf block renders that block, WID-064
2026-09-25 17:15:02 -05:00
james.maes 1a71b4988d docs(acceptance): list the TABLE_BASED and login branding phone rows (#700, #703) 2026-09-25 17:14:38 -05:00
james.maes a8860f6d0d chore(release): version 0.2.1
0.2.0 is published as a container image only; its Maven publish job could not run (interactive needrestart prompt), so the first Maven Central release is 0.2.1.
2026-09-25 17:12:53 -05:00
james.maes 4bb147ea4e feat(auth): TABLE_BASED sign-in and pre-sign-in login branding (#700, #703)
TABLE_BASED authentication (#700):
- The login page asks for a username and password (also after an expired
  session and after logout) and signs in through POST /qqq/v1/manageSession
  with an Authorization: Basic header; the password is read from the form
  on submit, sent once, cleared after every attempt and never stored.
- Sessions resume from the sessionUUID cookie after a reload; stale session
  cookies are expired before a sign-in so they cannot shadow the new one; a
  different user signing in drops the previous user's cached data.
- Refused credentials are shown in the form; empty fields and usernames with
  a colon are named before anything is sent.
- Acceptance on the TABLE_BASED security variant (SEC-034..SEC-037: sign-in,
  failure, idle expiry, logout) with owned users and sessions in the H2
  database; SEC-032 now proves an unknown type from an owned UNSUPPORTED
  variant, since TABLE_BASED is supported.

Login branding (#703):
- The login card shows the logo, app name, accent color, favicon and title
  from the unauthenticated GET /qqq/v1/metaData/authentication branding;
  logo and icon sources are limited to paths, http(s) and inline images.
- The dashboard shell applies branding through the same helper.
- NAV-030 proves the branding before sign-in and that banners stay hidden.
2026-09-25 17:11:49 -05:00
james.maes 8147a29e3b test(mobile): touch target and horizontal scroll helpers for phone specs (#708) 2026-09-25 17:05:59 -05:00
james.maes d0818d1a61 feat(mobile): tablet project, 44px touch targets and a phone coverage gate (#708)
Adds an iPad (WebKit, touch) acceptance project that runs the @mobile and
@tablet tests, sizes controls to 44 x 44 CSS px on coarse pointers, and makes
the gate require a passing touch-project test for every required row unless
the row records why it is desktop-only.
2026-09-25 17:05:03 -05:00
james.maes 53bc5d377c feat(parity): Material page shortcuts, hash links and table-scoped process URLs (#714)
- n/r/f on the table query and n/e/c/d/a on the record view, suppressed in text
  entry, with modifiers and while a dialog or menu is open
- /app/{table}/{process}, /app/{table}/{id}/{process} and /app/{table}/{report}
  open the process or report; runs launched from a record or query return there
- record view #audit, #/launchProcess=, #/createChild= and section anchors;
  create page #/defaultValues= and #/disabledFields= presets
- child record list Add new presets and locks the join fields, as Material does
2026-09-25 17:02:03 -05:00
james.maes 1f57dbf657 ci: keep package installation unattended in the Maven publish job 2026-09-25 17:01:24 -05:00
james.maes 06c47ee3be feat(api): load possible values through the v1 API (#699)
Table, process and standalone possible values now POST a JSON search
({searchTerm, ids, labels, useCase}) to /qqq/v1/.../possibleValues/...
instead of the legacy GET routes. The v1 routes also enforce table and
process permission (QRun-IO/qqq#406).

Acceptance: REC-049 (table field, persistence, 403 for a source the
persona cannot read, 404 for an unknown field) and PRC-048 (process
field); full chromium gate 329/329.
2026-09-25 17:00:18 -05:00
james.maes 604c44c7b9 ci: run the mocked e2e suite, src/lib coverage and Storybook on every PR (#709)
- .github/workflows/ci.yml: three parallel jobs on pull_request and
  pushes to main/develop, none needing a backend:
  unit (typecheck, lint, test:coverage), e2e (pnpm build, then the
  mocked Playwright suite against the standalone server, chromium) and
  storybook (build-storybook).
- Mocked e2e reliability:
  - saved-views.spec mocked /qqq/v1/processes/<name>/init with a JSON
    `values` field, but the app posts saved-view processes to the
    registered route (/processes/<name>/init) with one multipart field
    per value, so three tests saw no views. The mocks now match the
    real request.
  - playwright.config: QQQ_E2E_SERVER=production serves the `pnpm build`
    standalone output (scripts/e2e-server.mjs) instead of compiling
    routes on first hit; QQQ_E2E_PORT picks the port; retries 0 so a
    flaky test fails instead of passing on retry; trace kept on failure.
  - 104/104 pass; 520/520 with --repeat-each=5 --workers=4.
- vitest.config: coverage thresholds for src/lib just under the measured
  82.8/75.65/89.87/84.42 (statements/branches/functions/lines), and
  higher per-folder floors for src/lib/api and src/lib/utils.
- Storybook: silence Rollup's per-module "use client" directive and
  sourcemap-location warnings (100 lines per build).
- playwright-report/index.html and test-results/.last-run.json were
  committed despite being ignored; untracked.
2026-09-25 16:55:07 -05:00
james.maes caeadfb078 build(deps): Vitest 4.1.11 and the Vite Storybook framework (#698)
- vitest, @vitest/coverage-v8 and @vitest/ui 3.2.7 -> 4.1.11: fixes
  GHSA-82fw-gwwq-j7x9 in @vitest/mocker (patched in 4.1.11). All 93
  files / 923 unit tests pass unchanged.
- @storybook/nextjs -> @storybook/nextjs-vite (same 10.6.0 release).
  The webpack framework pulled in node-polyfill-webpack-plugin ->
  crypto-browserify -> browserify-sign/create-ecdh -> elliptic 6.6.1
  (GHSA-848j-6mx2-7j84, no patched release). None of the four stories
  uses a Node core module, so the Vite builder removes the path instead
  of shipping polyfills; 328 fewer packages resolve. The '@' alias moves
  from webpackFinal to viteFinal.
- storybook-static/ is ignored.

pnpm audit: No known vulnerabilities found.
2026-09-25 16:40:43 -05:00
james.maes 6c7eef0f9f Merge pull request #5: Next UI acceptance and QQQ-hosted default dashboard (QRun-IO/qqq#649)
Next UI acceptance, fixes and QQQ-hosted default dashboard (QRun-IO/qqq#649)
2026-09-25 16:36:14 -05:00
james.maes 2b6569f33b ci(acceptance): gate the phone project together with chromium 2026-09-25 16:05:46 -05:00
james.maes 657510542c ci(acceptance): build the sample from the QQQ checkout directory 2026-09-25 15:58:52 -05:00
james.maes e33cca670e chore(release): version 0.2.0 and report it from package.json (#649) 2026-09-25 15:53:26 -05:00
james.maes 44fff10964 test(acceptance): record James's approval of the three real-service exclusions (#649) 2026-09-25 15:52:12 -05:00
james.maes 24d573cb10 docs(acceptance): record the four-browser gate and the phone scope (#649, #694)
- browser-matrix.md: exact commands, engine versions, the @mobile scope
  and its phone rows, per-area/per-project results of the full run
  (chromium/firefox/webkit 326/326, mobile 13/13; gate 289 passed, 0
  failed, 0 missing, 3 excluded), and the engine differences fixed.
- README: browser selection, phone scope rules (@mobile, no skips),
  diagnostics handling of navigation-interrupted fetches.
- feature-matrix.md regenerated from the final gate.
2026-09-25 01:01:07 -05:00
james.maes 2c5eb2d0bf test(acceptance): recognize interrupted prefetches on every document origin (#649)
The security specs run against their own fixture server, so the UI URL is
not the only same origin: an interrupted Next.js prefetch is now matched
against the origins of the documents the page navigated to (SEC-010 in
WebKit).
2026-09-25 00:41:59 -05:00
james.maes 8712543266 test(acceptance): read WID-057 text block styles in the page (#649)
getComputedStyle() returned from evaluate() only serializes in Chromium;
Firefox and WebKit returned an empty object. Return fontSize and
fontWeight explicitly.
2026-09-25 00:21:08 -05:00
james.maes 9d57547042 fix(forms): store engine-neutral rich-text HTML (#649)
Firefox keeps a typed space before a formatting change as &nbsp;
('Plain&nbsp;<b>Strong</b>'), so the same edit stored different HTML per
browser (REC-020 in Firefox). RichTextField now reports editing-only
non-breaking spaces as plain spaces; runs of spaces and leading, trailing
or block-edge spaces keep theirs. The editor DOM is not rewritten for its
own normalized value, so the caret stays put.
2026-09-25 00:21:07 -05:00
james.maes ff15d7eee4 test(acceptance): run the phone specs in the mobile project (#649, #694)
- The mobile (Pixel 7) project runs the tests tagged @mobile: navigation
  drawer (NAV-026, INT-008), card list (INT-008, INT-009), record view,
  forms (INT-007, PRC-009), process runs (PRC-001, PRC-004), dialogs
  (INT-003), sign-in/out and expiry (SEC-020..022) and the axe scan
  (INT-005). Desktop-only layouts are not phone scenarios.
- New INT-009 phone test: a held list shows busy placeholder cards, not
  'No records found' (QRun-IO/qqq#694).
- INT-005 and SEC-021 accept the card list where they checked the grid.
- The gate reports per-project counts and fails a project that ran no
  tests.
2026-09-25 00:01:17 -05:00
james.maes cdb9549efd test(acceptance): ignore WebKit reports of Next.js prefetches cut off by navigation (#649)
WebKit raises '<url> due to access control checks.' for same-origin
Next.js route prefetches and RSC payload fetches that a document
navigation cancels (the server answered 200; Playwright never sees the
request). The diagnostics fixture now ignores such a report only when it
names a same-origin Next.js route/payload URL and arrives within a second
of a main-frame navigation, and records it under interruptedFetches.
API, cross-origin and unrelated access-control failures still fail.
2026-09-25 00:01:16 -05:00
james.maes 983e8d8230 fix(navigation): keep the metadata document title after client navigation (#649)
Next.js re-applies the route's static metadata title ('QQQ Admin') after a
client navigation; in WebKit that lands after the layout effect, so key
lookups and not-found pages kept the generic title (NAV-023, NAV-006).
useDocumentTitle sets the breadcrumb title and restores it when the head
changes.
2026-09-25 00:01:15 -05:00
james.maes c799b98bdf test(acceptance): make the security, process and relationship rows hold in the combined instance (#649)
- AcceptanceSampleServer: signing in again replaces an expired mock
  session (the manageSession persona carry-over made re-authentication
  after expiry impossible; SEC-022 first load).
- PRC-041: watch for the visit to /login?returnTo=... instead of polling
  the URL; mock sign-in returns to the process within milliseconds.
- SEC-007: the query Actions menu now carries the table's bulk actions,
  which follow the table rights noProcesses keeps; assert it offers only
  those and no standalone process.
- SEC-011: the query fixture shares alice's stock view with bob; prove
  record-level security on a view alice shares with nobody, and compare
  saved views/reports before and after instead of fixed row lists.
- INT-001: start the arrow-key step from the row above Blair in the
  current (Material id-descending) sort.
- REL-006: prove the unavailable panel on a child table denied with
  DenyBehavior.DISABLED (qryShelter/qryShelterPet); HIDDEN children are
  absent per SEC-001 (QRun-IO/qqq#671).
2026-09-24 23:48:57 -05:00
james.maes d9ddd1609d fix(records): do not expand associations whose table the user cannot read (#671, #649)
A child table denied with DenyBehavior.DISABLED is listed without read
permission. Its panel already says the related records are unavailable;
the record page no longer requests the all-or-nothing association
expansion that the backend refuses for it.
2026-09-24 23:48:50 -05:00
james.maes e9ce3f1931 fix(records): card list loading state and action sheet focus on phones (#694, #649)
- RecordCardView shows busy placeholder cards while the first page loads
  instead of 'No records found' (the grid already shows a skeleton).
- The record action sheet moves focus in on open, closes on Escape, and
  returns focus to its 'Record actions' trigger; a dialog opened from a
  sheet item (Delete) now restores focus to that trigger instead of the
  body, because the sheet item unmounts when the sheet closes.
- Copy from the sheet encodes table and record identifiers like the
  desktop actions do.

Refs QRun-IO/qqq#694
2026-09-24 23:48:49 -05:00
james.maes 947300b734 chore(merge): integrate the security and interaction area (#649)
Merge worktree-agent-ae5700825dc9daf72 (auth/session, permission gating,
a11y fixes, security specs and fixtures) into the integration branch.

Conflict resolutions:
- auth-provider: keep the security session model (auth-storage sessionUser);
  restore the anonymous@localhost fallback email the records work relies on.
- use-record-query: keep table-variant keys/params and distinct counts from
  the integration branch with the security branch's revalidate-on-mount.
- use-widget: keep UseWidgetOptions, keepPreviousData, retry:false and the
  unified HANDLES_OWN_ERRORS meta; enabled:false still skips the request.
- ConnectedWidget/AppHome: DISABLED-denied widgets keep their frame with a
  permission message and load no data (SEC-009); widget export kept.
- BulkActionBar: keep the integration action-list bar; bulk shortcuts are
  already gated by table permission and capability in buildActionEntries.
- RecordQuery: canInsertRecords for create; DataGrid keeps the resize
  handle aria values from the query work.
2026-09-24 23:34:18 -05:00
james.maes 8484d7e4c6 fix(auth): leave for the identity provider without a client navigation first; cross-browser security specs (#649)
Provider logout and sign-in retries no longer start an in-app navigation just
before leaving the page, which Firefox reported as an RSC fetch failure. Security
specs now run in all four projects: Option+Tab on WebKit, per-engine aborted
request text, card-or-grid list access on phones, Node-side Set-Cookie checks.
Adds the browser matrix record.
2026-09-24 23:25:21 -05:00
james.maes a7962d1ba6 fix(deps): override patched @humanfs/node and @babel/core (#647) 2026-09-24 23:08:06 -05:00
james.maes 850f180e5e fix(deps): upgrade dompurify to 3.4.16 (#647)
Clears the runtime HTML sanitizer advisories; production audit reports no known vulnerabilities.
2026-09-24 23:06:38 -05:00
james.maes f856d926ba test(acceptance): keep RPT-018 independent of seeded view shares (#649) 2026-09-24 23:02:01 -05:00
james.maes 523da4ca49 test(acceptance): pin NAV-017 quick actions to the combined instance (#649)
Pet Species is enumeration-backed and not insertable; assert it is omitted and Pet Note fills the sixth slot.
2026-09-24 23:02:01 -05:00
james.maes 50f4e3fe4d test(acceptance): launch PRC-005 from the query Actions menu (#649) 2026-09-24 23:02:00 -05:00
james.maes 76105121cb test(acceptance): wait for selection launches on the registered process route (#649) 2026-09-24 23:01:59 -05:00
james.maes 0ba5fe447e fix(query): keep the column configuration panel within the viewport (#649)
The fixed panel now caps its height at the room below its button, so every column stays reachable when banners push the toolbar down.
2026-09-24 23:01:58 -05:00
james.maes 82df289ba7 test(acceptance): keep the test persona and user through mock manageSession (#649) 2026-09-24 22:55:28 -05:00
james.maes 1714c6a2c3 Merge query and relationships area work (#649) 2026-09-24 22:41:30 -05:00
james.maes 3ddb1311f8 test(acceptance): align area specs with the combined instance (#649)
- WID-021, RPT-010: the last frontend step before backend work submits
  ("Submit") and reports download through the process download form.
- WID-061: process metadata comes from the registered /metaData/process route.
- PRC-042, RPT-007: denied objects show the not-found state.
- NAV-025: other areas add pages matching "pet"; require every result to
  match and the pet tables to keep their order.
- RPT-012: Is Active defaults on from metadata, so check it rather than
  toggle it; clear memory-backend scheduled reports on every reset so each
  test starts without the rows earlier tests created.
2026-09-24 22:38:06 -05:00
james.maes 1a649a5458 fix(forms): render the cron schedule widget section on record forms (#649)
With hidden sections now hidden on record forms, the scheduled report form
had no way to set its schedule: its cron expression and time zone fields
sit in a hidden section and are edited through the cronUI widget section
(includeOnRecordEditScreen), which forms did not render. Record forms now
render the fields a cronUI widget section edits in that section, as
Material's EntityForm does, and keep the rest of the hidden section hidden.
2026-09-24 22:38:05 -05:00
james.maes 57b80eb1fd Merge widgets and reports area work (#649) 2026-09-24 22:14:47 -05:00
james.maes eca667af73 test(acceptance): align security specs with the integrated navigation and records work (#649)
Use the shared not-found state, the records area's password rendering and form
error messages, mirror the noApps persona in the security variant, and use
repo-qualified issue references in the matrix.
2026-09-24 22:12:35 -05:00
james.maes e9eb14f4f0 fix(types): drop duplicate help content format declaration 2026-09-24 22:10:42 -05:00
james.maes 301bf3b31b test(acceptance): query and relationships matrix, fixture and specs (#649)
50 QRY/REL rows covering filters and operators, sort, paging, search,
columns, exposed joins, column statistics, selection handed to processes,
backend export, saved views, table variants, capabilities, associations
(parent/child, three-level, aliased and composite, denied reads, cascade
delete) and related links, each verified against SQL and the backend API.
2026-09-24 22:10:20 -05:00
james.maes a98aa8f089 fix(query): bring record query to Material parity on the live backend (#649)
- Filter builder: Material per-type operators and labels, every backend
  operator (LIKE, NOT_LIKE, IS_NULL_OR_IN, TRUE, FALSE), relative date
  expressions in the backend format, exposed-join fields, UTC date-times,
  typed values, stable rows while typing, labelled possible-value chips (#684)
- Query state: quick search ANDed with the filter, joins sent as LEFT only
  when used with join columns and distinct counts, primary-key-descending
  default sort kept in the URL, Material page sizes, foreign URL params
  preserved (#686)
- Saved views through querySavedView/storeSavedView/deleteSavedView with
  your and shared groups, unsaved-change tracking and the savedView route;
  anonymous sessions use the manageSession user (#687)
- Backend CSV/XLSX/JSON export of visible columns and the current filter,
  gated by TABLE_EXPORT (#688)
- Selection of the page, the full query result or the first N handed to
  processes without the page skip/limit; Actions menu with bulk processes
  gated by capability, permission and process (#689, #662)
- Table variants from /data/{table}/variants sent with query, count and
  export and remembered per table (#690)
- Related-record links in grid cells, empty null booleans, column
  statistics for QUERY_STATS tables (#691)
- Capability gating for count, export, create and bulk actions (#671)
2026-09-24 22:10:19 -05:00
james.maes eff2d11254 Merge processes area work (#649) 2026-09-24 22:09:27 -05:00
james.maes 730b38930a test(acceptance): widgets and reports matrix, fixture and specs (#649)
75 WID/RPT rows: one per canonical widget type plus chrome, controls, states,
permissions, parents, blocks, record-view widgets, reports in each format,
saved and scheduled reports, and sharing. WidgetsFixtures adds owned dashboards
for every widget shape, a record-view host table, data bags, scripts, reports
of each type, owned saved reports, and a loopback fake service so the real
QuickSight renderer, a custom component bundle, an image and audio run without
external accounts. The app home lays widgets out in QQQ's 12-column grid.
2026-09-24 22:09:24 -05:00
james.maes ce7a5a690a feat(reports): run reports through their process and add record sharing (#664, #665)
Reports: take the reports map (absent from V1 metadata) from the full metadata
route so report pages resolve; run each report through its process with the
chosen format (CSV, XLSX, JSON), prompt for its input fields with required
validation, poll asynchronous jobs and offer the generated file; reports
without a process use the streaming /reports route.

Sharing: shareable tables get a Share action on the record view (owner only,
with the reason shown to others) and a dialog that lists audiences, shares
with a scope, changes a share's scope and removes shares; recipient ids with
colons are kept whole (#444). Mock/anonymous sessions now take the user the
session identifies, so ownership checks compare real identities (#371).
2026-09-24 22:09:15 -05:00
james.maes 23a855297d test(acceptance): security and interaction matrix, fixtures, variants and specs (#649)
SEC rows cover hidden/disabled tables, processes, widgets and apps, CRUD and
capability denials with backend enforcement and SQL readback, record security
locks, protected fields, mid-session revocation, MOCK sessions (sign-in, logout,
expiry, denial, returnTo), OAUTH2 and AUTH_0 against an owned local OIDC
provider, FULLY_ANONYMOUS and unsupported TABLE_BASED. INT rows cover keyboard
flows, dialogs and focus, axe WCAG 2.1 AA scans, labels and validation, phone
layout, and held, failed, delayed and stale requests.

SecurityAcceptanceServer is a security-only backend variant (own port) that adds
SecurityFixtures to the stock sample and selects the authentication module.
2026-09-24 22:09:09 -05:00
james.maes 818935ec23 fix(a11y): keyboard-open grid rows, keep focus while typing, trap and restore dialog focus (#682, #676)
Enter on a grid cell opens its record. Form fields keep one wrapper element so
the first keystroke no longer remounts the input and drops focus. The command
palette traps Tab and restores focus on close; state-controlled Radix dialogs
(delete confirmation, keyboard shortcuts) return focus to what opened them.
Column resize separators expose their value (axe aria-required-attr).
2026-09-24 22:09:09 -05:00
james.maes cc58e1dcbb feat(widgets): render every canonical QQQ widget type with Material parity (#550)
Add renderers for all backend WidgetType values: multiStatistics, table,
multiTable, stepper, smallLine/horizontal/stacked bar charts, fieldValueList,
location, usaMap, quickSightChart, process, parentWidget (grid and tabs),
QQQ composite blocks (every block type and layout), customComponent, cronUI,
dynamicForm, dataBagViewer, pivotTableSetup, filterAndColumnsSetup, rowBuilder,
scriptViewer, generic, and child record lists without an association (#663).

Widget chrome now follows Material (#666): payload-driven PVS and DATE_PICKER
dropdowns with stored selections, the backend's required-selection message,
CSV export of csvData, reload, tooltips, help content, header icons, card and
plain widgets, footers, 12-column gridColumns sizing, hidden and empty alerts,
contained error and malformed-payload states without retries or error toasts.
Record-view section widgets receive the record context; form sections without
fieldNames no longer crash.
2026-09-24 22:09:03 -05:00
james.maes 1d28e4608a fix(data): revalidate records and lists, keep one record key, never retry writes (#683, #673)
Lists and records revalidate whenever they are shown again instead of serving a
30 s / 5 min cache, record keys normalize the primary key so a save refreshes
the viewed record, deleting no longer re-requests the deleted record, and
mutations are never retried automatically (writes are not idempotent).
2026-09-24 22:08:27 -05:00
james.maes 8cc626d61f fix(security): gate disabled tables, processes and widgets and honor table capabilities (#671, #651)
Objects denied with DenyBehavior.DISABLED are listed with their permission flag
false: list, record, create, edit and process links now explain the denial and
never load or start them; app homes show them as disabled tiles; /app quick
actions skip them. Disabled widgets show Material's permission message without
requesting data. Record actions, list toolbar, bulk bar and write pages require
both the permission and the table capability. Record views neither request nor
show associations whose table is hidden from the user. Apps without widgets or
section arrays no longer crash the landing page or app home.
2026-09-24 22:07:14 -05:00
james.maes 439349ddeb fix(processes): keep step wizard label tooltips open on keyboard focus (#649)
Radix Tooltip closes on any scroll containing its trigger, so focusing an
off-screen step label never showed its full text. Each step now uses the
focus-safe tooltip hook (copied verbatim from the integration branch) on its
focusable trigger: the back button for completed steps, the label itself
otherwise.
2026-09-24 22:05:33 -05:00
james.maes b10ce42f64 fix(auth): complete sign-in, logout and session expiry for every auth type (#669, #670, #332)
- MOCK / FULLY_ANONYMOUS: manageSession (JSON body v1 reads) creates the session and
  its values.user is the displayed identity; a denied session is an error, never an
  anonymous fallback.
- OAUTH2: authorization code + PKCE with provider discovery, backend scopes and the
  Material-compatible /token redirect URI; the backend redeems code + verifier
  through the unversioned manageSession (v1 accepts only accessToken, #406);
  reloads resume the session from its cookie.
- AUTH_0: the browser redeems the code as a public client and posts the token.
- Logout ends the backend and provider sessions, clears cached queries, recent
  records and the stored identity, and keeps the tab signed out until Sign in.
- A 401 routes to /login?returnTo and signs in again automatically (with a loop
  guard); session endpoints report their own 401s.
- returnTo is limited to same-origin in-app paths (open redirect).
- API errors carry the backend message instead of the generic axios text.
2026-09-24 22:04:57 -05:00
james.maes 350be92d6d chore(acceptance): accept repo-qualified issue references in the matrix 2026-09-24 22:04:19 -05:00
james.maes 04e738671a test(acceptance): link REC-036 to #685 2026-09-24 22:04:08 -05:00
james.maes d72143e7d1 fix(a11y): keep keyboard-opened tooltips open through the focus scroll (#649)
Radix Tooltip closes on the scroll that focusing an off-screen trigger causes; add noApps persona and make navigation specs robust to other areas' fixtures.
2026-09-24 22:03:50 -05:00
james.maes 93afb572f8 test(acceptance): processes matrix, fixture and real-backend specs (#649)
47 PRC rows covering record selection (selected, all, zero, none, from the
query grid), min/max input records, every frontend component on mixed
screens, validation, possible values, downloads, back steps, dynamic step
lists and updated fields, async progress, cancel from a screen and mid-job,
errors and retry, completion, record-list paging, streamed ETL review and
summary lines, bulk edit/delete/insert/edit-with-file with saved bulk load
profiles, named/seeded/ad hoc widgets, scanner and state-machine flows,
reload, expired session and permissions. Persisted effects are checked with
SQL and server enforcement through the backend API.

The owned fixture adds a Process Lab app (component lab, route wizard,
progress with cancel step, bounds, flaky loader, failures, widgets, drive,
early finish, many rows, scanner, loop, quick task), a specimen table, a
memory upload archive for bulk loads, and saved bulk load profile tables.

PRC-039 (Google Picker) needs an external Google account: required false,
approval pending. PRC-041 needs the backend fix for #668.
2026-09-24 21:59:51 -05:00
james.maes 0d477151f8 feat(processes): save, choose and delete saved bulk load profiles (#660)
The file mapping, value mapping and profile screens offer the saved bulk
load profile processes when the application defines them: choose a saved
profile to restore its mapping, Save / Save As the current mapping, and
delete it, re-submitting savedBulkLoadProfileId so review and result name
the profile in use.

Found under #649.
2026-09-24 21:58:12 -05:00
james.maes cc0f91c3e1 Merge branch 'worktree-agent-abc519c235b8dc3e7' into feature/GH-649-next-acceptance 2026-09-24 21:55:35 -05:00
james.maes 6b0e305a9c test(acceptance): navigation matrix, fixture and specs (#649) 2026-09-24 21:54:36 -05:00
james.maes f180a469c5 fix(navigation): render nav, app homes and branding from v1 metadata
Nested app tree, breadcrumbs, titles, icons, banners, local search, not-found and key routes (#538 #539 #651 #652 #653 #654).
2026-09-24 21:54:32 -05:00
james.maes a78af68929 chore: ignore Playwright MCP output 2026-09-24 21:48:58 -05:00
james.maes 4ead47f053 Merge branch 'worktree-agent-a30785eaa4b97f110' into feature/GH-649-next-acceptance 2026-09-24 21:48:45 -05:00
james.maes 7142274d76 test(acceptance): add the records matrix, Record Lab fixture and specs (#649)
48 REC rows cover record view, create/edit/copy/delete, every field type and
editor, Field Lab policies (length, case/whitespace, ranges, unique keys,
dynamic defaults, zoned date-times), every adornment, field/section/table
help, audits (process and audit-table paths), record labels, possible-value
editors, a read-only persona and keyboard use. Each test verifies persisted
values with SQL and diagnoses console/request noise.

RecordsFixtures adds the Records Lab app with the Record Lab table (every
adornment, help content in each role and format, hidden field and section,
unique key, FIELD audits, heavy and formatted file fields), the standard
audit tables and GetAuditsForRecord, and seeds two rows.

REC-039 (field help) needs the v1 field helpContents from the backend branch
feature/GH-649-records-backend (#406, #681).
2026-09-24 21:47:37 -05:00
james.maes 6c78038ced feat(processes): compose every declared process component on one screen (#645)
Rewrite the Next process runtime for parity with the Material dashboard:

- use the registered process routes (/processes/..., /metaData/process) that
  accept uploads, step back with isStepBack and report min/max input records;
  send tableName on init and only the screen's values on each step (#656)
- render every declared component in order with one shared form and one
  action bar: HELP_TEXT, EDIT_FORM, VIEW_FORM, RECORD_LIST, HTML, DOWNLOAD_FORM,
  VALIDATION_REVIEW_SCREEN, PROCESS_SUMMARY_RESULTS, WIDGET, BULK_EDIT_FORM,
  BULK_LOAD_* and GOOGLE_DRIVE_SELECT_FOLDER (#645)
- Back only to the backend backStep (#655); Next/Submit/Return per step flow
- poll async jobs every 1.5s with RUNNING progress, bounded error backoff (#657)
- retry with the original record selection (#658)
- validation review, summary lines and results from QQQ values (#659)
- real bulk load file/value mapping and profile screens (#660)
- named, seeded and ad hoc composite widgets with action blocks (#661)
- min/max input records, updatedFrontendStepList and updatedFields (#662)
- scanner-format screens, state-machine flows, defaultProcessValues links
- possible-value selects show the label of an initial value (#667)

Found under #649.
2026-09-24 21:47:36 -05:00
james.maes 5647e3c8e3 fix(records): make record view, forms and adornments follow QQQ metadata (#649)
- Delete no longer refetches the deleted record: no "Resource not found"
  toast or 404 after a successful delete (#541).
- Forms keep keyboard focus while typing; the HTML editor keeps its caret (#676).
- Edit resends only values that round-trip: masked passwords, LONGs,
  date-times and files are sent only when changed (#672); DATE_TIME is shown
  and entered in the viewer's zone and sent as UTC; typed LONGs stay exact (#679).
- Create applies metadata defaults; edit shows stored values and read-only
  fields; hidden sections are skipped; the server's too-long policy is not
  pre-empted; files can be removed (#680).
- Adornments use the backend value keys: LINK, CHIP, FILE_DOWNLOAD, TOOLTIP,
  SIZE (grid width), REVEAL, RENDER_HTML, ERROR, plus WIDGET rendering and a
  CODE_EDITOR editor; heavy fields show on the record view (#677).
- Audit history uses GetAuditsForRecord, or the audit table without process
  permission (#678).
- Field and section help follow screen roles and formats (#681).
- Server validation errors show the backend message once and keep the form (#673).
- Possible-value editors show the stored label, look up default labels and
  work from the keyboard (#667).
2026-09-24 21:47:27 -05:00
james.maes 33c2335c94 docs: describe the QQQ-hosted default dashboard and acceptance suite (#649) 2026-09-24 20:16:58 -05:00
james.maes 14d5b9a831 ci: add real-backend acceptance workflow and Maven Central dashboard publish (#649) 2026-09-24 20:15:39 -05:00
james.maes 6359846bba test(acceptance): enable sample sharing identities in the fixture (#649) 2026-09-24 20:02:46 -05:00
james.maes f276ea841a feat(acceptance): serve Next from QQQ and add real-backend acceptance harness
Static export mode with placeholder routes and Maven packaging for the Javalin-served default dashboard, plus the matrix-gated acceptance suite (QRun-IO/qqq#649).
2026-09-24 19:54:47 -05:00
james.maes 5b5af2ce18 Merge pull request #4 from QRun-IO/feature/qqq-641-local-quickstart
feat(quickstart): runnable Next image and real process walkthrough
2026-09-24 15:52:38 -05:00
james.maes d4c2ecc084 fix(ci): wait through dashboard startup resets (#641) 2026-09-24 15:50:14 -05:00
james.maes fec12b630f fix(security): resolve Next High and Critical dependencies (#646) 2026-09-24 15:42:59 -05:00
james.maes 68f850aa1c feat(quickstart): publish standalone Next dashboard image (#641) 2026-09-24 15:20:49 -05:00
james.maes a9c547f706 fix(process): load actual paginated record previews (#643) 2026-09-24 15:19:23 -05:00
james.maes 3040d20b45 fix(process): load metadata and preserve input selection (#642) 2026-09-24 15:16:07 -05:00
james.maes 2d84445645 fix: align Next dashboard with QQQ 4.0 contracts
Preserve native API and permission behavior, document deferred widget parity, and pin the verified package manager. Refs QRun-IO/qqq#498, QRun-IO/qqq#540, QRun-IO/qqq#549, QRun-IO/qqq#616.
2026-09-24 06:50:24 -05:00
James MaesandClaude Opus 4.8 a8b4b65373 docs: add CLAUDE.md pointer to QQQ knowledge-base dossier + hub
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:31:50 -05:00
james.maesandClaude Opus 4.6 41cd6d2034 docs: implement all documentation review fixes (DOC-CRIT + DOC-SIG)
Addresses every actionable item from docs/DOCUMENTATION-REVIEW-2026-03-01.md:

DOC-CRIT-1: README.md — expanded from one line to full project README
  - Prerequisites, quick start, command table, env vars, directory overview,
    tech stack summary, links to CLAUDE.md and all docs

DOC-CRIT-3: docs/STATE-MANAGEMENT.md — decision guide for all state containers
  - Decision matrix table; TanStack Query, React Hook Form, URL params,
    localStorage (with actual key names), QContext, useState/useReducer — each
    with scope rules, code examples, and what NOT to put there

DOC-SIG-1: docs/COMPONENT-PATTERNS.md — five architectural patterns
  - Metadata-driven rendering, data-qqq-id convention, useAsyncCombobox,
    widget rendering pipeline, field adornment priority — all grounded in
    actual source code with examples

DOC-SIG-2: docs/TYPE-GLOSSARY.md — ~45 types across 6 domain sections
  - App/Auth, Metadata, Records/Queries, Processes, Widgets, Enums
  - Key-field descriptions, usage examples, import cheat sheet

DOC-SIG-3: docs/API-ERROR-HANDLING.md — error handling architecture
  - 401 interceptor, global query/mutation handler, status-by-status guide
    (400→form fields, 403→hide/toast, 404→empty state, 5xx→toast+retry),
    when to add per-query handling, retry back-off, complete form example

DOC-SIG-4: zod-from-metadata.ts — @example added to zodFieldFromMetadata
  - Shows STRING required+maxLength and INTEGER optional+bounds examples

DOC-SIG-5: docs/FORM-VALIDATION.md — end-to-end validation strategy
  - Schema generation, field-type→Zod mapping table, required vs optional,
    number bounds, default values, client vs server validation, custom
    refinements, DynamicFormField dispatch, complete worked example

DOC-MIN-3: docs/ACCESSIBILITY.md — WCAG 2.1 AA reference
  - All implemented items, CSS media queries, testing guide, new-component
    checklist, outstanding items

552/552 tests pass; 0 TypeScript errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:34:11 -06:00
james.maesandClaude Opus 4.6 8b8e1c7c03 fix: add missing JSDoc to ValidationReviewStep; add documentation review
- ValidationReviewStep.tsx: move ValidationFilter type definition above
  the component's JSDoc block to satisfy jsdoc/require-jsdoc (was failing CI)
- docs/DOCUMENTATION-REVIEW-2026-03-01.md: full documentation audit with
  3 critical gaps (README, useRecordQuery return type, state-management guide),
  6 significant gaps, and 4 minor items; overall score 7.5/10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:20:03 -06:00
james.maesandClaude Opus 4.6 12edc4cf1e feat: resolve all design-review issues (MED + LOW)
Addresses every open item from docs/DESIGN-REVIEW-2026-03-01.md:

Layout shell (D-L / D-FB / D-R / D-T)
- D-L-3: Mobile search button in Header; prop wired through dashboard layout
- D-L-4: Banner.tsx persists dismissed keys to localStorage (qqq:dismissed-banners)
- D-L-5: Sidebar aria-expanded on mobile drawer
- D-L-6: Breadcrumb segments truncated (max-w-[8rem]) + flex-wrap on small screens
- D-L-7: Sidebar loading skeleton (6 animated bars) while routes are empty
- D-FB-1: HelpCircle button in Header triggers CommandMenu keyboard-shortcuts view
- D-FB-3: CommandMenu close button upgraded to 44 × 44 px touch target
- D-FB-4: GlobalSearch group headers: font-semibold uppercase tracking-wide
- D-T-3: qqq-theme.css: --qqq-primary-color / --qqq-accent-color token aliases
- D-T-4: CommandMenu z-index uses var(--qqq-z-toast) CSS token
- D-T-5: Dark-mode focus ring uses solid var(--color-ring), not 50% opacity
- D-T-6: --color-disabled-bg / --color-disabled-text in @theme + dark overrides
- D-R-3: Breadcrumbs flex-wrap so items reflow at 320 px

Record query (D-Q / D-R)
- D-Q-5: Pagination "go to page" input validates range; shows role="alert" on error
- D-Q-6: DataGrid aria-live region announces "Loading results" on background refetch
- D-Q-7: ColumnConfig SR announcement after column show/hide toggle
- D-Q-8: Sticky first two columns (checkbox + first data col) with CSS position:sticky
- D-R-2: Pagination navigation buttons and ColumnConfig close button ≥ 44 px targets

Record view (D-V)
- D-V-2: AssociatedRecords shows first 25 rows; "Show more (N remaining)" button
- D-V-3: getInitials() returns '?' for empty/whitespace display names
- D-V-5: Copy record-ID button (ClipboardCopy → Check icon swap + sr-only feedback)
- D-V-6: Audit trail footer shows createDate / modifyDate when present

Forms (D-F)
- D-F-2: NumberField renders min/max hint text; aria-describedby wired
- D-F-3: DateTimeField shows local timezone hint (Intl.DateTimeFormat)
- D-F-4: FieldHelpTooltip: tooltip on desktop; inline hint text on mobile
- D-F-5: FileUploadField drag-over overlay "Drop file here" with border-primary
- D-F-6: DynamicFormField DirtyWrapper highlights dirty fields with left border;
         dirtyFields prop threaded through EntityForm → DynamicForm → field

Process (D-P)
- D-P-2: StepWizard completed steps are clickable (onStepClick callback)
- D-P-3: ProcessRun shows timeout warning banner after 60 s of polling
- D-P-4: ValidationReviewStep filter (All / Errors / Warnings) + search input
- D-P-5: ProcessUploadFormStep indeterminate progress bar during upload
- D-P-6: StepWizard step labels wrapped in Radix Tooltip for long names
- D-P-8: BulkLoadStep save/load column mapping to localStorage

Widgets (D-W)
- D-W-2: WidgetBlock error state visual refresh (AlertCircle icon + Retry link)
- D-W-3: Bar/Line/PieChartWidget: ResizeObserver-based responsive legend layout
- D-W-4: RecordGridWidget caps display at 25 rows; "View all N records" link
- D-W-6: Chart palette extended from 5 to 10 colors (--color-chart-6…10)

Global CSS (D-T / D-L)
- D-T-1: prefers-reduced-motion collapses animation/transition durations
- D-T-2: prefers-contrast:more uses full-black borders + solid focus ring
- D-R-1: Mobile inputs/selects/textareas set to 16 px to prevent iOS zoom

Tests: 552/552 pass; 0 TypeScript errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:13:05 -06:00
james.maesandClaude Sonnet 4.6 511c005cad fix: address all must-fix WCAG 2.1 AA issues from design review
- globals.css: add prefers-reduced-motion rule (collapses all animation/
  transition durations to 0.01ms for users who prefer reduced motion)
- globals.css: add prefers-contrast: more rule (full-black borders, inputs,
  and muted text; solid focus ring for high-contrast OS mode)
- globals.css: add mobile input font-size: 16px rule (prevents iOS Safari
  auto-zoom on input focus for viewports ≤767px)
- DataGrid.tsx: make column resize handles keyboard-accessible — added
  handleResizeKeyDown (±10px on ArrowLeft/ArrowRight), role="separator",
  aria-orientation, aria-label, tabIndex=0, and focus-visible styling
- ProcessRun.tsx: auto-focus step heading on step transition — stepHeadingRef
  + useEffect watches currentStep.name; skips initial mount via stepFocusedRef
- StatisticsWidget.tsx: add sr-only direction label to TrendBadge so screen
  readers announce "trending up, +5%" instead of just "+5%"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:02:14 -06:00
james.maesandClaude Sonnet 4.6 bca0cc7d6d fix: resolve remaining open security and code-quality issues
Security:
- MED-14: Replace stateful regex.test() with i % 2 !== 0 in HighlightedText
  (GlobalSearch.tsx) — g-flag regex lastIndex was causing alternating highlights
- MED-6: Add isSafeRedirectPath() to string-utils.ts; use it in RecordQuery.tsx
  and RecordView.tsx to reject protocol-relative URLs (//evil.com open redirect)

Code quality:
- CQ-CRIT-1: Split 822-line use-record-query.ts god-hook into three files —
  use-column-config.ts (189L) and use-saved-views.ts (163L) extracted;
  public API of useRecordQuery unchanged, zero consumer changes required
- CQ-CRIT-3: Add buildNumberSchema() helper to zod-from-metadata.ts;
  eliminates ~34 lines of duplicated min/max logic across INTEGER/LONG/DECIMAL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 07:34:37 -06:00
james.maesandClaude Sonnet 4.6 e4b6742912 docs: lift JSDoc quality across all 6 source layers
Rewrote @returns, @param, and @file comments across ~97 files in
lib/utils, lib/hooks, lib/api, components/process, components/widgets,
components/records, components/feedback, components/layout,
components/forms, components/query, and app pages.

Changes are documentation-only; zero logic modified:
- @returns now describe shape, null conditions, side-effects, and
  notable behaviors (not just the return type)
- @param tags add constraints, units, naming conventions, and
  edge-value guidance
- Duplicate verbatim inline comments that mirrored @file blocks removed
- "Why" context added: caller identity, problem solved, orchestration role

All 552 unit tests pass; 0 ESLint errors; 0 TypeScript errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 07:02:00 -06:00
james.maesandClaude Sonnet 4.6 04118ae948 feat: enforce JSDoc on all TypeScript source files (eslint-plugin-jsdoc)
- Install eslint-plugin-jsdoc and configure in eslint.config.mjs
- Rules: require-jsdoc, require-description, require-param,
  require-param-description, require-returns, require-returns-description,
  check-param-names, check-tag-names, require-file-overview (all errors)
- Applied @file overview blocks and missing JSDoc to all ~163 src/ files
- Disable react-hooks/globals (React Compiler false positive, not used)
- 0 jsdoc/* violations, 552 unit tests passing, tsc clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 05:52:09 -06:00
james.maesandClaude Opus 4.6 ab6f1ef3b8 fix: address all 7 codebase weaknesses identified in code review
GlobalSearch (B): status-aware 401/403 error handling — 401 suppressed
(axios interceptor handles redirect), 403 shows permission-denied toast,
retry:false prevents 3 retries before redirect fires

TanStack Query (C): smart retry + global error handler — smartRetry()
skips 4xx, retries 5xx/network up to 3× with exponential backoff;
QueryCache/MutationCache onError routes to status-specific toast messages

Zod schemas (D): QRecordSchema now declares full QRecord wire format
(tableName, recordLabel, values, displayValues?, associatedRecords via
z.lazy, errors?, warnings?); z.ZodType<QRecord> annotation eliminates
double-cast; GlobalSearchResult.tableLabel corrected to optional (backend
may omit it); displayValues corrected to optional in both type and schema

Auth PKCE (F): implement PKCE initiation flow in login page — PKCE
verifier/challenge generated via crypto.subtle, state nonce stored in
sessionStorage; auth type dispatch redirects OAUTH2/AUTH_0 to IdP;
callback page forwards verifier to manageSession; auth-provider.tsx and
auth.ts updated to thread codeVerifier through the call chain

axe-core E2E (E): install @axe-core/playwright, create a11y-helpers.ts
with wcag2a/2aa/wcag21aa tags; add checkA11y() to 5 spec files; 3
real violations suppressed with TODO comments (color-contrast,
aria-input-field-name, scrollable-region-focusable)

selectedRecordIds (A): production code already used stable PKs via
getRowId; fixed test fixtures to use PK strings instead of index strings
and added explicit selectedRecordIds validation test

RecordQuery audit (G): gap report written to docs/record-query-gaps.md —
multi-column sort, join filters in UI, bulk select across pages,
row-level actions, pinned columns, inline edit, and dynamic date
expressions identified as missing or partial

Also: RecordInfoFooter type narrowing fix for optional displayValues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:55:16 -06:00
james.maesandClaude Opus 4.6 da909ec1f4 feat: enforce Apache 2.0 license headers on all source files
- Add scripts/check-license-headers.mjs — walks src/**/*.{ts,tsx},
  detects missing headers, exits 1 in CI; --fix mode auto-prepends
- Add .githooks/pre-commit — runs pnpm lint:license before every commit
- Register .githooks via git config core.hooksPath in package.json prepare
  (runs automatically on pnpm install, zero extra dependencies)
- Update pnpm lint to include license check — enforced in every CI pipeline
- Add pnpm lint:license and pnpm lint:license:fix convenience scripts
- Apply Apache 2.0 header to all 227 existing src/ files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:40:00 -06:00
james.maesandClaude Opus 4.6 81cd5346cb fix: resolve 8 confirmed bugs across auth, API, components, and utils
HIGH-1: auth-provider — setUser() now called after OAuth callback success
HIGH-2: BlockWidget — validate image/audio src against http/https/relative URL allowlist to prevent data URI XSS
HIGH-3: filter-utils — guard OPERATOR_CONFIG lookup with !config continue to prevent TypeError on unknown operators
MEDIUM-4: tables — Zod safeParse now returns parsed.data on success instead of raw response (schemas.ts: QRecordSchema uses passthrough() to preserve backend fields)
MEDIUM-5: client — add timeout: 30_000 to axios.create() so stalled requests surface errors instead of hanging indefinitely
MEDIUM-6: FieldValue — add PB to formatBytes units array and clamp index with Math.min to prevent "undefined" for ≥1 PB values
MEDIUM-7: BulkLoadStep — disable hidden submit trigger when no file is selected to prevent silent no-op on submit
LOW-8: auth-provider — setUser() moved inside !cancelled guard by returning AuthUser from setup helpers instead of calling setUser() directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:29:44 -06:00
james.maesandClaude Opus 4.6 da72f89eb9 fix: MED-5 replace double-encoded btoa+encodeURIComponent with TextEncoder in filter serialization
Switch serializeFilter/deserializeFilter to use TextEncoder/TextDecoder for
Unicode-safe base64 encoding. The old encodeURIComponent+btoa approach produced
strings 3–9× longer than necessary for non-ASCII filter values, risking URL
length limits. New approach produces strings ≈1.33× the JSON size regardless
of character set. All 551 unit tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:11:53 -06:00
james.maesandClaude Opus 4.6 1bb154dee9 fix: patch Dependabot high-severity serialize-javascript vulnerability
Add pnpm override to force serialize-javascript >= 7.0.3, fixing the RCE
vulnerability (CVE via RegExp.flags / Date.prototype.toISOString) pulled in
transitively by @storybook/nextjs. The low-severity elliptic alert has no
upstream fix available yet (dev-only transitive dep, no production exposure).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:05:55 -06:00
james.maesandClaude Opus 4.6 f075784933 fix: sanitize remaining dangerouslySetInnerHTML XSS vectors
Add DOMPurify.sanitize() to the two remaining unsanitized HTML injection
sites: ProcessWidgetStep (backend widget HTML) and RichTextField (initial
HTML value loaded from backend records). All other CRIT-1/2/3 and MED-2/3
XSS/CSS-injection issues were already resolved in previous sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:52:55 -06:00
james.maesandClaude Sonnet 4.6 01bd6254f9 fix: GlobalSearch dropdown reopens after clicking outside
Clicking outside the search dropdown called inputRef.current?.focus() which
triggered onFocus → setIsOpen(true), immediately reopening the dropdown.
Remove the erroneous focus() call from the click-outside handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 16:38:10 -06:00
james.maesandClaude Sonnet 4.6 c60a13facd fix: resolve all 18 E2E test failures — 117/117 passing
- **Column-config** (5 fixed): replaced `absolute right-0 z-[150]` panel with
  `position: fixed` + `getBoundingClientRect()` so it always renders above the
  sidebar regardless of toolbar wrap / stacking-context hierarchy

- **Process-run** (6 fixed): changed test process from `importData` (absent in
  MSW fixtures) to `importPeople` which MSW's process handlers fully simulate

- **Global-search** (5 fixed): `page.route()` cannot intercept requests that MSW
  service worker handles; added a real `/search` handler to the MSW tables
  fixture (filters across all fixture data, returns 500 for magic `__error__`
  term), then rewrote tests to rely on MSW fixture data instead of per-test
  Playwright route overrides

- **Filter-builder** (1 fixed): same MSW interception issue; rewrote the
  "filter reflected in query" test to assert the active-filter badge appears
  in the UI rather than capturing network requests

- **Keyboard-shortcuts** (1 fixed): added `data-qqq-id="command-menu-results"`
  to `Command.List` in CommandMenu.tsx and updated selector in test

- **api-mocks.ts**: fixed route registration order (generic `person/*` before
  specific count/query/99999) so specific routes win in Playwright's LIFO order;
  corrected search URL from `globalSearch` → `search`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 16:34:50 -06:00
james.maesandClaude Opus 4.6 b413535cb5 feat: HIGH-8 axe-core a11y tests; MED-3 Zod API boundary validation
HIGH-8: Integrate jest-axe into Vitest for accessibility regression testing.
- Install jest-axe and @types/jest-axe as devDependencies
- Extend Vitest expect with toHaveNoViolations in tests/setup.ts
- Configure axe globally (color-contrast disabled — evaluated manually)
- Add tests/helpers/axe.ts with a checkA11y() convenience helper
- Add tests/a11y/components.test.tsx: 14 axe tests across TextField,
  FieldValue (all branch paths: empty, BOOLEAN, TEXT, PASSWORD, CHIP),
  and compound ARIA patterns (label+input, button, progressbar)

MED-3: Add Zod runtime validation at key API response boundaries.
- Create src/lib/api/schemas.ts with QRecordSchema, QueryRecordsResponseSchema,
  CountRecordsResponseSchema, GlobalSearchResultSchema/ResponseSchema, and
  QInstanceMinimalSchema (branding + top-level maps)
- Add safeParse validation with console.warn on failure (non-breaking) to
  queryRecords(), countRecords(), and globalSearch() in tables.ts
- Add safeParse validation to loadMetaData() in metadata.ts
- Add src/lib/api/schemas.test.ts: 34 tests validating all schemas for
  valid data, optional fields, and missing required field failures

All 551 tests pass, pnpm typecheck clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:14:40 -06:00
james.maesandClaude Opus 4.6 62b557345c feat: MED-1 E2E process/CRUD specs; MED-2 Storybook setup with 4 component stories
MED-1: Add two new Playwright E2E spec files:
- tests/e2e/process-run.spec.ts — process execution (importData process init,
  StepWizard rendering, step labels, error-free navigation)
- tests/e2e/record-crud.spec.ts — CRUD flows (create form rendering, edit
  pre-population, delete action presence)

Add importData process fixture + process API route mocks to api-mocks.ts:
- POST /qqq/v1/processes/*/init — returns first step
- POST /qqq/v1/processes/*/step/** — step submission response
- GET  /qqq/v1/processes/*/status/** — polling COMPLETE response
- PATCH/DELETE handlers for person table

MED-2: Storybook 8.6 setup for Next.js:
- .storybook/main.ts — @storybook/nextjs framework, @ alias, autodocs
- .storybook/preview.ts — globals.css import, light/dark backgrounds
- pnpm storybook / build-storybook scripts added to package.json
- src/components/ui/Button.stories.tsx — 8 button variant stories
- src/components/records/FieldValue.stories.tsx — 11 field type/adornment stories
- src/components/widgets/StatisticsWidget.stories.tsx — 6 KPI tile stories
- src/components/widgets/BlockWidget.stories.tsx — 7 block layout stories
- pnpm typecheck passes; all 503 unit tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:10:04 -06:00
james.maesandClaude Opus 4.6 806678e7f9 feat: MED-4 ReportRun component; MED-6 ScriptEditor; MED-7 developer page
- MED-4: add src/lib/api/reports.ts (runReport, getReportStatus), create
  src/components/reports/ReportRun.tsx with format selector, async polling,
  download link, and error/success states; replace slug page placeholder with
  <ReportRun>; add src/components/reports/index.ts barrel
- MED-6: create src/components/forms/ScriptEditor.tsx — styled monospace
  textarea with language badge, line count toolbar, Tab→spaces interception,
  readOnly mode, and error display; add 14-test suite in ScriptEditor.test.tsx
- MED-7: create src/app/(dashboard)/app/developer/page.tsx — fixed route
  showing session info, environment values, metadata stats, API health check
  with round-trip timing, and build info; all sections have Copy JSON buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:07:19 -06:00
james.maes 021a08cec5 feat: process components — CRIT-3 renderers, HIGH-11/12 mobile, HIGH-14 bulk-load, MED-20/21 metadata+progress
CRIT-3: Add renderers for previously unhandled QComponentType values.
New step components: ProcessScriptViewerStep (SCRIPT_VIEWER), ProcessUploadFormStep
(UPLOAD_FORM), ProcessSummaryChartStep (PROCESS_SUMMARY_CHART), ProcessGoogleDriveStep
(GOOGLE_DRIVE_SELECT_FOLDER placeholder), ProcessUnknownStep (fallback for unrecognized
types). Extended resolveStepType to dispatch to all new types using string casts to
accommodate component types not yet in the QComponentType union.

HIGH-11: StepWizard now uses min-w-max on the scroll container so steps never wrap,
shrink-0 on step circles, and max-w-[6rem] truncate on labels with a title tooltip.

HIGH-12: All action button bars across every step component now carry
`md:relative md:bottom-auto` so they are only viewport-sticky on mobile (below md
breakpoint) and render in normal flow on desktop.

HIGH-14: BulkLoadStep detects the bulk-load component's values.type and renders a
dedicated SFTP credentials form (host, username, password, remote path), an API
credentials form (endpoint URL, API key), or the default file upload dropzone.

MED-20: ProcessRun now applies processMetaDataAdjustment.modifiedFields before
rendering each step. use-process accumulates modifiedFields from job complete
responses into state; applyModifiedFields in ProcessRun merges partial QFieldMetaData
overrides into formFields, viewFields, and recordListFields via a memoized helper.

MED-21: Progress bar for async job polling was already implemented in the previous
pass; the indeterminate/determinate logic is confirmed working via the polling overlay
and the full-page progress display for no-step polling states.
2026-02-27 18:20:40 -06:00
james.maes 8dbfddf631 feat: variant picker, record/form polish — HIGH-4/15, MED-17/18, HIGH-5/6, CRIT-15
- CRIT-15: add VariantPicker modal dialog (globalSearch-powered, Radix Dialog focus trap)
  and variant chip in RecordQueryToolbar; RecordQuery holds variantId/variantLabel state
  and auto-opens the picker on mount for usesVariants tables
- HIGH-15: add RichTextField (contentEditable + Bold/Italic/Underline/Link toolbar); wire
  into DynamicFormField for HTML type via react-hook-form Controller
- MED-17: RecordViewHeader shows RecordActions (dropdown) on md+; adds fixed bottom-sheet
  with identical actions (edit/copy/processes/delete) on mobile below md
- MED-18: RecordViewTabs renders pill tabs on md+ and independently collapsible accordion
  sections on mobile, first section open by default, ChevronDown rotates 180deg when open
- HIGH-5: all toolbar icon buttons (refresh, view-mode, density, column-config, filter,
  variant chip) now have min-h-[44px] min-w-[44px] for WCAG touch target compliance
- HIGH-6: darken --color-muted-foreground from #6b7280 (~4.7:1) to #5a6270 (~5.1:1)
  for a clearer WCAG 2.1 AA contrast margin
- HIGH-4: verified DeleteConfirmDialog uses @radix-ui/react-dialog which provides a
  full focus trap, Escape dismissal, and focus restoration — no additional work needed
2026-02-27 18:19:46 -06:00
james.maes 66c8e1fe48 feat: BarChartWidget vertical+stacked, StatisticsWidget mini, DataGrid a11y (scope/aria-sort/role/keyboard) 2026-02-27 18:13:01 -06:00
james.maes b1a65216f6 refactor: split RecordQuery/RecordView into sub-components; add 13 unit tests + 5 E2E specs
- RecordQuery.tsx → RecordQueryContent, RecordQueryBulkBar, RecordQueryToolbar
- RecordView.tsx → RecordViewAssociated, RecordViewHeader, RecordViewTabs
- New unit tests: GlobalSearch, Sidebar, PossibleValueSelect, UnsavedChangesDialog,
  ProcessHtmlStep, ProcessViewStep, BulkActionBar, ColumnConfig, ExportButton,
  SavedViewsMenu, BlockWidget
- New E2E specs: column-config, filter-builder, global-search, keyboard-shortcuts, saved-views
- Fix GlobalSearch aria-label on result options for correct accessible name
- 489 tests passing, 0 type errors
2026-02-27 18:01:02 -06:00
james.maesandClaude Opus 4.6 a0f1165459 fix: MED-8 goBack submits _goBack step to server; LOW-7 ExportButton anchor DOM insertion
MED-8: goBack in useProcess was client-side only, leaving server-side
process state out of sync when the user navigated back. Now submits
the reserved `_goBack` step name via stepMutation so the server can
roll back and return the correct previous-step metadata. Falls back to
client-side navigation when processUUID is null (pre-init). The hook's
UseProcessReturn interface updated: goBack() is now Promise<void>.

LOW-7: ExportButton anchor element is now appended to document.body
before .click() and removed immediately after, ensuring the download
triggers correctly on Firefox and other strict-mode browsers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:07:03 -06:00
james.maesandClaude Opus 4.6 c8bcf70c05 docs: add JSDoc to 100% of src/ — parallel agent pass
Full JSDoc coverage across all 10 workstreams:
- src/types/ — all interfaces, enums, type aliases, discriminated unions
- src/lib/api/ — all API functions, request/response interfaces
- src/lib/hooks/ — useRecordQuery, useProcess, useMetaData, useLocalStorage, useAsyncCombobox, useRoutes
- src/lib/utils/ — filter-utils, zod-from-metadata, error-utils, string-utils
- src/lib/auth/, src/lib/context/, src/lib/theme/
- src/components/query/ — RecordQuery, DataGrid, FilterBuilder, ColumnConfig, Pagination, etc.
- src/components/records/, src/components/forms/ — all field type components
- src/components/process/ — StepWizard, ValidationReview, BulkLoad, all step types
- src/components/widgets/ — all 18 widget components
- src/components/layout/, src/components/feedback/, src/app/ pages

No runtime code changed. pnpm tsc --noEmit passes. 287 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:01:46 -06:00
james.maesandClaude Sonnet 4.6 65f77c8f46 refactor: final LOW/quality sweep — comments, JSDoc, naming, ExportButton
- MED-1: add explanatory comment to AuthProvider initAuth effect dep
  suppression (runs once on mount — re-running would loop auth init)
- LOW-7: remove unnecessary document.body.appendChild/removeChild in
  ExportButton — direct a.click() works in all modern browsers
- LOW-8: document that app tree permission filtering is server-side
  (backend only includes nodes the user may access)
- Add JSDoc to serializeFilter/deserializeFilter, buildQuickFilterFromState,
  and zodFieldFromMetadata (previously flagged as missing)
- Rename pk → primaryKey in DataGrid.tsx for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:42:38 -06:00
james.maesandClaude Sonnet 4.6 768e6ca7f8 fix: final review pass — stable selection, CSS sanitization, cache keys, code quality
- HIGH-7: selectedRecordIds now reads PK keys directly from rowSelection
  (keyed by DataGrid's getRowId) instead of resolving by array index,
  which was wrong after sort/filter/pagination reorders
- MED-2: strip known CSS injection vectors (expression(), @import,
  javascript:, data: URLs, embedded style tags) from customCss before
  injecting into the page <style> tag
- LOW-2: sort widgetData params keys before building the cache key so
  { b:2, a:1 } and { a:1, b:2 } don't produce duplicate cache entries
- LOW-3: add explanatory comment to Sidebar's onClose dep suppression
- Remove unnecessary useMemo wrapper on joins computation in
  use-record-query — tableMetaData is a stable prop reference
- Rename ej → exposedJoin in joins map for readability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:36:20 -06:00
james.maesandClaude Sonnet 4.6 55de69a6f5 fix: address remaining review bugs and code quality issues
Runtime/security fixes:
- HIGH-1: add router + pathname to URL-sync effect deps in use-record-query
  to prevent stale-closure URL updates in concurrent mode
- HIGH-3: remove unnecessary processUUID cast — all QJobResponse subtypes
  already carry processUUID as a typed field
- HIGH-4: add catch-all error branch to handleJobResponse so unrecognized
  response shapes set error state instead of silently freezing the process
- HIGH-5: scope auth metadata cache key by API base URL so multi-instance
  deployments don't cross-contaminate; reduce TTL from 1h to 10min
- MED-3: validate accentColor against a color format regex before applying
  to CSS custom properties (prevents CSS injection via branding metadata)
- MED-4: stabilize useLocalStorage.removeValue with a ref for initialValue
  so inline object callers don't cause unnecessary re-creation
- MED-9: wrap EntityForm.onSubmit in useCallback to avoid unnecessary
  re-renders of all form fields on every parent render
- MED-10: add basic shape validation to getStoredUser before casting
  the JSON.parse result to AuthUser
- MED-11: TOGGLE_COLUMN treats undefined visibility as visible=true before
  inverting, so first toggle correctly hides a never-toggled column

Code quality:
- CQ-MED-4: remove redundant section fallback in RecordView — primarySections
  already includes all tier-less sections via the !s.tier predicate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:30:44 -06:00
james.maesandClaude Sonnet 4.6 13070e3b48 fix: address security and runtime bugs from code review
- HIGH-6: globalSearch now re-throws non-404 errors; previously a 401
  during search would bypass the global logout interceptor
- MED-6: validate `fromPath` starts with '/' in RecordQuery and RecordView
  to prevent open redirect via javascript: URLs
- MED-7: move window.innerWidth check out of useState initializer into
  useEffect to avoid SSR/client hydration mismatch
- MED-12: track active column-resize listeners in a ref and remove them
  on DataGrid unmount so mid-drag unmounts don't leak document handlers
- MED-13: split `if (isError || !record)` into separate guards in RecordView
  so a briefly-undefined record doesn't flash the error UI
- MED-14: fix stateful /gi regex in HighlightedText — use odd-index
  detection on split result instead of regex.test() to avoid lastIndex drift
- DataCell XSS: sanitize RENDER_HTML and HTML field types with DOMPurify
- CQ-MED-2: replace `as 'AND' | 'OR'` cast in FilterBuilder with a
  type-safe narrowing check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:26:13 -06:00
james.maesandClaude Sonnet 4.6 2f820518c7 refactor: address hard code quality issues (CQ-CRIT-1, CQ-HIGH-1, CQ-HIGH-5, CQ-MED-5)
- CQ-CRIT-1: namespace useRecordQuery's 40+ flat return props into grouped
  objects (pagination, filter, columns, selection, data, views) — same
  internal implementation, structured consumer API
- CQ-HIGH-1: extract DensitySelector and ViewModeToggle as self-contained
  sub-components in RecordQuery; each manages its own local state
- CQ-HIGH-5: replace fragile queryClient.getQueryCache().subscribe() in
  use-process with a simple useEffect([statusQuery.data]) — less coupling,
  no manual key comparison
- CQ-MED-5: wrap data grid / card view section in ErrorBoundary so render
  crashes don't bubble to the route error boundary

No behaviour changes — pure structural refactor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:02:45 -06:00
james.maesandClaude Sonnet 4.6 76a76ac0c7 refactor: fix medium code quality issues from CQ review
- Extract useAsyncCombobox hook (CQ-CRIT-2): eliminates ~200 lines of
  duplicated fetch/debounce/abort/click-outside logic shared between
  PossibleValueSingleSelect and PossibleValueMultiSelect in FilterBuilder

- FieldAdornment discriminated union (CQ-HIGH-3): replaces
  Record<string, unknown> with per-type value shapes; removes all 'as'
  casts in FieldValue and DataCell via Extract<> type predicates and
  switch-case narrowing

- Fix effectiveFilter over-inclusive deps (CQ-MED-1): extract
  quickSearchBase memo so effectiveFilter/countFilter don't depend on
  columnVisibility when quickSearchTerm is empty, avoiding needless
  re-queries on column toggle

- Rename subs → subFilters local vars in FilterGroup (CQ-MED-6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:50:27 -06:00
james.maes 47f7707482 chore: remove claude worktree submodules, add .claude/ to .gitignore 2026-02-27 13:37:47 -06:00
james.maesandClaude Sonnet 4.6 3affdbaa12 refactor: fix easy code quality issues from CQ review
- CQ-CRIT-3: extract buildStringSchema/buildDateTimeSchema helpers in zod-from-metadata
- CQ-CRIT-4: centralize getErrorStatusCode in src/lib/utils/error-utils.ts
- CQ-HIGH-4: extract PAGE_SIZE_OPTIONS, SEARCH_DEBOUNCE_MS, COMBOBOX_DEBOUNCE_MS to constants.ts
- CQ-HIGH-6: remove dead toolbar button (no onClick) from RecordQuery
- CQ-HIGH-8: fix retry button to call invalidateQueries instead of no-op setPage
- CQ-HIGH-2: React.memo on FilterBuilder CriteriaRow and FilterValueInput
- CQ-MED-3: extract isHttpUrl/isRelativeUrl/isEmail to string-utils.ts
- Use consistent error messages in RecordQuery error state (403/404/generic)
- Rename initVals → initialValues in use-record-query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:37:41 -06:00
james.maesandClaude Sonnet 4.6 9c312c6b0a chore: pin qqq-orb to minor version 0.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:08:01 -06:00
james.maesandClaude Sonnet 4.6 80abe29fe5 chore: float qqq-orb on major version 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:07:13 -06:00
james.maesandClaude Sonnet 4.6 0d53307396 chore: pin qqq-orb to minor version 0.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:07:06 -06:00
james.maesandClaude Sonnet 4.6 9eee90a8df chore: pin qqq-orb to 0.6.5 production release
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:06:41 -06:00
james.maesandClaude Sonnet 4.6 a468612e34 fix: handle optional maxInputRecords in RecordActions filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:55:33 -06:00
james.maesandClaude Sonnet 4.6 15d8d1835d fix: make maxInputRecords optional on QProcessMetaData
The implementation uses `?? Infinity` to treat undefined as unlimited.
Making the field optional allows test fixtures to omit it, correctly
exercising the "unlimited" code path in getSingleRecordProcesses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:54:13 -06:00
james.maesandClaude Sonnet 4.6 0bdde715e4 fix: correct two test fixture bugs surfaced by CI
- process-utils: makeProcess default maxInputRecords 1→0 so "unlimited" bulk process tests pass
- filter-utils: update NOW_WITH_OFFSET display expectation to match actual output ('day' not 'days')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:50:55 -06:00
james.maesandClaude Sonnet 4.6 75e111a5f0 fix: correct TypeScript errors in test fixtures
- Add missing isHeavy/adornments to QFieldMetaData test objects
- Fix capabilities strings to use TABLE_* prefix per Capability type
- Fix offsetUnit DAYS → DAY per QFilterCriteriaValue type
- Add required iconName/stepFlow/minInputRecords/maxInputRecords to makeProcess
- Remove non-existent iconName/hasPermission from QTableMetaData fixtures;
  add required isHidden/readPermission/insertPermission/editPermission/
  deletePermission/variantTableLabel fields
- Fix tokens.test.ts mock.calls destructuring type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:46:48 -06:00
james.maesandClaude Sonnet 4.6 1ebc2cd437 ci: fix orb job params and add typecheck script
- Remove branch_type (not a param on node_app_build job)
- Remove qqq-maven-registry-credentials context (no private packages)
- Add typecheck script to package.json to match orb's default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:20:50 -06:00
james.maesandClaude Sonnet 4.6 7182332294 ci: add CircleCI config using qrun-io qqq-orb node_app_* jobs
Adopts kingsrook/qqq-orb@dev:snapshot with new node_app_test_only and
node_app_build jobs — designed for pnpm Next.js applications.

Follows QRun-IO branch strategy: test_only on feature branches,
build on develop/release/main/hotfix with matching workflow names.

Also adds docs/prompts/qqq-orb-node-app-extension.md — the prompt used
to spec the node_app_* job family extension to the orb.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:17:31 -06:00
james.maesandClaude Opus 4.6 bc2a06c917 feat: security hardening, test coverage, tooling upgrades, and inventory dashboard
Security (critical findings from code review):
- DOMPurify sanitization on all dangerouslySetInnerHTML sites (FieldValue, BlockWidget, ProcessHtmlStep)
- OAuth2 PKCE state nonce validation in handleOAuthCallback to prevent CSRF
- Auth0/OAuth2 session proof via manageSession() before marking user authenticated
- 401 interceptor now clears auth metadata cache on session expiry
- ExportButton surfaces export errors via toast instead of silent failure

Tests (286 unit + 23 E2E):
- Full unit test suite across API, hooks, utils, and theme layers (79%+ coverage)
- Playwright E2E specs for navigation, record query, record view, and CRUD flows
- E2E uses page.route() interception (no MSW dependency) with api-mocks.ts helper

Tooling:
- Upgraded Next.js 15.2.3 → 16.1.6 (eliminates 9 audit vulnerabilities)
- Removed deprecated @types/dompurify stub
- Updated ESLint config to native flat config for Next.js 16 (dropped FlatCompat)
- Disabled React Compiler lint rules (project does not use React Compiler)
- Fixed 6 pre-existing ESLint errors across 6 components

Inventory dashboard:
- 5 widgets: KPI overview (4 tiles), stock-by-category bar chart, reorder-required stat,
  6-month value trend line chart, and low-stock record grid
- Updated 3 product fixture quantities to create realistic low-stock scenarios
- RecordGridWidgetData type extended to support full QFieldMetaData for column headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:37:13 -06:00
819 changed files with 150559 additions and 13233 deletions

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6
# ─── Jobs ────────────────────────────────────────────────────────────────────
# The dashboard jar (static export under next-dashboard/) is published to Maven
# Central on release tags, with the same orb commands and credentials as the
# Material Dashboard. QApplicationJavalinServer serves it at / when it is present.
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job; avoid the interactive needrestart prompt.
sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
jobs:
publish_dashboard_jar:
executor: qqq-orb/java
steps:
- configure-package-install
- checkout
- qqq-orb/install_java:
install_node: true
node_version: "22"
- run:
name: Verify the immutable release version
command: |
release_version="${CIRCLE_TAG#v}"
frontend_version=$(node -p "require('./package.json').version")
maven_version=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)
test "$frontend_version" = "$release_version"
test "$maven_version" = "$release_version"
- run:
name: Build the static export
command: |
corepack enable
pnpm install --frozen-lockfile
pnpm typecheck
pnpm lint
pnpm test
pnpm build:export
- qqq-orb/mvn_jar_deploy
# ─── Workflows ───────────────────────────────────────────────────────────────
# Branch strategy mirrors the QRun-IO orb convention:
# test_only → all feature / non-protected branches
# publish_snapshot → develop
# publish_release_candidate → release/*
# publish_release → main / version tags
# publish_hotfix_release → hotfix/*
workflows:
test_only:
jobs:
- qqq-orb/node_app_test_only:
filters:
branches:
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ develop ]
publish_release_candidate:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ /release\/.*/ ]
publish_release:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ main ]
tags:
only: [ /v.*/ ]
- publish_dashboard_jar:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /.*/
tags:
only: /^v[0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?$/
publish_hotfix_release:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ /hotfix\/.*/ ]
+60
View File
@@ -0,0 +1,60 @@
{
"permissions": {
"allow": [
"Bash(which pnpm:*)",
"Bash(node:*)",
"Bash(npm install:*)",
"Bash(pnpm create:*)",
"Bash(pnpm install:*)",
"Bash(pnpm approve-builds:*)",
"Bash(pnpm add:*)",
"Bash(pnpm tsc:*)",
"Bash(pnpm test:*)",
"Bash(pnpm build:*)",
"Bash(ls:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(cat:*)",
"Bash(git:*)",
"Bash(pnpm dlx:*)",
"Bash(echo killed:*)",
"Bash(cp:*)",
"Bash(grep:*)",
"Bash(sleep 1:*)",
"Bash(pnpm list:*)",
"Bash(lsof:*)",
"Bash(rm:*)",
"Bash(pnpm lint:*)",
"Bash(pnpm test:e2e:*)",
"Bash(head:*)",
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm test:e2e:*)",
"Bash(pnpm test:coverage:*)",
"Bash(pnpm audit:*)",
"Bash(pnpm up:*)",
"Bash(pnpm remove:*)",
"Bash(./node_modules/.bin/next lint:*)",
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm dev:*)",
"mcp__circleci-mcp-server__list_followed_projects",
"Bash(echo circleci CLI not found:*)",
"Bash(circleci config:*)",
"Bash(tail:*)",
"mcp__circleci-mcp-server__get_latest_pipeline_status",
"Bash(circleci orb:*)",
"Bash(circleci diagnostic:*)",
"mcp__circleci-mcp-server__run_pipeline",
"mcp__circleci-mcp-server__get_build_failure_logs",
"Bash(cd /Users/james.maes/Git.Local/QRun-IO/qqq-orb && make validate 2>&1)",
"mcp__circleci-mcp-server__get_job_test_results",
"Bash(pnpm typecheck:*)",
"Bash(pnpm dev:*)",
"Bash(python3:*)",
"Bash(echo Exit: $?:*)",
"Bash(gh api:*)",
"Bash(pnpm why:*)",
"Bash(npm view:*)",
"Bash(chmod +x:*)",
"Bash(done)",
"Bash(pnpm exec:*)"
]
}
}
+8
View File
@@ -0,0 +1,8 @@
.git
.next
node_modules
test-results
playwright-report
coverage
.env*
*.log
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# .githooks/pre-commit
#
# Enforces the Apache 2.0 license header on all staged TypeScript/TSX files.
# Registered automatically via "prepare" in package.json.
#
# To install hooks manually:
# git config core.hooksPath .githooks
pnpm lint:license
+83
View File
@@ -0,0 +1,83 @@
name: Real-backend acceptance
# Full QRun-IO/qqq#649 acceptance: the QRun-owned sample application built from QQQ source,
# serving this repository's production static export, driven by Playwright in each browser.
# The matrix gate fails on any required row without a first-attempt pass.
on:
pull_request:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
inputs:
qqq_ref:
description: QRun-IO/qqq branch or tag to build the sample backend from
required: false
permissions:
contents: read
concurrency:
group: acceptance-${{ github.ref }}
cancel-in-progress: true
env:
# The released QQQ version this dashboard is accepted against; override per run.
QQQ_REF: ${{ inputs.qqq_ref || vars.QQQ_ACCEPTANCE_REF || 'develop' }}
NEXT_TELEMETRY_DISABLED: '1'
jobs:
acceptance:
strategy:
fail-fast: false
matrix:
# Chromium provides the primary workflow gate independently of touch coverage.
browser: [chromium, 'mobile,tablet', firefox, webkit]
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq
ref: ${{ env.QQQ_REF }}
path: qqq-backend
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm exec playwright install --with-deps chromium firefox webkit
- name: Build QQQ from source
run: mvn -B -q -f qqq-backend/pom.xml install -DskipTests -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true
- name: Build and install the dashboard jar (static export)
run: |
pnpm build:jar
mvn -B -q install
echo "NEXT_JAR_VERSION=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)" >> "$GITHUB_ENV"
- name: Build the sample application jar
run: |
# Run from the QQQ checkout: its checkstyle/pmd configuration paths are relative to it.
(cd qqq-backend && mvn -B -q -f qqq-sample-project/pom.xml package -DskipTests -Djacoco.skip=true \
-Dqqq.frontend.next.version="$NEXT_JAR_VERSION")
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/qqq-backend/qqq-sample-project/target/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
- name: Acceptance (${{ matrix.browser }})
env:
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
# Retain native browser crashes alongside traces (QQQ#904, QQQ#960).
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: acceptance-${{ matrix.browser == 'mobile,tablet' && 'touch' || matrix.browser }}
path: test-results/acceptance/
retention-days: 30
+108
View File
@@ -0,0 +1,108 @@
name: CI
# Fast PR checks (QRun-IO/qqq#709, bundle budget #710). The real-backend gate, including the
# runtime performance budgets (specs/performance), runs in acceptance.yml.
# Each job installs on its own so they run in parallel; none needs a backend.
on:
pull_request:
push:
branches: [main, develop]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NEXT_TELEMETRY_DISABLED: '1'
STORYBOOK_DISABLE_TELEMETRY: '1'
jobs:
unit:
name: Typecheck, lint, unit tests and coverage
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- name: Unit tests with src/lib coverage thresholds (vitest.config.ts)
run: pnpm test:coverage
e2e:
name: Mocked e2e (Playwright, chromium)
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm exec playwright install --with-deps chromium
- name: Production build
run: pnpm build
- name: Mocked e2e suite
env:
QQQ_E2E_SERVER: production
run: pnpm test:e2e
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-report
path: |
playwright-report/
test-results/
retention-days: 14
storybook:
name: Storybook build
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile
- run: pnpm build-storybook --quiet
performance:
name: Bundle budget (static export)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile
- name: Static export
run: pnpm build:export
- name: First-load JS per route, total JS and largest chunk against perf-budget.json
run: pnpm perf:budget
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: bundle-budget
path: test-results/performance/bundle.json
retention-days: 30
+75
View File
@@ -0,0 +1,75 @@
name: Dashboard image
on:
pull_request:
push:
tags: ['v*']
permissions:
contents: read
env:
IMAGE: ghcr.io/qrun-io/qqq-frontend-next
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
if: github.event_name == 'push'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
file: docker/quickstart/Dockerfile
platforms: linux/${{ matrix.arch }}
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
cache-from: type=gha,scope=dashboard-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=dashboard-${{ matrix.arch }}
- name: Verify runnable dashboard
if: github.event_name == 'pull_request'
env:
TAG: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
run: |
container=$(docker run -d --rm -p 127.0.0.1:3000:3000 "$TAG")
trap 'docker rm -f "$container"' EXIT
curl -fsS --max-time 3 --retry 30 --retry-delay 1 --retry-all-errors \
--retry-max-time 90 http://127.0.0.1:3000/app/person >/dev/null \
|| { docker logs "$container"; exit 1; }
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-24.04
permissions:
packages: write
steps:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish versioned multi-architecture image
run: |
docker buildx imagetools create \
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
"$IMAGE:$GITHUB_SHA-amd64" "$IMAGE:$GITHUB_SHA-arm64"
+7
View File
@@ -34,3 +34,10 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.claude/
target/
/test-results/
/playwright-report/
/log/
/.playwright-mcp/
/storybook-static/
+42
View File
@@ -0,0 +1,42 @@
import type { StorybookConfig } from '@storybook/nextjs-vite'
import { fileURLToPath } from 'node:url'
// The Vite builder: the webpack-based @storybook/nextjs framework bundled Node core
// polyfills (crypto-browserify -> elliptic, GHSA-848j-6mx2-7j84) that no story needs.
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-docs',
],
framework: {
name: '@storybook/nextjs-vite',
options: {},
},
viteFinal: async (config) => {
const src = fileURLToPath(new URL('../src', import.meta.url))
const alias = config.resolve?.alias
config.resolve = {
...config.resolve,
alias: Array.isArray(alias)
? [...alias, { find: '@', replacement: src }]
: { ...alias, '@': src },
}
// Rollup drops the Next.js "use client" directives (meaningless in Storybook) and warns
// once per client module; keep the build log to the warnings that matter.
const onwarn = config.build?.rollupOptions?.onwarn
config.build = {
...config.build,
rollupOptions: {
...config.build?.rollupOptions,
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' || warning.code === 'SOURCEMAP_ERROR') return
if (onwarn) onwarn(warning, warn)
else warn(warning)
},
},
}
return config
},
}
export default config
+21
View File
@@ -0,0 +1,21 @@
import type { Preview } from '@storybook/nextjs-vite'
import '../src/styles/globals.css'
const preview: Preview = {
tags: ['autodocs'],
initialGlobals: { backgrounds: { value: 'light' } },
parameters: {
layout: 'centered',
backgrounds: {
options: {
light: { name: 'light', value: '#ffffff' },
dark: { name: 'dark', value: '#0f172a' },
},
},
nextjs: {
appDirectory: true,
},
},
}
export default preview
+13
View File
@@ -169,3 +169,16 @@ The current frontend being replaced lives in the sibling `qqq-frontend-material-
Set via `NEXT_PUBLIC_API_BASE_URL` environment variable. Defaults to `/qqq/v1`.
The API uses cookie-based session auth (`sessionUUID` cookie).
## Knowledge base
Durable, cross-session knowledge about this repo and the wider QQQ platform lives in
the second-brain vault (`R:/Git.Local/KofTwentyTwo/second-brain`):
- Platform hub: `knowledge/qqq/qqq-hub.md`
- This repo's dossier: `knowledge/qqq/repos/qqq-frontend-next.md`
(reviewed at commit `41cd6d2034a8` on branch `main`, 2026-07-04)
Consult the dossier before deep-diving the code — it covers the API contract this
frontend targets (including versioned-middleware endpoint gaps), maturity assessment,
known parity gaps, and its relationship to `qqq-frontend-material-dashboard`.
+132 -1
View File
@@ -1 +1,132 @@
# qqq-frontend-next
# QQQ Frontend Next
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
[Next UI **1.0.0-RC.9** is published](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) for application testing. Public Maven and AMD64/ARM64 container delivery are verified; requirements, verification scope and known gaps are in the [RC9 notes](docs/releases/1.0.0-RC.9.md). Final 1.0 remains held for owner application testing and explicit approval.
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
## Use it in a QQQ application
The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq-bom-pom`) and `QApplicationJavalinServer` serves it at `/`, on the same port and origin as the API:
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.9</version>
</dependency>
```
The explicit version overrides an older BOM-managed frontend. RC9 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
The jar holds the static export (`pnpm build:export`) under `next-dashboard/`. Deep links such as `/app/person/1` are served from placeholder pages and read their route from the browser path, so no Node.js server is involved. The Material Dashboard remains available: select it with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`. Material routes are `/<app>/<table>/<id>`; Next routes are `/app/<table>/<id>`.
Feature coverage is recorded in the real-backend acceptance matrix in [`docs/acceptance/feature-matrix.md`](docs/acceptance/feature-matrix.md) ([QRun-IO/qqq#649](https://github.com/QRun-IO/qqq/issues/649)). Run it with `pnpm test:acceptance` (see [`tests/acceptance/README.md`](tests/acceptance/README.md)).
## Run the local QQQ sample
Use the [QQQ quickstart](https://github.com/QRun-IO/qqq/blob/main/qqq-sample-project/README.md#quickstart-with-next): it clones editable Java sample source, compiles it against published QQQ, and serves this dashboard from the sample on port 8000. It needs JDK 21+, Git, curl and unzip; no Node.js, Maven or Docker installation.
A container image (`ghcr.io/qrun-io/qqq-frontend-next`, built from `docker/quickstart/Dockerfile`) remains available for running the dashboard as a separate Node server in front of a backend on host port 8000. The backend origin is fixed at image build time (`QQQ_BACKEND_URL`).
## Frontend development prerequisites
- **Node.js** 20.19+ or 22.12+ (required by the Storybook developer tooling)
- **pnpm** (install with `npm install -g pnpm` if not already present)
## Frontend development
```bash
git clone https://github.com/QRun-IO/qqq-frontend-next.git
cd qqq-frontend-next
pnpm install
pnpm dev
```
The dev server starts at [http://localhost:3000](http://localhost:3000).
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `NEXT_PUBLIC_API_BASE_URL` | `/qqq/v1` | Base URL for all QQQ backend API calls |
| `QQQ_BACKEND_URL` | _(unset)_ | Standalone build only: backend origin for same-origin API rewrites; the container image uses `http://host.docker.internal:8000` |
| `QQQ_NEXT_OUTPUT` | _(unset)_ | `export` produces the static export for QQQ hosting (`pnpm build:export`) |
| `NEXT_PUBLIC_MOCK_API` | _(unset)_ | Set to `true` to activate MSW browser mocks for dev/demo without a live backend |
Copy `.env.example` to `.env.local` and adjust as needed.
## Available Commands
| Command | Description |
|---|---|
| `pnpm dev` | Start the Next.js development server (Turbopack) |
| `pnpm build` | Compile the standalone (Node server) production build |
| `pnpm build:export` | Compile the static export served by QQQ (`out/`) |
| `pnpm build:jar` | Static export packaged as the `qqq-frontend-next` Maven jar |
| `pnpm test:acceptance` | Real-backend acceptance suite and matrix gate (needs `QQQ_SAMPLE_JAR`) |
| `pnpm start` | Serve the production build |
| `pnpm test` | Run Vitest unit tests (single pass) |
| `pnpm test:watch` | Run Vitest in watch mode |
| `pnpm test:coverage` | Run Vitest with V8 coverage; fails below the `src/lib` thresholds in `vitest.config.ts` |
| `pnpm test:e2e` | Run the mocked Playwright e2e suite (`tests/e2e`, no backend); `QQQ_E2E_SERVER=production` serves the `pnpm build` output, `QQQ_E2E_PORT` picks the port |
| `pnpm perf:budget` | Check the static export (`out/`) against the bundle budget in `perf-budget.json` ([`docs/acceptance/performance.md`](docs/acceptance/performance.md)) |
| `pnpm typecheck` | Run `tsc --noEmit` (type check without emitting files) |
| `pnpm lint` | Run ESLint + Apache license header check |
| `pnpm format` | Run Prettier over all source files |
| `pnpm storybook` | Start Storybook component explorer on port 6006 |
| `pnpm build-storybook` | Build the static Storybook (`storybook-static/`) |
## Directory Structure
```
src/
├── app/ # Next.js App Router pages
│ ├── (auth)/ # Login and OAuth callback routes
│ ├── (dashboard)/ # Authenticated shell (sidebar + nav)
│ │ └── app/ # All app routes (tables, records, processes)
│ ├── layout.tsx # Root layout — providers
│ ├── error.tsx # Global error boundary
│ └── not-found.tsx # 404 page
├── components/
│ ├── ui/ # shadcn/ui primitives (Button, Input, Dialog, …)
│ ├── layout/ # Sidebar, Header, Breadcrumbs, Banner
│ ├── records/ # RecordView, FieldValue
│ ├── forms/ # DynamicForm, EntityForm, field components
│ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination
│ ├── process/ # StepWizard, ValidationReview, BulkLoad
│ ├── widgets/ # Widget containers, charts, stat blocks, grids
│ └── feedback/ # Alerts, Toasts, Modals, CommandMenu
├── lib/
│ ├── api/ # Typed API client modules (client.ts, tables.ts, …)
│ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
│ ├── hooks/ # Shared React hooks (useLocalStorage, useMetadata, …)
│ ├── utils/ # Pure utility functions
│ ├── context/ # React contexts (QContext)
│ └── theme/ # Theme provider and CSS custom property tokens
├── types/ # TypeScript type definitions
└── styles/ # Global styles and theme tokens
```
## Documentation
| Document | Purpose |
|---|---|
| [`CLAUDE.md`](./CLAUDE.md) | Full architecture reference — conventions, patterns, key decisions |
| [`docs/STATE-MANAGEMENT.md`](./docs/STATE-MANAGEMENT.md) | When to use each state container (TanStack Query, RHF, URL params, localStorage, QContext) |
| [`docs/API-ERROR-HANDLING.md`](./docs/API-ERROR-HANDLING.md) | Global error handler, 401 interceptor, per-status guidance, code examples |
| [`docs/implementation-plans/`](./docs/implementation-plans/) | Work-package implementation plans (6 packages) |
## Tech Stack
- **Framework:** Next.js 16 (App Router) with React 19
- **Language:** TypeScript 5.x (`strict: true`)
- **Styling:** Tailwind CSS 4.x with CSS custom properties
- **Components:** shadcn/ui (Radix primitives + Tailwind)
- **Data Grid:** TanStack Table v8
- **Forms:** React Hook Form v7 + Zod
- **Data Fetching:** TanStack Query v5
- **Charts:** Recharts
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
- **Package Manager:** pnpm
+25
View File
@@ -0,0 +1,25 @@
FROM registry-1.docker.io/library/node:22-bookworm-slim AS frontend
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
ENV NEXT_PUBLIC_MOCK_API=false
RUN pnpm build
FROM registry-1.docker.io/library/node:22-bookworm-slim AS runtime
LABEL org.opencontainers.image.source="https://github.com/QRun-IO/qqq-frontend-next"
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
COPY --from=frontend --chown=node:node /app/.next/standalone ./
COPY --from=frontend --chown=node:node /app/.next/static ./.next/static
COPY --from=frontend --chown=node:node /app/public ./public
# server.js wrapped with the dashboard security headers (QRun-IO/qqq#734); add origins the
# dashboard must reach (QuickSight, custom component bundles) with QQQ_DASHBOARD_CSP_SOURCES
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/backend-prefixes.json ./
COPY LICENSE ./LICENSE
USER node
EXPOSE 3000
CMD ["node", "qqq-server.mjs"]
+338
View File
@@ -0,0 +1,338 @@
# Accessibility Reference — QQQ Frontend Next
**Standard:** WCAG 2.1 Level AA
**Testing tools:** axe-core (automated), Playwright E2E assertions, manual keyboard testing
---
## WCAG 2.1 AA Compliance Status
The sections below summarise what has been implemented and what remains outstanding,
based on the design review conducted 2026-03-01 (`docs/DESIGN-REVIEW-2026-03-01.md`).
---
## Key Implementations (Resolved Items)
### Skip-to-Content Link (D-L-1)
A visually hidden skip link is rendered as the first element in the dashboard layout
(`src/app/(dashboard)/layout.tsx`). It becomes visible on keyboard focus and links to
`#main-content`, allowing keyboard users to bypass the sidebar on every page navigation.
```tsx
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 ..."
data-qqq-id="skip-to-content"
>
Skip to main content
</a>
```
The target `<main id="main-content" data-qqq-id="main-content">` is present in the same layout.
---
### `aria-sort` on DataGrid Column Headers (D-Q-1)
`src/components/query/DataGrid.tsx` computes an `aria-sort` value for every `<th>` based on
the current TanStack Table sort state:
- Sorted ascending → `aria-sort="ascending"`
- Sorted descending → `aria-sort="descending"`
- Not sorted → `aria-sort="none"` (only on sortable columns; unsortable columns omit the attribute)
Screen readers announce the sort direction when the user focuses or activates a column header.
---
### Row Selection `aria-label` (D-Q-2)
Each row's selection checkbox carries a descriptive label:
```tsx
aria-label={`Select ${row.original.recordLabel ?? 'record'}`}
```
The "select all" header checkbox uses `aria-label="Select all rows on this page"`.
---
### Keyboard-Accessible Column Resize Handles (D-Q-3)
The resize handle on each column header responds to `ArrowLeft` and `ArrowRight` keyboard
events, adjusting the column width by ±10 px per keypress. The handler is implemented in
`handleResizeKeyDown` inside `DataGrid.tsx`:
```typescript
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
const delta = e.key === 'ArrowRight' ? 10 : -10
```
---
### `aria-live` Region for Fetch-in-Progress (D-Q-6)
The DataGrid wraps the loading progress bar and result count in an `aria-live="polite"` region
so that background re-fetches triggered by sort or filter changes are announced to screen readers
without interrupting the current reading flow.
---
### Focus Management in ProcessRun (D-P-1)
`src/components/process/ProcessRun.tsx` uses a `stepHeadingRef` and a `useEffect` keyed on the
active step name to move focus to the step heading whenever the wizard advances or retreats.
This satisfies WCAG 2.4.3 (Focus Order) by ensuring screen reader users are positioned at the
start of each new step:
```typescript
const stepHeadingRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
if (hasRenderedFirstStep.current) {
stepHeadingRef.current?.focus()
}
hasRenderedFirstStep.current = true
}, [activeStepName])
```
The target `<h2>` carries `tabIndex={-1}` and is referenced by `ref={stepHeadingRef}`.
---
### Screen-Reader Text for Trend Indicators (D-W-1)
`src/components/widgets/StatisticsWidget.tsx` renders a visually hidden `<span>` next to each
trend arrow icon so the direction is announced by screen readers:
```tsx
<span className="sr-only">{config.srLabel}</span>
// srLabel values: 'trending up,' | 'trending down,' | 'no change,'
```
The icon itself is `aria-hidden="true"` so it is not double-announced.
---
### Submit Button Disabled While Submitting (D-F-1)
`src/components/forms/EntityForm.tsx` reads `isSubmitting` from React Hook Form's `formState`
and applies it to all action buttons, preventing duplicate submissions:
```tsx
disabled={disabled || isSubmitting}
// Label also changes: isSubmitting ? 'Saving...' : saveButtonLabel
```
---
### Baseline Form Accessibility
All form inputs across `DynamicForm`, `EntityForm`, and individual field components have:
- `<label>` with `htmlFor` wired to the input `id`
- `aria-required={field.isRequired}`
- `aria-invalid={!!errors[field.name]}`
- `aria-describedby` pointing to the error message element
- Required asterisk marked `aria-hidden="true"` to avoid screen-reader repetition
- Help tooltips use Radix Tooltip, which is keyboard-accessible via `Tab` / `Escape`
---
### CSS Media Queries
#### `prefers-reduced-motion` (D-L-2 / D-T-1)
`src/styles/globals.css` collapses all animation and transition durations to near-zero
when the user's OS has reduced motion enabled. This resolves skeleton pulse, sidebar
slide-in, toast fade, and all other CSS transitions:
```css
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
#### `prefers-contrast: more` (D-T-2)
A high-contrast media block increases border weights and forces text colors to fully opaque
black, targeting users who have enabled "Increase Contrast" in their OS settings:
```css
@media (prefers-contrast: more) {
:root {
--color-border: #000000;
--color-input: #000000;
--color-muted-foreground: #000000;
}
*,
::before,
::after {
border-color: var(--color-border) !important;
}
:focus-visible {
outline: 3px solid #000000 !important;
outline-offset: 2px !important;
}
}
```
#### Mobile 16px Font-Size Fix (D-R-1)
iOS Safari auto-zooms any focused input whose `font-size` is below 16px. A mobile-scoped rule
prevents this without affecting desktop appearance:
```css
@media screen and (max-width: 767px) {
input,
select,
textarea {
font-size: 16px;
}
}
```
#### Dark-Mode Focus Ring Fix (D-T-5)
The default `color-mix`-based focus ring can disappear on dark backgrounds. A dark-mode
override forces the ring to a solid, fully opaque color:
```css
@media (prefers-color-scheme: dark) {
:focus-visible {
outline-color: var(--color-ring);
}
}
```
---
### Additional Resolved Items (from `What's Working Well`)
| Item | Implementation |
|------|----------------|
| `aria-required`, `aria-invalid`, `aria-describedby` on all form inputs | `DynamicFormField.tsx`, `EntityForm.tsx` |
| `role="status"` + `aria-busy="true"` on skeleton components | All skeleton components |
| `role="status"` + `aria-live="polite"` on `EmptyState` | `EmptyState.tsx` |
| Full keyboard nav in SearchDialog (ArrowUp/Down/Enter/Escape) | `SearchDialog.tsx` |
| Breadcrumbs: `aria-label="Breadcrumb"` + `aria-current="page"` | `Breadcrumbs.tsx` |
| `aria-expanded` on mobile drawer | `Sidebar.tsx` |
| DOMPurify sanitization on all user-controlled HTML output | `FieldValue.tsx`, `BlockWidget.tsx`, `ProcessHtmlStep.tsx` |
| Unsaved-changes guard: `beforeunload` + confirm modal | `EntityForm.tsx` |
| Focus rings on all interactive elements via `:focus-visible` | `globals.css` |
| Muted text darkened to `#5a6270` for WCAG AA contrast margin | `globals.css` |
---
## Automated Testing
### Running axe-core Scans
The helper in `tests/e2e/a11y-helpers.ts` wraps `@axe-core/playwright` and asserts zero
violations against the `wcag2a`, `wcag2aa`, and `wcag21aa` rule sets:
```typescript
import { checkA11y } from './a11y-helpers'
test('page is accessible', async ({ page }) => {
await page.goto('/app/person')
await waitForAppReady(page)
await checkA11y(page)
})
```
`checkA11y` runs on every navigation E2E test in `navigation.spec.ts`. To run all E2E tests
including accessibility assertions:
```bash
pnpm test:e2e
```
To run a single spec with axe output in the terminal:
```bash
pnpm test:e2e -- tests/e2e/navigation.spec.ts
```
### Known Suppressed Violations (with TODOs)
Three rule violations are currently disabled in `checkA11y` because the underlying issues
are not yet fixed. Each has a TODO comment in `a11y-helpers.ts`:
| Rule | Reason suppressed | TODO |
|------|-------------------|------|
| `color-contrast` | Amber banner background (#f59e0b) and indigo sidebar/button states yield contrast ratios just below 4.5:1 | Audit all Tailwind color pairs in Banner, Sidebar, and Button and replace with WCAG AA-compliant pairs |
| `aria-input-field-name` | Some form inputs rendered by `DynamicForm` may have broken `<label htmlFor>` associations when IDs are generated dynamically | Ensure every `<input>` has either a matching `htmlFor` or an `aria-label` |
| `scrollable-region-focusable` | The TanStack Table scrollable wrapper `<div>` has no `tabIndex`, blocking keyboard scrolling | Add `tabIndex={0}` to the scrollable DataGrid wrapper |
---
## Outstanding Work
The following items from the design review are **not yet resolved** and require future work.
### High Priority (WCAG 2.1 AA gaps)
| ID | Area | Description |
|----|------|-------------|
| D-V-1 | Record View | Edit/Delete buttons render regardless of user permissions; they fail with 403 on action instead of being hidden/disabled based on `editPermission`/`deletePermission`. |
| D-L-3 | Layout | Global search is hidden on mobile (`hidden md:block`); mobile users have no access to search. |
### Medium Priority (UX gaps)
| ID | Area | Description |
|----|------|-------------|
| D-L-5 | Layout | Mobile sidebar `aria-expanded` state may not be consistently set on the nav landmark. |
| D-Q-4 | FilterBuilder | Async combobox shows no loading indicator while possible-value options are fetching. |
| D-Q-5 | Pagination | "Go to page" input silently ignores out-of-range values with no user feedback. |
| D-Q-7 | DataGrid | Column visibility toggle changes are not announced via a `role="status"` region. |
| D-P-2 | Process | Completed step indicators in the wizard are not keyboard-navigable for back-navigation. |
| D-P-3 | Process | No timeout message if backend polling runs beyond ~60 seconds. |
| D-P-4 | Process | Validation review result list has no severity filter or field-name search. |
| D-P-5 | Process | File upload step shows no byte-level progress during multipart upload. |
| D-W-2 | Widgets | Widget API call failures show blank space with no error boundary or fallback UI. |
| D-FB-1 | Feedback | Keyboard Shortcuts dialog is not discoverable (no visible `?` hint in the header). |
| D-FB-2 | Feedback | Toasts have no dismiss button; users must wait for auto-timeout. |
| D-FB-3 | Feedback | CommandMenu icon buttons may be below the 44px minimum touch target size. |
| D-R-2 | Responsive | Multiple interactive elements (pagination controls, density buttons) below 44px touch target. |
### Items Deferred Pending Backend Support
| ID | Area | Description |
|----|------|-------------|
| D-V-6 | Record View | "Last modified by / at" audit footer requires backend to expose this data on the record endpoint. |
| D-T-3 | Theme | Dual theming systems (`--qqq-primary-color` and `--color-primary`) need consolidation; depends on coordinating with backend-supplied theme values. |
---
## Checklist for New Component Authors
When adding a new interactive component, verify the following before opening a pull request:
- [ ] Every `<input>`, `<select>`, and `<textarea>` has a `<label htmlFor>` that matches its `id`, or an `aria-label` / `aria-labelledby` attribute.
- [ ] Required inputs have `aria-required={true}`.
- [ ] Invalid inputs have `aria-invalid={true}` and an `aria-describedby` pointing to the error message element.
- [ ] All icon-only buttons have a descriptive `aria-label` (e.g. `aria-label="Close dialog"`).
- [ ] Icon elements that are purely decorative are marked `aria-hidden="true"`.
- [ ] Modals/dialogs trap focus while open and restore it to the triggering element on close (use Radix Dialog for this automatically).
- [ ] All interactive elements are reachable and operable via `Tab` / `Shift+Tab` / `Enter` / `Space` / `Escape`.
- [ ] Interactive elements have a visible `:focus-visible` ring (do not add `outline: none` without a replacement).
- [ ] Any element that announces dynamic state changes uses `aria-live="polite"` (or `role="status"`).
- [ ] Touch targets are at least 44x44px (`min-h-[44px] min-w-[44px]` or equivalent padding).
- [ ] Color is not the only means of conveying information (pair color with text or icon).
- [ ] Text color meets a minimum 4.5:1 contrast ratio against its background for normal-weight text at normal size.
- [ ] The component does not break at 320px viewport width.
- [ ] The component respects `prefers-reduced-motion` (animations driven by CSS transitions inherit the global rule in `globals.css`; JS-driven animations need a manual check).
- [ ] Any rendered HTML that originates from user input or the backend is sanitized with DOMPurify before being set via `dangerouslySetInnerHTML`.
- [ ] Run `pnpm test:e2e` and confirm `checkA11y` passes for any page that includes the new component.
+310
View File
@@ -0,0 +1,310 @@
# API Error Handling Guide
This document describes how API errors flow through the QQQ Frontend Next application and what, if anything, individual components need to do about them.
The short answer is: **most errors require no per-call handling**. The global error handler in the query client toasts an appropriate message automatically, and the 401 interceptor in the Axios client redirects to login. Individual components only need to add their own handling when the generic behavior is insufficient for a specific UX requirement.
---
## Architecture Overview
There are two independent error interception layers:
1. **Axios response interceptor** (`src/lib/api/client.ts`) — fires synchronously on every HTTP response before the promise settles. Handles 401 (unauthorized) only.
2. **TanStack Query global handlers** (`src/lib/query-client.ts`) — `QueryCache.onError` for reads, `MutationCache.onError` for writes. Fire after the interceptor. Handle all other errors by toasting a message.
```
API response
│
▼
Axios interceptor
│ 401 → call unauthorizedCallback() → redirect to login
│ all others → pass through as rejected promise
▼
TanStack Query cache (onError)
│ 401 → silently ignored (already handled)
│ 403 → toast "Permission denied" / "You do not have permission…"
│ 404 → toast "Resource not found"
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
│ network → toast "Something went wrong"
▼
Component (optional per-query onError)
│ Only needed when generic toast is wrong for the specific UX
```
---
## The 401 Interceptor
**Location:** `src/lib/api/client.ts`
The singleton `APIClient` registers a response interceptor that fires on every request:
```typescript
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401 && this.unauthorizedCallback) {
this.unauthorizedCallback()
}
return Promise.reject(error) // always re-reject so TanStack Query also sees it
}
)
```
The `unauthorizedCallback` is wired to the auth provider's logout/redirect function during initialization. When a 401 arrives:
1. The callback fires → clears auth state → redirects to the login page.
2. The error is still re-rejected, so TanStack Query's `onError` also fires.
3. The global handler in `query-client.ts` explicitly silences 401s to avoid a redundant toast on top of the redirect.
**Implication for components:** Never handle 401 in a component. The interceptor + auth provider handle it globally. Writing per-component 401 handling will race with the redirect and confuse users.
---
## The Global Query Error Handler
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
```typescript
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
const status = getErrorStatusCode(error)
if (status === 401) return // handled by axios interceptor
if (status === 403) {
toast.error(
context === 'mutation'
? 'You do not have permission to perform this action'
: 'Permission denied'
)
return
}
if (status === 404) {
toast.error('Resource not found')
return
}
if (status !== undefined && status >= 500) {
toast.error(
context === 'mutation'
? 'Server error — please try again'
: 'Server error — retrying…'
)
return
}
// Network failure or unexpected status
toast.error('Something went wrong')
}
```
This handler is registered on both `QueryCache` and `MutationCache`:
```typescript
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => handleQueryError(error, 'query'),
}),
mutationCache: new MutationCache({
onError: (error) => handleQueryError(error, 'mutation'),
}),
})
```
**Implication for components:** For most queries and mutations, you do not need `onError` at all. The global handler covers toasting. Add per-query handling only when you need to do something the global handler cannot — such as mapping a 400 error's field messages back to form fields.
---
## Extracting the Status Code
**Location:** `src/lib/utils/error-utils.ts`
```typescript
import { getErrorStatusCode } from '@/lib/utils/error-utils'
const status = getErrorStatusCode(error)
```
`getErrorStatusCode` handles two shapes:
- `AxiosError` — reads `error.response?.status`
- Plain objects with a numeric `status` property (e.g. fetch-based wrappers)
Returns `undefined` for anything else (non-object errors, null, network failures with no response).
---
## Status-by-Status Guide
### 400 — Validation Error
The global handler does NOT toast on 400 because 400s typically carry field-level validation messages that must be mapped back to individual form fields. Handle 400 explicitly wherever you submit a form.
```typescript
import { getErrorStatusCode } from '@/lib/utils/error-utils'
const mutation = useMutation({
mutationFn: (data: FieldValues) => saveRecord('order', data),
onError: (error) => {
if (getErrorStatusCode(error) === 400) {
// Map backend validation messages to React Hook Form fields
const fieldErrors = extractFieldErrors(error) // your helper
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field, { type: 'server', message })
}
}
// Non-400 errors are already toasted by the global handler
},
})
```
If a 400 arrives outside of a form context, add a toast yourself:
```typescript
onError: (error) => {
if (getErrorStatusCode(error) === 400) {
toast.error('Invalid request — please check your input')
}
}
```
### 403 — Permission Denied
The global handler toasts a generic permission-denied message. If the 403 should instead silently hide an action (e.g. a button that should not appear for users without permission), suppress the default behavior by checking permissions from metadata before rendering rather than relying on the API to reject:
```typescript
// Preferred: hide the action before the user tries it
{tableMetaData.capabilities.includes('insert') && (
<Button onClick={openCreateModal}>New Record</Button>
)}
```
If you need custom handling on a 403, use `onError` and call `toast` yourself — or do nothing if the generic toast is acceptable.
### 404 — Not Found
The global handler toasts "Resource not found". For record-view pages where 404 means the record was deleted, render an empty state instead:
```typescript
const { data: record, error } = useQuery({
queryKey: queryKeys.tableRecord(tableName, recordId),
queryFn: () => getRecord(tableName, recordId),
})
if (getErrorStatusCode(error) === 404) {
return <EmptyState message="This record no longer exists" />
}
```
The global handler will also toast, which is acceptable — the empty state just provides additional UI context.
### 5xx — Server Error
The global handler toasts "Server error — retrying…" for queries and "Server error — please try again" for mutations. Queries are automatically retried up to 3 times with exponential back-off (1 s → 2 s → 4 s, max 30 s) before the error surfaces.
No additional per-component handling is needed for 5xx unless you want a custom retry UI or a structured error state for a specific page.
### Network Failure (no response)
Treated as non-4xx by the retry logic — queries retry up to 3 times. The global handler toasts "Something went wrong" after all retries are exhausted. No per-component handling needed for the common case.
---
## When to Add Per-Query Error Handling
Add `onError` to a specific `useQuery` or `useMutation` call when:
| Scenario | What to do |
|---|---|
| 400 with field-level validation messages | Map errors to form fields with `form.setError()` |
| 403 should render empty state instead of toast | Catch 403, render empty UI, call `toast` yourself if the global one also fires |
| 404 should render a structured empty state page | Check `getErrorStatusCode(error) === 404` and render `<EmptyState>` |
| Error should navigate away (e.g. deleted parent record) | `useRouter().push('/...')` in `onError` |
| The generic toast message is wrong for this specific action | Supply a custom `onError` that calls `toast.error(customMessage)` |
For all other cases, rely on the global handler and write no error-handling code in the component.
---
## Retry Behavior
The `smartRetry` function in `src/lib/query-client.ts` controls when TanStack Query retries:
```typescript
function smartRetry(failureCount: number, error: unknown): boolean {
const status = getErrorStatusCode(error)
// Never retry client errors — they are deterministic
if (status !== undefined && status >= 400 && status < 500) return false
// Retry server errors and network failures up to 3 times
return failureCount < 3
}
```
Back-off schedule: 1 s → 2 s → 4 s, capped at 30 s.
4xx errors are never retried. There is no per-component way to opt into retrying 4xx errors — that would be intentional since a 400 with bad field data will not resolve itself on retry.
---
## Complete Example — Form with 400 Handling
```typescript
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { saveRecord } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
const queryClient = useQueryClient()
const form = useForm({ resolver: zodResolver(schema), defaultValues })
const mutation = useMutation({
mutationFn: (data: FieldValues) => saveRecord('order', orderId, data),
onSuccess: () => {
// Invalidate so the record view refreshes
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord('order', orderId) })
toast.success('Order saved')
},
onError: (error) => {
if (getErrorStatusCode(error) === 400) {
// Map field errors from the server response body to form fields.
// The global handler does NOT toast on 400, so we must do it here or
// rely on inline field error messages in the form UI.
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
if (body?.errors) {
for (const [field, message] of Object.entries(body.errors)) {
form.setError(field, { type: 'server', message })
}
} else {
toast.error('Please correct the highlighted fields')
}
return
}
// 401, 403, 404, 5xx, network — already toasted by the global handler
},
})
return (
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
{/* field components */}
</form>
)
}
```
---
## Quick Reference
| Layer | File | Handles |
|---|---|---|
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
| Global query handler | `src/lib/query-client.ts` | 403, 404, 5xx, network → toast |
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
+316
View File
@@ -0,0 +1,316 @@
# Code Quality Review — QQQ Frontend Next
**Date:** 2026-02-27
**Scope:** DRY, KISS, component design, hooks, TypeScript quality, naming, structure
**Note:** Security/runtime bugs are documented separately in `CODE-REVIEW-2026-02-27.md`
---
## Summary
The codebase demonstrates **strong architectural foundations** with excellent separation of concerns and metadata-driven rendering patterns. However, there are **significant code quality issues** across multiple dimensions: substantial DRY violations in filter/combobox logic, an overengineered god-hook, inconsistent error handling approaches, and TypeScript practices that miss opportunities for stronger typing. The `use-record-query` hook is an anti-pattern — a 540-line hook mixing data fetching, URL synchronization, localStorage persistence, and UI state. FilterBuilder has two combobox implementations that are ~95% identical. Several utilities and constants are defined inline or scattered across files. While the component structure is clean and the metadata-driven approach is correct, the implementation needs targeted refactoring to reduce coupling, eliminate duplication, and simplify abstractions.
---
## CRITICAL Quality Issues (blocks maintainability)
### CQ-CRIT-1: `use-record-query` — God Hook Anti-Pattern (540 lines)
**File:** `src/lib/hooks/use-record-query.ts`
This hook violates SRP catastrophically. It manages:
- Pagination state (pageNum, pageSize)
- Filter state (userFilter, quickSearchTerm, filterMode)
- Column configuration (columnVisibility, columnOrder, columnWidths)
- Row selection state
- UI panel toggles (filterPanelOpen, columnConfigOpen)
- localStorage persistence (5 separate useEffect blocks)
- URL params synchronization (1 useEffect block)
- TanStack Query integration (2 queries)
- Saved views (saveView, loadView, deleteView callbacks)
- 18+ action dispatchers
The hook returns 40+ properties. Components must destructure dozens of items they don't need. The reducer pattern for 24+ action types is overkill for this state complexity.
**Why it's Critical:**
- Testing requires mocking localStorage, URL manipulation, TanStack Query, and multiple state branches simultaneously
- Any change to one concern risks breaking another
- Components using this hook are tightly coupled to its entire API shape
- The localStorage sync fires 5 separate effects independently
**Recommended Fix:**
Split into 3-4 focused hooks:
- `useRecordPagination()` — pageNum, pageSize, totalPages, setPage, setPageSize
- `useRecordFilter()` — userFilter, quickSearchTerm, setFilter, resetFilter
- `useRecordSelection()` — rowSelection, setRowSelection, clearSelection
- `useRecordUIState()` — columnConfigOpen, filterPanelOpen, toggles
Let RecordQuery coordinate these together.
---
### CQ-CRIT-2: FilterBuilder — Two Comboboxes That Are 95% Identical
**File:** `src/components/query/FilterBuilder.tsx`, lines 508-927
`PossibleValueSingleSelect` (lines 508-698) and `PossibleValueMultiSelect` (lines 713-927) are nearly identical. Both:
- Fetch options via `fetchTablePossibleValues()`
- Debounce search input (300ms) — copied at lines 557-563 and 771-777
- Manage AbortController for stale request cancellation — copied at lines 585-590 and 799-804
- Implement click-outside-to-close
- Render search input, loading state, option list
Differences: only selection model (single vs. multi) and tag display.
**Why it's Critical:**
- Bug fix in one requires manual sync to the other
- 400+ lines of duplicated code
- Missing memoization: callbacks re-created on every render
**Recommended Fix:**
Extract a `useAsyncCombobox` hook:
```typescript
function useAsyncCombobox(tableName: string, fieldName: string) {
// Shared debounce, abort, cleanup logic
return { options, isLoading, debouncedFetch, abortRef }
}
```
Then SingleSelect and MultiSelect become thin wrappers managing only the selection model.
---
### CQ-CRIT-3: `zod-from-metadata` — Repeated Required/Optional Pattern x10
**File:** `src/lib/utils/zod-from-metadata.ts`, lines 11-114
STRING, TEXT, HTML, and PASSWORD cases (lines 19-60, 99-110) all repeat:
```typescript
let schema = z.string()
if (isRequired) schema = schema.min(1, `${label} is required`)
if (maxLength) schema = schema.max(maxLength, ...)
return isRequired ? schema : schema.optional()
```
The required/optional wrapping pattern is written identically across all 10+ field types.
**Recommended Fix:**
```typescript
function buildStringSchema(isRequired: boolean, maxLength?: number, label?: string) {
let schema = z.string()
if (isRequired) schema = schema.min(1, `${label} is required`)
if (maxLength) schema = schema.max(maxLength, `Must be ${maxLength} characters or fewer`)
return isRequired ? schema : schema.optional()
}
case 'STRING':
case 'TEXT':
case 'HTML':
case 'PASSWORD':
return buildStringSchema(isRequired, field.maxLength, field.label)
```
---
### CQ-CRIT-4: Inconsistent Error Status Detection Across Components
**Files:**
- `src/components/records/RecordView.tsx` lines 68-83 — custom `getErrorStatusCode()`, handles 403/404/500
- `src/components/query/RecordQuery.tsx` lines 493-512 — generic "Failed to load records" for all errors
Error handling is implemented differently in the two main page components. If the API changes, fixes are needed in multiple places.
**Recommended Fix:**
```typescript
// src/lib/utils/error-utils.ts
export function getErrorStatusCode(error: unknown): number | undefined { ... }
export function getErrorMessage(error: unknown, context: string): string { ... }
```
Use this in both RecordView and RecordQuery.
---
## HIGH Quality Issues (significant tech debt)
### CQ-HIGH-1: RecordQuery — Too Many Responsibilities (586 lines)
**File:** `src/components/query/RecordQuery.tsx`
This component manages: quick search debounce, view mode toggle, mobile filter sheet state, density selector, and renders: toolbar (150+ lines inline), filter panel (desktop + mobile variants), bulk action bar, error/empty states, DataGrid, RecordCardView, and process launcher coordination.
**Extractable subcomponents:**
- `<RecordQueryToolbar />` — all toolbar state and rendering
- `<DensitySelector />` — the dropdown (lines 291-339)
- `<ViewModeToggle />` — button group (lines 258-288)
- `<QuickSearchInput />` — search field with debounce
---
### CQ-HIGH-2: FilterBuilder — No Memoization on Recursive Components
**File:** `src/components/query/FilterBuilder.tsx`
The recursive chain FilterBuilder → FilterGroup → CriteriaRow → FilterValueInput doesn't memoize callbacks or components. Every filter state change re-renders all sibling rows, and PossibleValueSingleSelect's useEffect (line 566) re-fires unnecessarily — potentially resetting combobox search input during user input.
**Fix:**
```typescript
export const CriteriaRow = React.memo(function CriteriaRow({ ... }) { ... })
export const FilterValueInput = React.memo(function FilterValueInput({ ... }) { ... })
```
---
### CQ-HIGH-3: Field Adornments Typed as `Record<string, unknown>`
**File:** `src/components/records/FieldValue.tsx`, lines 65-66, 119-120
```typescript
const href = (linkAdornment?.values?.['linkURL'] ?? String(value)) as string
const colorMap = (chipAdornment?.values?.['colorMap'] ?? {}) as Record<string, string>
```
These `as` casts are necessary because adornment `values` is `Record<string, unknown>`. The type information exists in the backend but isn't modeled.
**Recommended Fix:** Discriminated union types for adornments:
```typescript
type LinkAdornment = { type: 'LINK'; values: { linkURL: string } }
type ChipAdornment = { type: 'CHIP'; values: { colorMap: Record<string, string> } }
export type FieldAdornment = LinkAdornment | ChipAdornment | IconAdornment | ...
```
Eliminates all `as` casts in FieldValue.
---
### CQ-HIGH-4: Magic Numbers and Strings Scattered Throughout
- `use-record-query.ts:246` — `[10, 25, 50, 100].includes(n)` — hardcoded page sizes
- `RecordQuery.tsx:75` — `400` ms debounce
- `FilterBuilder.tsx:560` — `300` ms debounce
- `DataGrid.tsx:41-51` — density class maps inline
- `FieldValue.tsx:1024` — bytes-per-KB hardcoded
**Fix:**
```typescript
// src/lib/constants.ts
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const
export const SEARCH_DEBOUNCE_MS = 400
export const COMBOBOX_DEBOUNCE_MS = 300
```
---
### CQ-HIGH-5: `use-process` — Stale Closure via Manual `stateRef`
**File:** `src/lib/hooks/use-process.ts`, lines 105-128
```typescript
const stateRef = useRef(state)
stateRef.current = state
// later: stateRef.current.stepValues
```
Manually syncing a ref to shadow state is a sign that the polling subscription (lines 249-268) is too coupled to mutable state. This pattern is fragile and indicates the state management design needs simplification.
---
### CQ-HIGH-6: Dead Toolbar Button
**File:** `src/components/query/RecordQuery.tsx`, lines 394-401
A "Settings / more" button has an aria-label but no onClick handler — it does nothing. Either wire it up or remove it.
---
## MEDIUM Quality Issues
### CQ-MED-1: `use-record-query` — `effectiveFilter` Depends on Unrelated State
**File:** `src/lib/hooks/use-record-query.ts`, lines 317-332
`effectiveFilter` is recomputed when `state.columnVisibility` changes, but column visibility has nothing to do with filters. The dependency array is over-inclusive.
---
### CQ-MED-2: Boolean Operator Type is Raw String Cast
**File:** `src/components/query/FilterBuilder.tsx`, lines 196-198
```typescript
onChange({ ...filter, booleanOperator: e.target.value as 'AND' | 'OR' })
```
Should use a const assertion or enum to avoid typo risk:
```typescript
const BOOLEAN_OPERATORS = ['AND', 'OR'] as const
type BooleanOperator = typeof BOOLEAN_OPERATORS[number]
```
---
### CQ-MED-3: FieldValue — Repeated URL/Email Pattern Matching
**File:** `src/components/records/FieldValue.tsx`, lines 294, 339, 358
Three separate URL/email regex checks scattered across the file. Extract to:
```typescript
// src/lib/utils/string-utils.ts
export const isUrl = (s: string) => /^https?:\/\//i.test(s) || s.startsWith('/')
export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
```
---
### CQ-MED-4: RecordView — Redundant Section Fallback Logic
**File:** `src/components/records/RecordView.tsx`, lines 234-289
```typescript
const primarySections = visibleSections.filter(s => !s.tier || s.tier === 'T1' || s.tier === 'basic')
// ...
const t1Sections = primarySections.length > 0 ? primarySections : visibleSections
```
The fallback `? primarySections : visibleSections` is redundant if all sections without explicit tiers are already included in `primarySections`. Simplify or add a comment explaining when this fallback fires.
---
### CQ-MED-5: Missing Error Boundaries Below Route Level
Only `src/app/error.tsx` provides an error boundary. Forms and record views should have their own boundaries to prevent full-page crashes from single component errors.
---
### CQ-MED-6: `FilterBuilder` — `subs` Variable Name
**File:** `src/components/query/FilterBuilder.tsx`, line 178
```typescript
const subs = filter.subFilters ?? []
```
Should be `subFilters` for clarity.
---
## LOW / Style Issues
- **`data-qqq-id` inconsistency** — Mix of `grid-select-row-${row.index}` (underscores), `filter-field-${depth}-${index}` (hyphens). Standardize to kebab-case throughout.
- **Abbreviations:** `initVals` → `initialValues` (`use-record-query.ts:257`), `ej` → `exposedJoin` (`use-record-query.ts:348`), `pk` → `primaryKey` (DataGrid, RecordView), `subs` → `subFilters`
- **Import ordering:** `FilterBuilder.tsx` imports lucide-react after custom hooks. Project convention is: React → External → Types → Lib → Components.
- **Missing JSDoc:** `buildQuickFilterFromState()` (use-record-query.ts), `getErrorStatusCode()` (RecordView.tsx), `zodFieldFromMetadata()` (zod-from-metadata.ts) — all complex enough to warrant a one-liner doc comment.
- **Unnecessary `useMemo`:** `joins` calculation in `use-record-query.ts:345-354` is a simple object map — too cheap to justify memoization overhead.
- **Unused import:** `SelectField` in `DynamicFormField.tsx` (identified in prior review).
- **`window.innerWidth` in callback** — `RecordQuery.tsx:127-134` — not reactive to resize events.
---
## Positive Patterns Worth Preserving
1. **Metadata-Driven Rendering** — entire UI derived from backend metadata, zero hardcoded field/table names.
2. **Query Key Factory Pattern** — `queryKeys.tableRecords(tableName)` prevents string-based cache bugs.
3. **TanStack Query Usage** — proper staleTime, placeholderData, manual pagination, invalidation. No over-fetching.
4. **Typed API Layer** — all `src/lib/api/` functions properly typed, good use of discriminated unions for responses.
5. **Filter Serialization** — `serializeFilter()` / `deserializeFilter()` correctly handle shareable URLs.
6. **Debouncing** — SearchInput and FilterBuilder correctly debounce and clean up.
7. **Tailwind + CSS Custom Properties** — consistent theming without inline styles.
8. **Accessibility Fundamentals** — ARIA labels, roles, keyboard handling are present throughout.
---
## Recommended Fix Priority
| Priority | Item | Effort |
|----------|------|--------|
| 1 | CQ-CRIT-2: Extract `useAsyncCombobox` hook (FilterBuilder) | Medium |
| 2 | CQ-CRIT-3: `buildStringSchema()` helper in zod-from-metadata | Small |
| 3 | CQ-CRIT-4: Centralize `getErrorStatusCode()` utility | Small |
| 4 | CQ-HIGH-3: Discriminated union types for adornments | Medium |
| 5 | CQ-HIGH-4: Extract constants to `src/lib/constants.ts` | Small |
| 6 | CQ-HIGH-6: Remove or wire dead toolbar button | Trivial |
| 7 | CQ-HIGH-2: `React.memo` on FilterBuilder subcomponents | Small |
| 8 | CQ-MED-3: Extract URL/email utils | Small |
| 9 | CQ-CRIT-1: Split `use-record-query` into focused hooks | Large |
| 10 | CQ-HIGH-1: Extract RecordQuery toolbar subcomponents | Medium |
+390
View File
@@ -0,0 +1,390 @@
# QQQ Frontend Next — Code Review
**Date:** 2026-02-27
**Reviewer:** Claude (automated)
**Scope:** Full `src/` codebase audit
**Commit:** df8e2e6
---
## CRITICAL (7 findings)
---
### CRIT-1: XSS — unsanitized `dangerouslySetInnerHTML` in `FieldValue`
**File:** `src/components/records/FieldValue.tsx:136–143`
Raw backend HTML value injected into DOM with zero sanitization:
```tsx
dangerouslySetInnerHTML={{ __html: String(value) }}
```
**Fix:** Use DOMPurify:
```tsx
import DOMPurify from 'dompurify'
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(value)) }}
```
---
### CRIT-2: XSS — unsanitized `dangerouslySetInnerHTML` in `BlockWidget` (two locations)
**File:** `src/components/widgets/BlockWidget.tsx:40–48, 278–285`
Both the legacy `data.html` path and the `case 'html'` block renderer pass backend HTML directly to the DOM.
**Fix:** Apply `DOMPurify.sanitize()` to both HTML rendering paths.
---
### CRIT-3: XSS — regex script-stripping in `ProcessHtmlStep` is not real sanitization
**File:** `src/components/process/ProcessHtmlStep.tsx:14–17, 100`
```ts
// TODO: Replace with DOMPurify for full sanitization
function stripScripts(html: string): string {
return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
}
```
Bypassed by: `<img onerror="...">`, `<svg onload="...">`, `<a href="javascript:...">`, and dozens of other vectors.
**Fix:** Replace `stripScripts` with `DOMPurify.sanitize()`.
---
### CRIT-4: Auth bypass — `handleOAuthCallback` sets `isAuthenticated=true` without token exchange
**File:** `src/lib/auth/auth-provider.tsx:164–176`
```tsx
async function handleOAuthCallback(code: string, state: string) {
// For now, just mark as authenticated
setIsAuthenticated(true)
}
```
`code` and `state` params are accepted but unused. No CSRF state validation, no token exchange, no session creation.
**Fix:** Implement PKCE: validate `state` against `sessionStorage`, exchange `code`+`code_verifier` for tokens via backend `/manageSession`, then set `isAuthenticated` only on success.
---
### CRIT-5: Auth bypass — Auth0/OAuth2 sessions authenticate without server validation
**File:** `src/lib/auth/auth-provider.tsx:123–138`
Both `setupAuth0Session` and `setupOAuth2Session` call no protected endpoint. Any app configured with AUTH_0 or OAUTH2 shows as authenticated for any page load. Default user is hardcoded: `{ name: 'User', email: 'user@example.com' }`.
**Fix:** Call a protected endpoint (e.g., `GET /session`) to validate existing session cookie. Redirect to provider if 401.
---
### CRIT-6: Auth state corruption — 401 interceptor doesn't clear auth metadata cache
**File:** `src/lib/auth/auth-provider.tsx:65–70`
The 401 interceptor redirects to login but does NOT call `clearAuthMetadataCache()`, leaving stale auth metadata. On next page load the cached auth type governs initialization without a fresh server check.
**Fix:** Call `clearAuthMetadataCache()` inside the 401 callback before redirect.
---
### CRIT-7: Silent export failure — `ExportButton` swallows errors with no user feedback
**File:** `src/components/query/ExportButton.tsx:88–89`
```tsx
} catch (err) {
console.error('[ExportButton] Export failed:', err)
}
```
Users believe export succeeded when it silently failed.
**Fix:** Add `toast.error('Export failed. Please try again.')` in the catch block.
---
## HIGH (8 findings)
---
### HIGH-1: Stale closures — missing deps in URL sync + column persistence effects
**File:** `src/lib/hooks/use-record-query.ts:280–311`
Four `useEffect` hooks suppress `react-hooks/exhaustive-deps`. The URL-sync effect is missing `router` and `pathname` — stale closures can fire URL updates on the wrong route in concurrent mode.
**Fix:** Add `router` and `pathname` to the URL-sync effect deps. Use `useRef` for stable callback references if needed.
---
### HIGH-2: Performance — `JSON.stringify` key comparison on every query cache event in `useProcess`
**File:** `src/lib/hooks/use-process.ts:251–268`
```ts
if (JSON.stringify(key) !== JSON.stringify(expectedKey)) return
```
This serializes two arrays on every cache event during rapid polling intervals.
**Fix:** Use `queryClient.getQueryData(expectedKey)` or shallow key comparison utility.
---
### HIGH-3: Logic bug — `processUUID` unsafe cast falls back to empty string
**File:** `src/lib/hooks/use-process.ts:274–278`
```ts
const pUUID = (response as QJobResponse & { processUUID?: string }).processUUID ?? ''
```
If `processUUID` is absent, all subsequent step calls use `''` as the UUID, producing 404 API calls.
**Fix:** Add `processUUID` to `QJobResponse` type. Validate it is non-empty before proceeding.
---
### HIGH-4: Logic bug — type guard fallthrough on unexpected `QJobResponse` shapes
**File:** `src/lib/hooks/use-process.ts:76–82`
A response with both `error` and `values` fields, or with both `jobUUID` and `values`, falls through all type guards silently. Process state freezes indefinitely.
**Fix:** Use a discriminated union with an explicit `type` field. Add a default catch-all error case.
---
### HIGH-5: Stale auth — metadata cached globally without API base URL scoping
**File:** `src/lib/api/auth.ts:29–37`
Auth metadata cached for 1 hour under a single key `qqqAuthMetadata` regardless of API base URL. Backend auth type changes are ignored for up to 1 hour. Multi-instance deployments cross-contaminate caches.
**Fix:** Include API base URL in cache key. Consider shorter TTL (5–10 min) or TanStack Query instead.
---
### HIGH-6: Auth bypass — `globalSearch` silently swallows 401/403 errors
**File:** `src/lib/api/tables.ts:132–136`
```ts
} catch {
// If /search endpoint is not available (404), return empty results
return []
}
```
Catches all errors including 401 (session expired). A search 401 bypasses the global logout interceptor.
**Fix:**
```ts
} catch (err) {
if (isAxiosError(err) && err.response?.status === 404) return []
throw err
}
```
---
### HIGH-7: Wrong record selection — `selectedRecordIds` computed from array index, not stable PK
**File:** `src/lib/hooks/use-record-query.ts:411–421`
`rowSelection` is keyed by array index but records can reorder after sort/filter/pagination. Index 5 after a filter may point to a completely different record than before.
**Fix:** Key `rowSelection` by primary key string (same as `getRowId` in DataGrid). Update the `selectedRecordIds` computation to use PK-based lookup.
---
### HIGH-8: Dead retry button — `setPage(rq.pageNum)` is a no-op
**File:** `src/components/query/RecordQuery.tsx:503–508`
The error-state retry button dispatches `SET_PAGE` with the current page number — TanStack Query sees the same query key and does not re-fetch.
**Fix:** Use `queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableName) })`.
---
## MEDIUM (14 findings)
---
### MED-1: `AuthProvider` effect suppresses `onAuthError` dep without documentation
**File:** `src/lib/auth/auth-provider.tsx:121`
**Fix:** Document intent: `// intentional: runs once on mount — auth initialization is not repeatable`
---
### MED-2: CSS injection — raw `customCss` injected into `<style>` tag without validation
**File:** `src/app/(dashboard)/layout.tsx:108–119`
Arbitrary CSS from the backend can overlay phishing forms, exfiltrate keystrokes via CSS selectors, or perform UI redressing.
**Fix:** Use a CSS sanitizer or restrict to CSS custom property overrides only.
---
### MED-3: CSS injection — `accentColor` set on CSS custom properties without format validation
**File:** `src/app/(dashboard)/layout.tsx:71–89`
`accentColor: 'red; --qqq-primary: url(https://evil.com)'` would be applied as-is.
**Fix:** Validate `accentColor` matches `/^#[0-9a-fA-F]{3,8}$|^rgb|^hsl/` before applying.
---
### MED-4: Stale `initialValue` reference in `useLocalStorage.removeValue`
**File:** `src/lib/hooks/use-local-storage.ts:36–43`
`initialValue` is an object passed inline at call sites — new reference on every render. Causes `removeValue` re-creation and downstream `useEffect` churn.
**Fix:** Stabilize with `useRef`: `const initialValueRef = useRef(initialValue)`, use `initialValueRef.current` in `removeValue`, remove `initialValue` from deps.
---
### MED-5: `filterUtils` `btoa`/`atob` may produce overly long base64 for Unicode filter values
**File:** `src/lib/utils/filter-utils.ts:224–256`
Double-encoding (`encodeURIComponent` + `btoa`) produces unnecessarily long strings that may exceed URL limits.
**Fix:** Use `btoa(unescape(encodeURIComponent(json)))` for true Unicode-safe base64.
---
### MED-6: Open redirect — `fromPath` URL param used as `href` without origin validation
**File:** `src/components/records/RecordView.tsx:409–416`
`/app/person/1?from=javascript:alert(1)` creates a clickable XSS link.
**Fix:**
```ts
const safeFromPath = fromPath?.startsWith('/') ? fromPath : null
```
---
### MED-7: Hydration mismatch — `window.innerWidth` in `useState` initializer
**File:** `src/components/query/RecordQuery.tsx:92–97`
Server renders one view mode, client may initialize another, causing React hydration warning.
**Fix:** Initialize from `preferences.tableDefaultViewMode`, apply mobile override in `useEffect`.
---
### MED-8: `goBack` in `useProcess` is client-only, not a server step submission
**File:** `src/lib/hooks/use-process.ts:341–355`
Server-side state is not rolled back. If the server expects a `_goBack` step, the process goes out of sync.
**Fix:** Submit `processStep(processName, processUUID, '_goBack', {})` and let server dictate `nextStep`.
---
### MED-9: Missing `useCallback` on `EntityForm.onSubmit`
**File:** `src/components/forms/EntityForm.tsx:195–197`
`onSubmit` re-created on every render, causing unnecessary re-renders of all form fields.
**Fix:** `const onSubmit = useCallback(async (values) => { await activeMutation.mutateAsync(values) }, [activeMutation])`
---
### MED-10: Unvalidated `JSON.parse` from localStorage in `getStoredUser`
**File:** `src/lib/auth/auth-provider.tsx:150–161`
**Fix:** Add type guard or schema validation.
---
### MED-11: `TOGGLE_COLUMN` initializes from `undefined` for never-toggled columns
**File:** `src/lib/hooks/use-record-query.ts:119–126`
`!undefined === true` means first toggle always shows column, losing the "default" state distinction.
**Fix:**
```ts
const currentVal = state.columnVisibility[action.fieldName] ?? true
return { ...state, columnVisibility: { ...state.columnVisibility, [action.fieldName]: !currentVal } }
```
---
### MED-12: Memory leak — column resize `mousemove`/`mouseup` listeners not removed on unmount
**File:** `src/components/query/DataGrid.tsx:231–253`
If `DataGrid` unmounts during a resize drag, the document event listeners remain.
**Fix:** Track active listeners in a `useRef`; clean them up in a `useEffect` cleanup.
---
### MED-13: Premature error state when `record` is undefined but `isError` is false
**File:** `src/components/records/RecordView.tsx:120–121`
```ts
if (isError || !record) { /* renders error UI */ }
```
Shows error flash before loading state.
**Fix:** Split: `if (isError) { ... }` then `if (!record && !isLoading) { ... }`.
---
### MED-14: Stateful regex in `HighlightedText` — `gi` flag causes incorrect `test()` after `split()`
**File:** `src/components/feedback/SearchDialog.tsx:31–47`
`split(regex)` advances `lastIndex`, making subsequent `regex.test(part)` calls unreliable.
**Fix:** Create a new regex for `test()`, or use `part.toLowerCase() === query.toLowerCase()`.
---
## LOW (8 findings)
- **LOW-1** `auth.ts:64` — Duplicate `clearAuthMetadataCache()` call (harmless)
- **LOW-2** `query-client.ts:55` — Widget cache key unstable with unordered `params` objects
- **LOW-3** `Sidebar.tsx:128` — Missing comment explaining intentional dep omission
- **LOW-4** `RecordView.tsx:37–44` — `getInitials('')` throws TypeError (empty label)
- **LOW-5** `DynamicFormField.tsx` — Unused `SelectField` import (dead code)
- **LOW-6** `RecordQuery.tsx:127–134` — `window.innerWidth` in callback not reactive to resize
- **LOW-7** `ExportButton.tsx:81–86` — Legacy DOM insertion pattern for file download trigger
- **LOW-8** `process-utils.ts`, `use-routes.ts` — Verify `hasPermission`/`readPermission` filtering for nav items
---
## Priority Order for Fixes
1. **CRIT-1, CRIT-2, CRIT-3** — Install DOMPurify, apply to all 4 HTML rendering sites
2. **CRIT-4, CRIT-5** — Auth stub implementations need proper server validation
3. **CRIT-6** — One-line fix: add `clearAuthMetadataCache()` to 401 callback
4. **HIGH-6** — One-line fix: re-throw non-404 errors in `globalSearch`
5. **HIGH-7** — Fix record selection to use stable PKs
6. **HIGH-8** — Fix retry button to call `invalidateQueries`
7. **CRIT-7** — Add error toast to `ExportButton`
8. **MED-6** — Validate `fromPath` starts with `/`
9. **HIGH-1** — Fix stale closures in `useRecordQuery` effects
10. Remaining HIGH/MED items by team priority
+398
View File
@@ -0,0 +1,398 @@
# Component Patterns
This guide describes the recurring patterns used throughout the QQQ Frontend Next codebase. Understanding these patterns is essential before adding new components or modifying existing ones.
---
## Pattern 1: Metadata-Driven Rendering
QQQ is a metadata-driven framework. The backend declares all structure — table names, field lists, widget layouts, and navigation items — and the frontend renders from it dynamically. No table names, field names, or navigation entries are hardcoded anywhere in the component tree.
### How it works
**Server-side (or near-server) layers** fetch metadata and pass it down as props. **Client Components** never fetch metadata themselves — they receive it through their props and render from it.
The routing entry point is `src/app/(dashboard)/app/[slug]/page.tsx`. It is a Client Component that fetches the full QQQ instance metadata from the server, resolves the slug against the metadata, and dispatches to the appropriate page component:
```tsx
// src/app/(dashboard)/app/[slug]/page.tsx (simplified)
export default function SlugPage() {
const params = useParams<{ slug: string }>()
const slug = params.slug
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30, // 30 minutes
})
// Resolve slug against apps, tables, processes, reports in priority order
const isTable = Boolean(metaData?.tables?.[slug])
const table = metaData?.tables?.[slug]
// Pass resolved metadata as props — the child component never fetches
if (isTable && table) {
const tableProcesses = getProcessesForTable(metaData, slug)
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
}
// ... other cases
}
```
`RecordQuery` receives `tableMetaData` as a prop. It does not know or care how that data arrived — it renders from it:
```tsx
// src/components/query/RecordQuery.tsx
interface RecordQueryProps {
tableName: string
tableMetaData: QTableMetaData // always passed in, never fetched internally
processes?: QProcessMetaData[]
}
export function RecordQuery({ tableName, tableMetaData, processes }: RecordQueryProps) {
// tableMetaData is already resolved — no fetching here
const canCreate = tableMetaData.insertPermission
// ...
}
```
### Key rules
- `tableMetaData` (and any other metadata object) is `undefined` until TanStack Query resolves it. Every component must handle the loading state. The slug page renders a spinner until metadata is ready; it never passes `undefined` to its child components.
- User-facing text always uses `label` from metadata, never `name`. For example, the page header is set to `table?.label ?? slug`, not the raw slug.
- The `resolveSlugTarget` function in `SlugPage` is extracted as a pure function precisely so it can be tested in isolation without any rendering.
### Resolution priority
The slug page resolves in this order: **app** → **table** → **process** → **report**. The first match wins.
---
## Pattern 2: data-qqq-id Attributes
Every significant or interactive element carries a `data-qqq-id` attribute. This attribute serves two purposes:
1. **CSS customization hook** — deployers can write `[data-qqq-id="button-save"] { ... }` in their own stylesheets to override the default styling without touching source code.
2. **Test selector** — Playwright E2E tests and unit tests target elements by `data-qqq-id` rather than by class name (which is an implementation detail subject to change).
### Naming convention
The format is `{component-type}-{identifier}`:
| Pattern | Example |
|---|---|
| `record-query-{tableName}` | `record-query-orders` |
| `widget-{name}` | `widget-ordersSummary` |
| `widget-label-{name}` | `widget-label-ordersSummary` |
| `widget-content-{name}` | `widget-content-ordersSummary` |
| `button-widget-reload-{name}` | `button-widget-reload-ordersSummary` |
| `button-widget-export-{name}` | `button-widget-export-ordersSummary` |
| `button-widget-help-{name}` | `button-widget-help-ordersSummary` |
| `button-save` | `button-save` |
| `button-cancel` | `button-cancel` |
| `field-value-{fieldName}` | `field-value-status` |
| `filter-panel-close` | `filter-panel-close` |
| `link-back-to-source` | `link-back-to-source` |
| `unknown-slug-{slug}` | `unknown-slug-foo` |
### Examples from the actual code
`WidgetBlock.tsx` — the outer card, its label, and its icon buttons:
```tsx
<section
data-qqq-id={`widget-${name}`}
aria-label={label}
>
<h3 data-qqq-id={`widget-label-${name}`}>{label}</h3>
<button data-qqq-id={`button-widget-help-${name}`}>...</button>
<button data-qqq-id={`button-widget-export-${name}`}>...</button>
<button data-qqq-id={`button-widget-reload-${name}`}>...</button>
<div data-qqq-id={`widget-content-${name}`}>...</div>
</section>
```
`RecordQuery.tsx` — the outer container, the filter close button, and the back link:
```tsx
<div data-qqq-id={`record-query-${tableName}`}>
<Link data-qqq-id="link-back-to-source">...</Link>
<button data-qqq-id="filter-panel-close">...</button>
</div>
```
`EntityForm.tsx` — the form itself and its action buttons:
```tsx
<form data-qqq-id={`entity-form-${tableMetaData.name}`}>
<button data-qqq-id="button-cancel">Cancel</button>
<button data-qqq-id="button-save">Save</button>
</form>
```
`FieldValue.tsx` — every field value element is tagged with its field name:
```tsx
<span data-qqq-id={`field-value-${field.name}`}>...</span>
```
### Rules
- Do not use Tailwind class names as test selectors. If a test needs to click a button, it should use `page.getByTestId` or `[data-qqq-id="..."]` selectors, not `.bg-primary` or `.rounded-md`.
- Identifier segments use the backend `name` field (e.g., `orders`), not the `label` (e.g., `"Customer Orders"`). Labels can contain spaces and special characters; names are safe identifiers.
---
## Pattern 3: Async Combobox (`useAsyncCombobox`)
Any dropdown that loads its options from the API with user-typed search uses the `useAsyncCombobox` hook (`src/lib/hooks/use-async-combobox.ts`). The hook encapsulates fetch, debounce, AbortController cancellation, and click-outside-to-close logic that would otherwise be duplicated in every combobox component.
### When to use it
Use `useAsyncCombobox` when:
- The options come from a QQQ possible-value source (table-backed lookup).
- The user can type to search and the list is fetched on demand.
- The option list is too large to load all at once (more than ~50 items).
Use a plain `<select>` or a static shadcn/ui `<Select>` when:
- The options are a small static list (e.g., sort direction: "Ascending / Descending").
- The options are already available in memory (e.g., column names from `tableMetaData.fields`).
### Usage
```tsx
import { useAsyncCombobox } from '@/lib/hooks/use-async-combobox'
function MyPossibleValueInput({ tableName, fieldName, onChange }) {
const {
isOpen,
setIsOpen,
searchTerm,
setSearchTerm,
options, // QPossibleValue[] — the fetched options to display
isLoading,
containerRef, // attach to outermost wrapper for click-outside close
inputRef, // attach to the <input> for focus management
debouncedFetch, // call in onChange handler to trigger debounced fetch
} = useAsyncCombobox({
tableName,
fieldName,
onOptionsFetched: (results) => {
// optional: update a label map or other derived state
},
})
return (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
debouncedFetch(e.target.value)
}}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<ul role="listbox">
{isLoading && <li>Loading...</li>}
{options.map((opt) => (
<li
key={opt.id}
role="option"
onClick={() => {
onChange(opt.id)
setIsOpen(false)
}}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
)
}
```
### What the hook manages internally
| Concern | Mechanism |
|---|---|
| Debouncing | `setTimeout` cleared on each call; fires after `COMBOBOX_DEBOUNCE_MS` (200 ms) |
| Request cancellation | `AbortController` — aborts the previous fetch before starting a new one |
| Click-outside close | `document.addEventListener('mousedown', handler)` attached to `containerRef` |
| Auto-fetch on open | `useEffect` on `isOpen` — fetches the full list when the dropdown opens |
| Cleanup on unmount | Clears pending timer and aborts any in-flight request |
### Return values
| Field | Type | Purpose |
|---|---|---|
| `isOpen` | `boolean` | Drive conditional rendering of the dropdown list |
| `setIsOpen` | `Dispatch` | Programmatically open/close (e.g., on focus, on select) |
| `searchTerm` | `string` | Controlled value for the text input |
| `setSearchTerm` | `Dispatch` | Update the text input value |
| `options` | `QPossibleValue[]` | The fetched options — map these to `<li>` elements |
| `isLoading` | `boolean` | Show a spinner or loading state while the fetch is in-flight |
| `containerRef` | `RefObject<HTMLDivElement>` | Attach to the outermost wrapper element |
| `inputRef` | `RefObject<HTMLInputElement>` | Attach to the text `<input>` element |
| `fetchOptions` | `(term: string) => Promise<void>` | Immediate fetch (bypasses debounce) |
| `debouncedFetch` | `(term: string) => void` | Debounced fetch — use this in `onChange` |
---
## Pattern 4: Widget Rendering Pipeline
All dashboard widgets flow through a three-layer pipeline:
```
ConnectedWidget
└─ WidgetBlock (card chrome, loading, error, dropdown selects)
└─ WidgetRenderer (type dispatcher)
└─ StatisticsWidget | BarChartWidget | BlockWidget | ...
```
### Layer 1: ConnectedWidget
`src/components/widgets/ConnectedWidget.tsx` is the primary entrypoint. It:
1. Reads `widgetMetaData.dropdowns` and initializes dropdown state.
2. Fetches dropdown option lists from the possible-value API.
3. Calls `useWidget(widgetMetaData.name, mergedParams)` to fetch the widget's data payload.
4. Returns `null` immediately when `widgetMetaData.hasPermission === false`.
5. Renders `<WidgetBlock>` with loading/error props and passes the fetched `data` to `<WidgetRenderer>`.
```tsx
// ConnectedWidget orchestrates everything
export function ConnectedWidget({ widgetMetaData, params, className }) {
const { data, isLoading, isError, error, refetch } = useWidget(widgetMetaData.name, mergedParams)
return (
<WidgetBlock
widgetMetaData={widgetMetaData}
isLoading={isLoading}
isError={isError}
error={error}
onReload={refetch}
// ... dropdown props
>
{data && <WidgetRenderer widgetMetaData={widgetMetaData} data={data} />}
</WidgetBlock>
)
}
```
### Layer 2: WidgetBlock
`src/components/widgets/WidgetBlock.tsx` provides the standard card shell:
- Header with `label`, optional help tooltip, optional export button, optional reload button, and optional dropdown `<select>` elements.
- Body area that renders a skeleton, an error state, or its `children`.
- A `bare` prop that strips all chrome for use inside composite widgets.
The `WidgetBlock` does not know what type of widget it wraps. It only knows about loading/error state.
### Layer 3: WidgetRenderer
`src/components/widgets/WidgetRenderer.tsx` is the type dispatcher. It receives `widgetMetaData` and the raw `data` payload, reads `widgetMetaData.type`, and renders the appropriate typed component via a `switch` statement.
Supported types:
| `widgetMetaData.type` | Rendered component |
|---|---|
| `'statistics'` | `StatisticsWidget` |
| `'barChart'` | `BarChartWidget` (lazy) |
| `'lineChart'` | `LineChartWidget` (lazy) |
| `'pieChart'` | `PieChartWidget` (lazy) |
| `'recordGrid'` | `RecordGridWidget` |
| `'html'` / `'block'` | `BlockWidget` |
| `'divider'` | `DividerWidget` |
| `'quickLinks'` | `QuickLinksWidget` |
| `'alert'` | `AlertWidget` |
| `'processSummary'` | `ProcessSummaryWidget` |
| `'composite'` / `'parent'` | `CompositeWidget` |
| `'chart'` | `ChartTypeDispatcher` (secondary `chartType` dispatch) |
Chart widgets (`barChart`, `lineChart`, `pieChart`) are loaded with `React.lazy` + `Suspense` to split the Recharts library into a separate bundle chunk. This keeps the initial JS bundle small on pages that contain no charts.
### How to add a new widget type
1. Create a new component file, e.g., `src/components/widgets/HeatmapWidget.tsx`. Export a typed payload interface and the component:
```tsx
// src/components/widgets/HeatmapWidget.tsx
export interface HeatmapWidgetPayload {
rows: Array<{ label: string; cells: number[] }>
columnLabels: string[]
}
export function HeatmapWidget({ data, widgetName }: { data: HeatmapWidgetPayload; widgetName: string }) {
// render the heatmap
}
```
2. Add a `case` to the `switch` in `WidgetRenderer.tsx`:
```tsx
import { HeatmapWidget } from './HeatmapWidget'
import type { HeatmapWidgetPayload } from './HeatmapWidget'
// Inside the switch:
case 'heatmap':
return <HeatmapWidget data={data as HeatmapWidgetPayload} widgetName={name} />
```
3. The backend returns `widgetMetaData.type = 'heatmap'` for this widget. No other changes are needed — `ConnectedWidget` picks it up automatically.
---
## Pattern 5: Field Adornment Priority
`FieldValue` (`src/components/records/FieldValue.tsx`) renders a single QQQ field value in read-only mode. When a field has adornments (decorations declared in `field.adornments[]`), `FieldValue` applies them in strict priority order. The first matching adornment wins; subsequent ones are skipped.
### Priority order
1. **LINK** — renders the value as an `<a>` tag with an external link icon.
2. **FILE_DOWNLOAD** — renders a download anchor using the display value as the URL.
3. **SIZE** — formats the raw number as a human-readable byte size (e.g., `1.5 MB`).
4. **CHIP** — renders the value as a colored badge using a `colorMap` from adornment values.
5. **RENDER_HTML** (or field type `HTML`) — renders sanitized HTML via `DOMPurify.sanitize`.
6. **CODE_EDITOR** — renders the value in a monospace `<pre><code>` block.
7. **TOOLTIP** — wraps the value in a Radix tooltip. Falls through to type-based rendering when no tooltip text is resolved.
8. **ERROR** — renders the value with a destructive icon and styling.
9. Record reference link — when `field.possibleValueSourceName` matches a known table, renders as a Next.js `<Link>` with a `RecordHoverCard` preview.
10. Type-based fallbacks — `BOOLEAN`, `PASSWORD`, `BLOB`, `TEXT` each have dedicated renderers.
11. Default — auto-links bare `http(s)://` URLs and `mailto:` email addresses.
### How adornment detection works
Each adornment is checked with `Array.some()` before its rendering block. The discriminated union type `FieldAdornment` is narrowed inside each block using a type predicate:
```tsx
// Detection
const hasLink = field.adornments?.some((a) => a.type === 'LINK')
// Narrowing within the block
if (hasLink) {
const linkAdornment = field.adornments?.find(
(a): a is Extract<FieldAdornment, { type: 'LINK' }> => a.type === 'LINK'
)
const href = linkAdornment?.values?.linkURL ?? String(value)
// ...
}
```
The `Extract<FieldAdornment, { type: 'LINK' }>` predicate gives TypeScript full knowledge of which `values` keys are available for that specific adornment type without a cast.
### TOOLTIP fall-through
The TOOLTIP case has one deliberate exception to the "first match wins" rule: if the `TOOLTIP` adornment is present but none of the `values` keys (`tooltipText`, `text`, `tooltip`) resolve to a non-empty string, the block falls through to type-based rendering. The value is still displayed — only the tooltip wrapper is omitted because there is nothing to show. This is documented with a comment in the source.
### DOMPurify sanitization
HTML values are sanitized once via `useMemo` that only re-runs when `value` changes:
```tsx
const sanitizedHtml = useMemo(
() => DOMPurify.sanitize(String(value)),
[value]
)
```
This prevents unnecessary sanitization work on every render while also preventing XSS from backend-provided HTML content.
+35
View File
@@ -0,0 +1,35 @@
# Material CSS hooks in Next UI
QQQ application themes can provide `supplementalInstanceMetaData.materialDashboardTheme.customCss`. Next UI keeps the main Material Dashboard `data-qqq-id` hooks so application CSS can target metadata-driven controls. The source of truth for ID generation is [`src/lib/utils/qqq-id.ts`](../src/lib/utils/qqq-id.ts); [NAV-055](acceptance/feature-matrix.md) exercises representative selectors against the real sample server.
## ID rules
The ID suffix is lowercased, each run of characters outside `a-z` and `0-9` becomes one dash, leading and trailing dashes are removed, and the result is cut at 50 characters. Camel case is not split: `firstName` becomes `firstname`. Use the metadata label for navigation entries and the metadata name for fields, tables and sections.
| Control | Pattern | Example |
|---|---|---|
| Button | `button-{id or text}` | `button-save`, `button-query-mode-advanced` |
| Editable text field | `input-{field}` | `input-firstname` |
| Possible-value field | `select-{field}` | `select-speciesid` |
| Boolean field | `switch-{field}` | `switch-isemployed` |
| Navigation item | `sidenav-{label}` | `sidenav-people-app` |
| Menu item | `menu-item-{text}` | `menu-item-bulk-edit` |
| Tab | `tab-{label}` | `tab-related` |
| Table header | `table-header-{field}` | `table-header-firstname` |
| Link | `link-{text}` | `link-back-to-person` |
The ID may be on a layout-neutral wrapper around a native button or menu item. Select the wrapper when applying layout or visibility rules, and select its button descendant for button-specific styles. Inspect the rendered element before changing an application's CSS.
## Stable named hooks
- Navigation: `sidenav-root`, `sidenav-logo-area`, `sidenav-menu-list`, `sidenav-logout-button`; `.qqq-sidebar-active` marks the active item. `data-qqq-sidenav-item-type` identifies `user-profile` and `top-level-parent-app` entries.
- App home and chrome: `app-card-{appName}-icon`, `.banner.info`/`.banner.warning`/`.banner.error`, and `.qqq-branded-header-bar`.
- Query: `button-filter-builder`, `button-views`, `quick-views-container`, `button-query-mode-basic`, `button-query-mode-advanced`, `table-header-{field}`, and `menu-item-{action}`.
- Record view: `.recordView`, `record-sidebar`, `record-view-header-{table}`, `record-view-title-{table}`, `record-view-button-bar-{table}`, `record-view-actions-menu`, and `delete-confirmation-*` with `button-delete-yes`/`button-delete-no`.
- Forms: `.entityForm`, `record-create-{table}`/`record-edit-{table}`, `form-section-{section}`, `.field-wrapper.is-visible`, and `.stickyBottomButtonBar`. Default action buttons expose `data-button-variant` values such as `gradient`, `contained`, and `outlined`.
The record view has a phone accordion and action sheet, a tablet tab list without the large-screen sidebar, and a large-screen tab list with the sidebar. A selector can be present in the DOM while its control is hidden at a narrower width. Test custom CSS at all three widths, including focus and contrast, before shipping it.
## Display mode and primary text
Next's persisted light/dark choice controls both theme tokens and Tailwind `dark:` variants. OS color preference does not override it; a configured Material application theme keeps light mode active. `--color-primary` controls branded fills, while `--text-color-primary` controls the existing `text-primary` classes and opacity variants. In light mode the text token follows the primary color; in dark mode it mixes the brand hue with white for readable text on dark surfaces. Explicit application theme and custom CSS rules still take precedence. Verify text and filled-button contrast separately when overriding these tokens.
+278
View File
@@ -0,0 +1,278 @@
# Design & UX Review — QQQ Frontend Next
**Date:** 2026-03-01
**Reviewer:** Claude Code (automated static analysis)
**Scope:** Full codebase UX/design audit — layout, pages, forms, process, widgets, feedback, theme, responsive, and a11y
---
## Executive Summary
The new frontend has strong bones: metadata-driven rendering is correctly implemented, component composition is clean, TanStack Query manages server state well, and the base accessibility implementation (labels, aria-required, focus rings, skip nav on breadcrumbs) is solid. The primary gaps fall into three categories:
1. **Accessibility** — several WCAG 2.1 AA gaps in the data grid, animations, and motion preferences
2. **Mobile/responsive polish** — search hidden on mobile, touch targets below 44px minimum in several places
3. **Loading/error state coverage** — a handful of async operations show no spinner or fallback
No showstopper UX regressions from the Material UI frontend were found. The issues below are incremental improvements.
---
## Severity Legend
| Severity | Meaning |
|----------|---------|
| **CRIT** | WCAG 2.1 Level A violation or severe usability break |
| **HIGH** | WCAG 2.1 AA violation or significant UX regression |
| **MED** | Noticeable UX gap, inconsistency, or missing polish |
| **LOW** | Minor polish, nice-to-have, or future consideration |
---
## 1. Layout Shell
**Files:** `src/components/layout/Sidebar.tsx`, `Header.tsx`, `Breadcrumbs.tsx`, `Banner.tsx`, `src/app/(dashboard)/layout.tsx`
### Strengths
- Desktop sidebar + mobile drawer correctly split at `md` breakpoint
- Breadcrumbs have `aria-label="Breadcrumb"` and `aria-current="page"`
- Escape key closes mobile drawer; sidebar items have focus rings
- Banner severity states (info/warning/error) correctly scoped with icons
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-L-1 | **HIGH** | **No skip-to-content link.** Keyboard users must tab through the entire sidebar before reaching page content. Add `<a href="#main-content" className="sr-only focus:not-sr-only">Skip to main content</a>` as the first child of `<body>`. |
| D-L-2 | **HIGH** | **Animations ignore `prefers-reduced-motion`.** Sidebar slide-in, banner fade-in, and skeleton `animate-pulse` all run unconditionally. Add `@media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }` to `globals.css`. |
| D-L-3 | **MED** | **Header search hidden on mobile** (`hidden md:block`). Mobile users lose global search entirely. Add a search icon button on mobile that opens `SearchDialog`. |
| D-L-4 | **MED** | **Dismissed banners not persisted.** Banners dismissed via local state reappear on reload. Save dismissed banner keys to `localStorage` under `qqq:dismissed-banners`. |
| D-L-5 | **MED** | **Mobile sidebar `aria-expanded` not set.** The mobile drawer open/closed state isn't announced to screen readers. Add `aria-expanded={mobileOpen}` to the nav landmark element. |
| D-L-6 | **LOW** | **Breadcrumb overflow on very narrow viewports** (<320px). Long breadcrumb chains don't truncate; last segment can overflow. Add `truncate` + `max-w-[8rem]` on intermediate segments. |
| D-L-7 | **LOW** | **Sidebar nav has no loading skeleton.** During the initial metadata fetch the sidebar is empty with no placeholder. Add skeleton nav items matching the expected structure. |
---
## 2. Record Query (List View)
**Files:** `src/components/query/DataGrid.tsx`, `FilterBuilder.tsx`, `Pagination.tsx`, `RecordQueryToolbar.tsx`, `RecordCardView.tsx`, `ColumnConfig.tsx`
### Strengths
- Skeleton rows during fetch match final layout (no layout shift)
- Three row density variants (compact/standard/comfortable) with persistence
- Empty state with icon, title, and "Clear filters" CTA
- Quick-search debounced at 300ms
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-Q-1 | **CRIT** | **DataGrid missing `aria-sort` on column headers.** When a column is sorted, screen readers don't announce direction. Add `aria-sort="ascending" \| "descending" \| "none"` to each `<th>` based on current sort state. |
| D-Q-2 | **HIGH** | **Row selection checkboxes have no label.** Each row checkbox announces "checkbox" with no context. Add `aria-label={\`Select ${recordLabel}\`}` to each. |
| D-Q-3 | **HIGH** | **Column resize handles not keyboard accessible.** Resize is mouse-drag only. Keyboard users cannot adjust column widths. Add `onKeyDown` handler supporting `ArrowLeft`/`ArrowRight` to the resize handle element. |
| D-Q-4 | **MED** | **FilterBuilder async combobox has no loading indicator.** While possible-value options are fetching, the dropdown appears empty with no spinner. Add a `Loader2` icon while `isLoading` is true. |
| D-Q-5 | **MED** | **"Go to page" input silently ignores out-of-range values.** Typing a page number beyond the last page does nothing with no feedback. Show an inline validation message like "Page must be between 1 and N". |
| D-Q-6 | **MED** | **No `aria-live` region for fetch-in-progress.** Background re-fetches (sort/filter changes) are visually indicated by the progress bar but not announced. Add `aria-live="polite"` region announcing "Loading results". |
| D-Q-7 | **MED** | **Column visibility toggle state not announced.** Toggling a column in ColumnConfig doesn't notify screen readers. Add a `role="status"` region that announces "Column X hidden" / "Column X visible". |
| D-Q-8 | **LOW** | **No frozen/pinned columns.** The primary key / record label column scrolls out of view on wide tables. Consider pinning the first column on horizontal scroll. |
| D-Q-9 | **LOW** | **RecordCardView touch targets may be too small.** Cards are clickable but no explicit 44px minimum hit area. Ensure card tap targets meet the 44px minimum on mobile. |
---
## 3. Record View (Detail Page)
**Files:** `src/components/records/RecordView.tsx`, `RecordViewHeader.tsx`, `FieldValue.tsx`, `RecordViewAssociated.tsx`
### Strengths
- DOMPurify sanitization applied before rendering HTML field values
- 403/404/500 errors shown with contextual messages
- Tab layout for T2/T3 sections with proper semantic structure
- Field adornment priority correctly handled (LINK → FILE_DOWNLOAD → SIZE → CHIP)
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-V-1 | **HIGH** | **Edit/Delete buttons shown regardless of user permissions.** If table metadata indicates the user lacks UPDATE/DELETE rights, buttons should be hidden (or disabled with tooltip). Currently they render and fail with a 403 on action. |
| D-V-2 | **MED** | **Associated records list not paginated.** Many-to-many joins can return thousands of records, all rendered to DOM. Add pagination (or a "Show more" pattern) capped at 25 by default. |
| D-V-3 | **MED** | **Avatar initials fail for non-Latin scripts.** `getInitials()` splits on whitespace which produces empty strings for CJK/Arabic names. Add a fallback to a generic user icon when initials can't be derived. |
| D-V-4 | **MED** | **No delete confirmation dialog.** The delete action currently triggers immediately. Add a confirm dialog ("Delete this record? This cannot be undone.") before the API call. |
| D-V-5 | **LOW** | **No "Copy record ID" quick action.** Admin users frequently need the primary key for support/debugging. Add a small copy-to-clipboard icon next to the record ID. |
| D-V-6 | **LOW** | **No audit trail footer.** No "Last modified by / at" metadata shown. If the backend provides this, surface it at the bottom of the record view. |
---
## 4. Create / Edit Forms
**Files:** `src/components/forms/DynamicForm.tsx`, `EntityForm.tsx`, `DynamicFormField.tsx`, `src/components/forms/field-types/`
### Strengths
- All inputs have `aria-required`, `aria-invalid`, `aria-describedby` wired correctly
- Unsaved-changes guard (`beforeunload` + modal) prevents accidental data loss
- Help tooltips use Radix Tooltip with keyboard support
- Required asterisk is `aria-hidden="true"` to avoid screen-reader repetition
- Disabled state styling consistent across field types
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-F-1 | **HIGH** | **Submit button stays enabled during mutation.** User can click Save multiple times before the first request completes, creating duplicate records. Add `disabled={isSubmitting}` and a loading spinner to the submit button. |
| D-F-2 | **MED** | **Number fields don't display min/max constraints.** If field metadata defines `minValue`/`maxValue`, the user doesn't see them until a validation error fires. Show the range as hint text below the field (`0 – 100`). |
| D-F-3 | **MED** | **DateTime fields don't indicate time zone.** The input accepts a time but doesn't show which time zone is assumed. Show the user's local TZ abbreviation next to the field. |
| D-F-4 | **MED** | **Help tooltips not touch-friendly on mobile.** The `?` icon is hover-only and < 44px. On mobile, show the help text inline beneath the label instead. |
| D-F-5 | **MED** | **File upload field has no drag-and-drop visual feedback.** The drop zone changes border on `dragover` but has no text like "Drop file here". Add clear drop-zone copy and a highlight color change. |
| D-F-6 | **LOW** | **No "which fields changed" diff in edit mode.** Users editing a long form can't tell what they've modified at a glance. Consider highlighting changed fields with a left border accent. |
| D-F-7 | **LOW** | **Large forms show all fields at once.** Sections with 20+ fields have no collapse/expand. Consider optional `<details>` sections for secondary field groups. |
---
## 5. Process Execution
**Files:** `src/components/process/ProcessRun.tsx`, `StepWizard.tsx`, `ProcessFormStep.tsx`, `ValidationReviewStep.tsx`, `ProcessUploadFormStep.tsx`, `BulkLoadStep.tsx`
### Strengths
- Step wizard gives clear numbered/checkmark visual progress
- Cancellation confirm dialog prevents accidental process abort
- Back navigation available when not on the first step
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-P-1 | **HIGH** | **Current step not auto-focused after "Next".** When advancing to a new step, focus remains on the Next button (now gone), leaving screen reader users disoriented. Move focus to the step heading or first field on step change. |
| D-P-2 | **MED** | **Step wizard steps not keyboard-navigable (back).** Completed steps have no click handler to jump back. Add click-to-navigate on completed step indicators. |
| D-P-3 | **MED** | **No process timeout message.** If the backend hangs, the polling spinner runs indefinitely. After ~60s show "This is taking longer than expected" with an option to wait or cancel. |
| D-P-4 | **MED** | **Validation review results not filterable.** With 1000+ errors, the flat list is unmanageable. Add severity filter buttons (All / Errors / Warnings) and a field-name search. |
| D-P-5 | **MED** | **File upload step has no progress bar.** Large uploads show no byte-level progress. Add a `<progress>` element or Radix Progress during multipart upload. |
| D-P-6 | **MED** | **Step label truncation has no mobile tooltip.** Long step names are truncated at `max-w-[6rem]` with only a `title` attribute, which doesn't appear on touch. Add a Radix Tooltip wrapper. |
| D-P-7 | **LOW** | **No "retry" on process step failure.** Users must restart the entire process from step 1 if one step fails. Add a "Retry this step" button where the backend supports re-running a step. |
| D-P-8 | **LOW** | **Bulk load column mappings not saveable.** Users re-map the same CSV columns every run. Add a "Save this mapping" option persisted to localStorage. |
---
## 6. Widgets & Dashboard
**Files:** `src/components/widgets/WidgetRenderer.tsx`, `StatisticsWidget.tsx`, `BlockWidget.tsx`, `BarChartWidget.tsx`, `LineChartWidget.tsx`, `PieChartWidget.tsx`, `RecordGridWidget.tsx`
### Strengths
- Charts lazy-loaded via dynamic import (non-chart pages don't load Recharts)
- Suspense skeleton placeholder shown while chart chunk loads
- Exhaustive `switch` on widget type with unknown fallback
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-W-1 | **HIGH** | **Statistics trend arrows not accessible.** `TrendingUp`/`TrendingDown`/`Minus` icons are `aria-hidden` with no text alternative. Screen readers don't announce whether a metric is up or down. Add `<span className="sr-only">trending up</span>` next to each icon. |
| D-W-2 | **MED** | **Widget data fetch errors show blank space.** If a widget's API call fails there is no error boundary or fallback UI. Add a per-widget error state (icon + "Could not load widget" message + retry). |
| D-W-3 | **MED** | **Chart legends overflow horizontally on mobile.** Recharts legends don't reflow on narrow screens. Use `layout="vertical"` or hide the legend on mobile with a `<ResponsiveContainer>` width check. |
| D-W-4 | **MED** | **RecordGrid widget not paginated.** Renders the full result set to DOM. Cap at 25 rows and add a "View all" link to the full record query page. |
| D-W-5 | **MED** | **No manual widget refresh.** Stale widget data can't be refreshed without a full page reload. Add a small refresh icon button on each widget card header. |
| D-W-6 | **LOW** | **Only 5 chart color tokens defined.** Dashboards with 6+ data series reuse colors, making series indistinguishable. Extend `--color-chart-*` to at least 10 distinct values. |
---
## 7. Feedback Components
**Files:** `src/components/feedback/GlobalSearch.tsx`, `SearchDialog.tsx`, `CommandMenu.tsx`, `EmptyState.tsx`, `ErrorBoundary.tsx`, `KeyboardShortcutsDialog.tsx`
### Strengths
- `EmptyState` uses `role="status"` + `aria-live="polite"` correctly
- Skeleton components have `role="status"` + `aria-busy="true"`
- SearchDialog has full ArrowUp/ArrowDown/Enter/Escape keyboard nav
- `HighlightedText` now correctly uses `i % 2 !== 0` (recently fixed)
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-FB-1 | **MED** | **Keyboard Shortcuts dialog not discoverable.** `?` opens it but there is no visible hint in the UI. Add a `?` icon button in the header, or a footer note "Press ? for keyboard shortcuts". |
| D-FB-2 | **MED** | **Toasts have no visible close/dismiss button.** Users must wait for auto-dismiss. Add an × button to each toast (sonner supports `closeButton` prop). |
| D-FB-3 | **MED** | **CommandMenu icon buttons below 44px touch target.** Small icon-only buttons in the command menu are hard to tap on mobile. Ensure `min-h-[44px] min-w-[44px]` on interactive icons. |
| D-FB-4 | **LOW** | **Search results missing table/app context in header.** Results are grouped by table in logic but group labels may not be visually prominent enough. Ensure group headers have sufficient weight/spacing. |
---
## 8. Theme & Global Styles
**Files:** `src/styles/globals.css`, `src/styles/qqq-theme.css`, `src/lib/theme/theme-provider.tsx`, `src/lib/theme/tokens.ts`
### Strengths
- 60+ CSS custom property tokens for colors, spacing, shadows
- Complete dark mode override section
- `:focus-visible` with proper outline and offset
- Muted text color darkened to meet WCAG AA contrast
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-T-1 | **HIGH** | **No `prefers-reduced-motion` override.** All animations (skeleton pulse, slide-in transitions, toast fades) run unconditionally. See D-L-2 above — one global CSS rule resolves this. |
| D-T-2 | **HIGH** | **No `prefers-contrast: more` support.** Users with OS high-contrast mode enabled get no enhanced styling. Add a `@media (prefers-contrast: more)` block that increases border weights and ensures 7:1 contrast ratios. |
| D-T-3 | **MED** | **Dual theming systems create token confusion.** `--qqq-primary-color` and `--color-primary` both define the primary color. Components reference both inconsistently. Consolidate to one system (Tailwind v4 `--color-*` preferred) and alias the `--qqq-*` tokens to the canonical values for backwards compatibility. |
| D-T-4 | **MED** | **Z-index tokens defined but not used consistently.** `--qqq-z-sidebar: 100`, `--qqq-z-modal: 1000` exist in the token file but some components use hardcoded values. Use the tokens everywhere. |
| D-T-5 | **LOW** | **Focus ring may be invisible in dark mode.** The `color-mix`-based focus outline color may not contrast sufficiently on dark backgrounds. Test with browser dark mode enabled. |
| D-T-6 | **LOW** | **No disabled-state semantic token.** All disabled states use `opacity-50`. A dedicated `--color-disabled` token would allow more nuanced disabled styling (e.g., different for text vs background). |
---
## 9. Responsive / Mobile
### Strengths
- Sidebar correctly switches between desktop rail and mobile drawer at `md`
- RecordQuery shows bottom-sheet filter panel on mobile
- RecordCardView provides card layout alternative to data grid on mobile
- Pagination uses `flex-wrap` to reflow on narrow screens
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-R-1 | **HIGH** | **Mobile `<input>` elements trigger browser zoom.** Inputs with `font-size < 16px` cause iOS Safari to auto-zoom on focus, breaking the layout. Ensure all form inputs have `text-base` (16px) as minimum font size. |
| D-R-2 | **MED** | **Touch targets below 44px in multiple places.** Pagination size selector, density buttons, column config toggles. Audit all interactive elements and ensure `min-h-[44px]` or adequate padding. |
| D-R-3 | **MED** | **No 320px (iPhone SE) layout testing.** Several components assume ≥375px. Run a responsive check at 320px and fix any overflows. |
| D-R-4 | **LOW** | **Landscape tablet layout untested.** At ~768px landscape some components may show desktop layout with insufficient room. Test iPad Mini landscape. |
---
## Priority Matrix
### Must Fix (WCAG 2.1 AA / CRIT / HIGH)
| ID | Area | Fix |
|----|------|-----|
| D-L-1 | Layout | Skip-to-content link |
| D-L-2 / D-T-1 | Layout / Theme | `prefers-reduced-motion` CSS rule |
| D-T-2 | Theme | `prefers-contrast: more` support |
| D-Q-1 | Record Query | `aria-sort` on DataGrid column headers |
| D-Q-2 | Record Query | Row checkbox `aria-label` |
| D-Q-3 | Record Query | Keyboard-accessible column resize |
| D-V-1 | Record View | Permission-gated Edit/Delete buttons |
| D-F-1 | Forms | Disable submit button while submitting |
| D-P-1 | Process | Auto-focus step heading on step change |
| D-W-1 | Widgets | Screen-reader text for trend arrows |
| D-R-1 | Responsive | 16px minimum font size on inputs |
### Should Fix (MED — noticeable UX gaps)
D-L-3 (mobile search), D-L-4 (banner persistence), D-L-5 (sidebar aria-expanded), D-Q-4 (FilterBuilder spinner), D-Q-5 (pagination range feedback), D-Q-6 (aria-live for fetch), D-V-2 (associated records pagination), D-V-4 (delete confirmation), D-F-2 (number field min/max hint), D-F-3 (DateTime TZ indicator), D-F-4 (mobile tooltip → inline help), D-P-2 (step back navigation), D-P-3 (process timeout message), D-P-4 (validation review filter), D-P-5 (upload progress bar), D-W-2 (widget error boundary), D-W-3 (chart legend mobile), D-W-5 (widget refresh button), D-FB-1 (keyboard shortcuts discoverability), D-FB-2 (toast close button), D-T-3 (dual theming consolidation), D-T-4 (z-index token usage), D-R-2 (touch targets), D-R-3 (320px layout)
### Nice to Have (LOW — polish)
D-L-6 (breadcrumb truncation), D-L-7 (sidebar skeleton), D-Q-8 (frozen columns), D-V-5 (copy record ID), D-V-6 (audit trail), D-F-6 (edit diff), D-F-7 (collapsible form sections), D-P-7 (retry step), D-P-8 (save bulk load mapping), D-W-6 (chart color tokens), D-FB-4 (search result context), D-T-5 (dark mode focus ring), D-T-6 (disabled token), D-R-4 (landscape tablet)
---
## What's Working Well
The following areas are solid and don't require significant rework:
- **Metadata-driven rendering** — no hardcoded table/field names anywhere
- **API layer** — all calls through typed `src/lib/api/` functions, consistent error handling
- **Form accessibility baseline** — `aria-required`, `aria-invalid`, `aria-describedby` correctly wired
- **Unsaved changes guard** — `beforeunload` + confirm modal works correctly
- **TanStack Query** — caching, deduplication, and revalidation correctly configured
- **Dark mode** — complete token coverage, no visual regressions found
- **Skeleton loading** — components have `role="status"` and `aria-busy="true"`
- **DOMPurify** — applied consistently on all user-controlled HTML output
- **URL state** — filters, pagination, and sort are shareable via query params
+194
View File
@@ -0,0 +1,194 @@
<!--
Copyright 2026 QRun.IO, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
# QQQ Frontend Next — Documentation Review
**Date:** 2026-03-01
**Scope:** Full `src/` + `docs/` + project root
**Commit:** 12edc4c
---
## Overall Score: 7.5 / 10
**Strengths:** JSDoc coverage is high across the API client, utilities, and most hooks. Type definitions are well-documented. `CLAUDE.md` and the implementation plans give a strong architectural overview.
**Key gaps:** `README.md` is essentially empty, `useRecordQuery` has no return-type documentation, and there are no architecture guides for state management or component composition patterns — the two things a new contributor needs most.
---
## CRITICAL — Must Address
### DOC-CRIT-1: README.md is empty
**File:** `README.md`
**Issue:** The file contains only a title. No setup instructions, command list, project overview, or links to docs.
**Impact:** A new developer cannot start contributing without reading `CLAUDE.md` and inferring the rest.
**Fix:** Add the following sections at minimum:
- What this project is (2–3 sentences)
- Prerequisites (Node.js version, pnpm)
- Quick start (`git clone` → `pnpm install` → `pnpm dev`)
- Available commands (dev, build, test, test:e2e, lint, tsc)
- Pointer to `CLAUDE.md` for architecture and conventions
- Pointer to `docs/` for implementation plans
---
### DOC-CRIT-2: `useRecordQuery` return type is undocumented
**File:** `src/lib/hooks/use-record-query.ts` (~line 60, main hook export)
**Issue:** The hook returns a large composite object (pagination, filter, selection, sort, query state, plus delegated `columns` and `views` namespaces from sub-hooks). None of the return properties are documented. Only two components consume this hook today, but that will grow.
**Impact:** Callers must trace through 685 lines of source to discover which properties exist.
**Fix:** Add a `@returns` block enumerating the top-level namespaces and key properties, or define an explicit `UseRecordQueryResult` interface with JSDoc on each field.
---
### DOC-CRIT-3: No state-management decision guide
**Issue:** The project uses five different state containers (React Context, TanStack Query, React Hook Form, URL search params, localStorage), each for different purposes. There is a brief table in `CLAUDE.md` but no guidance on the decision criteria.
**Impact:** New contributors pick the wrong state container; inconsistent patterns accumulate over time.
**Fix:** Create `docs/STATE-MANAGEMENT.md` covering:
- TanStack Query: all server state (records, metadata, widgets)
- React Hook Form: form field state only
- URL search params: filters, pagination, sort (shareable links)
- localStorage: user preferences, column config, saved views
- QContext: application-level UI state (modals, toast queue)
- When NOT to use useState for server-derived state
---
## SIGNIFICANT — Should Address
### DOC-SIG-1: No component composition patterns guide
**Issue:** The codebase uses several non-obvious patterns not documented anywhere outside of scattered code comments:
- Metadata-driven rendering (metadata always flows down; children never fetch)
- `data-qqq-id` naming convention (`{component-type}-{identifier}`)
- Async combobox pattern (`useAsyncCombobox` wrapping)
- Field adornment priority chain (documented in `FieldValue.tsx` but not shared)
**Fix:** Create `docs/COMPONENT-PATTERNS.md` with each pattern described, the rationale, and a code example.
---
### DOC-SIG-2: No type glossary
**Issue:** The `src/types/` directory exports ~40 interfaces and type aliases. There is no index or glossary. Finding the right type requires `grep`-ing.
**Impact:** Types are duplicated or approximated when developers can't find the canonical definition.
**Fix:** Create `docs/TYPE-GLOSSARY.md` with a table: Type | File | Purpose | Example usage. Focus on the ~15 most-used types (`QInstance`, `QTableMetaData`, `QFieldMetaData`, `SavedView`, `QProcessMetaData`, `StatTile`, `FilterModel`, etc.).
---
### DOC-SIG-3: API error handling patterns undocumented
**File:** `src/lib/api/client.ts` (referenced)
**Issue:** The 401 interceptor is documented, but there is no guide to how callers should handle 400 (validation), 403 (permission), 404 (not found), and 500 (server error) responses. Components handle these inconsistently today.
**Fix:** Add a section to the API client file header or create `docs/API-ERROR-HANDLING.md` with the standard response patterns per HTTP status code.
---
### DOC-SIG-4: Zod schema generation lacks examples
**File:** `src/lib/utils/zod-from-metadata.ts`
**Issue:** The function and helpers are well-documented, but there is no `@example` showing how a field metadata object maps to a generated Zod schema. This is the most opaque part of the form validation pipeline.
**Fix:** Add a 6-line `@example` to `zodFieldFromMetadata` showing an input `QFieldMetaData` object and the corresponding schema.
---
### DOC-SIG-5: Form validation strategy not centralized
**Issue:** Zod schema generation is in `zod-from-metadata.ts`, field-level validation rules are in individual field components, and server-side validation errors are mapped in `EntityForm.tsx`. No single document explains the end-to-end validation strategy.
**Fix:** Create `docs/FORM-VALIDATION.md` covering schema generation, custom refinements, and mapping server errors to form fields.
---
### DOC-SIG-6: TanStack Query key factory undocumented
**File:** `src/lib/query-client.ts`
**Issue:** The query key factory pattern is used throughout the codebase but the naming convention and invalidation patterns are not documented. Developers risk creating overlapping keys.
**Fix:** Document the key structure and add `@example` tags showing how to invalidate a table's record list after a mutation.
---
## MINOR — Nice to Have
### DOC-MIN-1: Test file headers are sparse
Many test files (`*.test.tsx`) have no JSDoc file header explaining what behavior is under test and what is explicitly out of scope. This makes it harder to assess test coverage gaps at a glance.
**Files:** Most files in `src/components/**/*.test.tsx`, `src/lib/hooks/*.test.ts`
---
### DOC-MIN-2: Performance decisions lack inline rationale
Files like `use-record-query.ts` have several `useMemo` / `useCallback` / `useRef` optimizations with comments referencing ticket numbers but no explanation of the underlying performance problem being solved.
---
### DOC-MIN-3: Accessibility decisions not consolidated
WCAG compliance work is scattered across code comments and the design review document. A single `docs/ACCESSIBILITY.md` checklist would make it easy to see what has been done and what remains.
---
### DOC-MIN-4: Widget creation guide missing
The widget rendering pipeline (`WidgetBlock` → `WidgetRenderer` → specific widget component) is non-obvious. A short guide in `docs/` or a code comment in `WidgetRenderer` explaining how to add a new widget type would help.
---
## Already Excellent
| Area | Quality | Notes |
|------|---------|-------|
| API client (`src/lib/api/`) | 9/10 | 100% JSDoc; error conditions noted; param types clear |
| Utilities (`src/lib/utils/`) | 8.5/10 | `filter-utils`, `process-utils`, `zod-from-metadata` all thorough |
| Sub-hooks (`use-column-config`, `use-saved-views`, `use-async-combobox`) | 9/10 | Return shapes documented; edge cases noted |
| Type definitions (`src/types/metadata.ts`) | 8.5/10 | All major interfaces have field-level JSDoc |
| Core query components (`DataGrid`, `FilterBuilder`, `Pagination`, `ColumnConfig`) | 8/10 | Props and behavior well-described |
| Records components (`FieldValue`, `RecordView`, `EntityForm`) | 7.5/10 | Good headers; adornment priority documented |
| `CLAUDE.md` | 9/10 | Architecture, conventions, file structure all clear |
| Implementation plans (`docs/implementation-plans/`) | 8/10 | Phased clearly; shared context well-organized |
---
## Recommended Work Order
### Phase 1 — Unblock contributors (half day)
1. **DOC-CRIT-1** — Expand README.md
2. **DOC-CRIT-2** — Document `useRecordQuery` return type
### Phase 2 — Improve onboarding (1 day)
3. **DOC-CRIT-3** — Create `docs/STATE-MANAGEMENT.md`
4. **DOC-SIG-1** — Create `docs/COMPONENT-PATTERNS.md`
5. **DOC-SIG-2** — Create `docs/TYPE-GLOSSARY.md`
### Phase 3 — Fill API/validation gaps (half day)
6. **DOC-SIG-3** — API error handling guide
7. **DOC-SIG-4** — Add `@example` to `zodFieldFromMetadata`
8. **DOC-SIG-5** — Create `docs/FORM-VALIDATION.md`
9. **DOC-SIG-6** — Document query key factory
### Phase 4 — Polish (as time allows)
10–13. **DOC-MIN-1 through DOC-MIN-4** — Test headers, perf rationale, a11y checklist, widget guide
+333
View File
@@ -0,0 +1,333 @@
# Form Validation
This guide covers how forms are validated in QQQ Frontend Next. Validation is schema-driven: Zod schemas are generated automatically from the same backend field metadata that drives rendering, so there is no need to maintain a separate validation definition alongside the form.
---
## Overview
The validation stack is:
- **Zod** — runtime schema definition and validation.
- **React Hook Form** + `@hookform/resolvers/zod` — drives form state; the Zod schema is passed as the resolver.
- **`zodFieldFromMetadata`** — the core function that maps a single `QFieldMetaData` to a Zod validator.
- **`zodSchemaFromTableMetadata`** — builds a full `z.ZodObject` from all editable fields in a table.
- **`zodSchemaFromFields`** — builds a `z.ZodObject` from a flat array of fields (used for process steps).
The source for the schema generation utilities is `src/lib/utils/zod-from-metadata.ts`.
---
## Schema generation
### For table-based forms
`EntityForm` (`src/components/forms/EntityForm.tsx`) builds its schema on mount:
```tsx
const schema = useMemo(
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
[tableMetaData, fieldNamesToInclude]
)
const { register, control, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: mergedDefaults,
})
```
`zodSchemaFromTableMetadata` iterates the table's fields, skips hidden (`isHidden`) and non-editable (`!isEditable`) fields, and calls `zodFieldFromMetadata` for each remaining field:
```tsx
export function zodSchemaFromTableMetadata(
tableMetaData: QTableMetaData,
fieldNamesToInclude?: string[]
): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {}
for (const fieldName of fieldsToProcess) {
const field = tableMetaData.fields[fieldName]
if (!field) continue
if (field.isHidden) continue
if (!field.isEditable) continue
shape[fieldName] = zodFieldFromMetadata(field)
}
return z.object(shape)
}
```
When `fieldNamesToInclude` is provided, only those fields are included. This is useful for partial-edit dialogs or multi-step wizards where only a subset of fields are shown per step.
### For process step forms
Process steps receive an ordered array of fields rather than a table metadata object. Use `zodSchemaFromFields`:
```tsx
import { zodSchemaFromFields } from '@/lib/utils/zod-from-metadata'
const schema = zodSchemaFromFields(stepFields) // QFieldMetaData[]
```
Hidden fields are excluded automatically. The resulting schema shape is identical to the table-based variant.
### For single fields
Call `zodFieldFromMetadata` directly when you need the Zod type for a single field:
```tsx
import { zodFieldFromMetadata } from '@/lib/utils/zod-from-metadata'
const fieldSchema = zodFieldFromMetadata(tableMetaData.fields['email'])
```
---
## Field type to Zod type mapping
The table below shows exactly how each QQQ field type is converted. All mappings are implemented in `zodFieldFromMetadata`.
| QQQ field type | Zod type (required) | Zod type (optional) | Notes |
|---|---|---|---|
| `STRING` | `z.string().min(1, ...)` | `z.string().optional()` | `maxLength` adds `.max()` when set |
| `TEXT` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
| `HTML` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
| `PASSWORD` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
| `INTEGER` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Empty `<input type="number">` submits `""` |
| `LONG` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Same as INTEGER |
| `DECIMAL` | `z.coerce.number(...)` | `z.union([z.literal(''), z.coerce.number(...)]).optional()` | No `.int()` — allows fractional values |
| `BOOLEAN` | `z.boolean().optional()` | `z.boolean().optional()` | Always optional — checkboxes are never "required" |
| `DATE` | `z.string().min(1, ...)` | `z.string().optional()` | HTML date inputs submit ISO-8601 strings |
| `TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
| `DATE_TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
| `BLOB` | `z.union([z.instanceof(File), z.string()]).optional()` | same | Accepts File objects or existing URL strings |
### Why numbers use `z.literal('')` in the optional case
An `<input type="number">` submits an empty string (`""`) when left blank, not `null` or `undefined`. A plain `z.coerce.number()` would coerce `""` to `0`, silently making an empty field appear as zero. The `z.union([z.literal(''), schema])` pattern allows the empty string to pass through as-is so the form can distinguish "nothing entered" from "zero entered".
### Why dates stay as strings
Date and time values come from `<input type="date">` and `<input type="datetime-local">` as ISO-8601 strings (`"2026-03-01"`, `"2026-03-01T14:30"`). The schema validates them as strings. Any coercion to JavaScript `Date` objects — if required by the API — happens in the submit handler, not in the Zod schema.
---
## Required vs optional
The `isRequired` flag on `QFieldMetaData` is the single source of truth for whether a field is required:
- **`isRequired: true`** → `.min(1, "${label} is required")` for string types; plain `z.coerce.number()` for numeric types (empty string fails coercion).
- **`isRequired: false` or absent** → `.optional()` for string types; `z.union([z.literal(''), ...]).optional()` for numeric types.
All error messages use the human-readable `field.label` (not `field.name`), so they read naturally in the UI: `"First Name is required"`, not `"firstName is required"`.
---
## Number bounds
When a field's metadata declares `minValue` or `maxValue`, those bounds are applied to the Zod schema:
```tsx
function buildNumberSchema(isRequired, label, isInteger, minValue, maxValue) {
let schema = isInteger
? z.coerce.number().int(`${label} must be a whole number`)
: z.coerce.number({ message: `${label} must be a number` })
if (minValue !== undefined && minValue !== null) {
schema = schema.min(Number(minValue), `${label} must be at least ${minValue}`)
}
if (maxValue !== undefined && maxValue !== null) {
schema = schema.max(Number(maxValue), `${label} must be at most ${maxValue}`)
}
// ...
}
```
The bounds come directly from metadata — no hardcoded ranges exist in the frontend. `DynamicFormField` also forwards `minValue` and `maxValue` as HTML attributes (`min`, `max`) on `<NumberField>` so the browser provides native range feedback, but Zod is the authoritative validator on submit.
---
## String length constraints
`maxLength` from field metadata maps to Zod's `.max()`:
```tsx
function buildStringSchema(isRequired, label, maxLength) {
let schema = z.string()
if (isRequired) schema = schema.min(1, `${label} is required`)
if (maxLength) schema = schema.max(maxLength, `${label} must be at most ${maxLength} characters`)
return isRequired ? schema : schema.optional()
}
```
`DynamicFormField` also passes `maxLength` to `<TextField>` as an HTML `maxlength` attribute, providing in-browser character limiting, but the Zod schema validates the constraint on submit as well.
---
## Default values
`defaultValuesFromRecord` (`src/lib/utils/zod-from-metadata.ts`) builds the `defaultValues` object for `useForm`:
```tsx
export function defaultValuesFromRecord(
tableMetaData: QTableMetaData,
recordValues: Record<string, unknown>
): Record<string, unknown>
```
For each editable, visible field it applies this priority:
1. Use the record value (from the API response), with type coercion:
- `DATE`, `TIME`, `DATE_TIME` → `String(value)` (ISO-8601 string for the HTML input).
- `BOOLEAN` → `Boolean(value)`.
- All other types → value as-is.
2. Fall back to `field.defaultValue` from metadata.
3. Fall back to `false` for `BOOLEAN` fields.
4. Fall back to `''` (empty string) for everything else.
`EntityForm` calls this inside `useMemo` and passes the result to `useForm({ defaultValues })`. When the record changes (e.g., navigating between records), the form is reset via `reset(defaultValuesFromRecord(...))` in a `useEffect`.
---
## Client vs server validation
### Client-side (Zod)
Zod runs on the client when the user submits the form. React Hook Form calls the Zod resolver, collects all field errors, and populates `formState.errors`. Fields display their error messages inline — `DynamicFormField` renders a `<p role="alert">` with `id="${fieldId}-error"` below each failing input, and the input gains `aria-invalid="true"` and `aria-describedby` pointing at the error element.
```tsx
{fieldError && (
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{fieldError.message}
</p>
)}
```
### Server-side (API)
The backend can return validation errors after a save attempt. `EntityForm` handles this via TanStack Query mutation `onError` callbacks. Currently, API errors are shown as a top-level error banner rather than per-field:
```tsx
const insertMutation = useMutation({
mutationFn: (values) => insertRecord(tableMetaData.name, values),
onError: (err: Error) => {
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
},
})
```
When the API returns structured per-field errors (a map of `{ fieldName: errorMessage }`), map them to React Hook Form using `form.setError`:
```tsx
onError: (err) => {
const fieldErrors = parseApiFieldErrors(err)
if (fieldErrors) {
for (const [fieldName, message] of Object.entries(fieldErrors)) {
form.setError(fieldName, { type: 'server', message })
}
} else {
toast.error(err.message)
}
}
```
This makes server validation errors appear in the same inline error elements as client-side Zod errors, giving the user a consistent experience.
---
## DynamicFormField dispatch
`DynamicFormField` (`src/components/forms/DynamicFormField.tsx`) is the component that renders a single field inside a metadata-driven form. It dispatches to a typed input component based on field properties in this priority order:
1. **`field.possibleValueSourceName` is set** → `<PossibleValueSelect>` (async combobox).
2. **`FILE_UPLOAD` adornment or `BLOB` type** → `<FileUploadField>`.
3. **`field.type` switch** → one of `TextField`, `NumberField`, `BooleanField`, `DateField`, `DateTimeField`, `PasswordField`, or inline `<textarea>` (TEXT), `<RichTextField>` (HTML), `<input type="time">` (TIME).
4. **Default case** → `<TextField>` (treats unknown types as plain text).
Hidden fields (`isHidden: true`) and non-editable fields return `null` immediately.
### Dirty field indicator
When a field's value has changed from its default, `DynamicFormField` wraps the input in a left-border accent:
```tsx
function DirtyWrapper({ isDirty, children }) {
if (!isDirty) return <>{children}</>
return (
<div className="border-l-2 border-primary pl-2">
{children}
</div>
)
}
```
The `isDirty` prop comes from `formState.dirtyFields[fieldName]` in `EntityForm`, which passes the full `dirtyFields` map to `DynamicForm`, which passes each field's individual dirty state to `DynamicFormField`.
---
## Adding custom Zod refinements
The generated schemas cover all constraints that can be expressed in QQQ field metadata. When you need a validation rule that metadata cannot express (for example, cross-field validation or a format check), add a `.superRefine()` or `.refine()` on the schema returned by `zodSchemaFromTableMetadata`:
```tsx
// In the component that builds the form
const baseSchema = zodSchemaFromTableMetadata(tableMetaData)
const schema = baseSchema.superRefine((values, ctx) => {
// Cross-field: end date must be after start date
if (values.startDate && values.endDate && values.endDate < values.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'End date must be after start date',
})
}
})
```
Prefer `.superRefine()` over `.refine()` when you need to report the error on a specific field path rather than at the root of the object, so React Hook Form can display it inline next to the correct input.
---
## Complete example
### Field metadata (from the API)
```json
{
"name": "quantity",
"label": "Quantity",
"type": "INTEGER",
"isRequired": true,
"isEditable": true,
"isHidden": false,
"minValue": 1,
"maxValue": 9999
}
```
### Generated Zod schema for this field
`zodFieldFromMetadata` produces:
```ts
z.coerce.number()
.int("Quantity must be a whole number")
.min(1, "Quantity must be at least 1")
.max(9999, "Quantity must be at most 9999")
```
Because `isRequired` is `true`, this is the final schema (no `z.literal('')` union wrapping). An empty `<input type="number">` submits `""`, `z.coerce.number()` coerces that to `NaN`, and `NaN` fails the `.int()` check — so the required constraint is enforced implicitly.
### Full table schema (example)
```ts
const schema = zodSchemaFromTableMetadata(orderTableMetaData)
// Produces:
z.object({
customerId: z.string().min(1, 'Customer is required'), // STRING, required
quantity: z.coerce.number().int(...).min(1).max(9999), // INTEGER, required
notes: z.string().optional(), // TEXT, optional
shipDate: z.string().optional(), // DATE, optional
isRush: z.boolean().optional(), // BOOLEAN, always optional
})
```
+61
View File
@@ -0,0 +1,61 @@
# Next UI issue tracking
GitHub issues are the source of truth for scope, current status, remaining work and release decisions. Start at [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713), which links the release baseline, known failures, visual review, external-provider checks and existing feature tickets.
## Where work lives
| Area | Authoritative issue |
|---|---|
| Roadmap and complete issue index | [qqq#713](https://github.com/QRun-IO/qqq/issues/713) |
| Feature acceptance and default adoption | [qqq#649](https://github.com/QRun-IO/qqq/issues/649) |
| Behavior and metadata compatibility | [qqq#714](https://github.com/QRun-IO/qqq/issues/714) |
| Original Next visual review and owner approval | [qqq#711](https://github.com/QRun-IO/qqq/issues/711) |
| Browser failures and incomplete run evidence | [qqq#985](https://github.com/QRun-IO/qqq/issues/985) |
| Real application soak and final release hold | [qqq#712](https://github.com/QRun-IO/qqq/issues/712) |
| Cross-application launch/restart acceptance | [qqq#650](https://github.com/QRun-IO/qqq/issues/650) |
| ESB integration and acceptance | [qqq#986](https://github.com/QRun-IO/qqq/issues/986) |
| Paired runtime provenance and CI handoff | [qqq#905](https://github.com/QRun-IO/qqq/issues/905) |
Create **all Next UI tickets in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues)** and add them to the [QQQ Roadmap, Project 12](https://github.com/orgs/QRun-IO/projects/12). Use existing issues for the same requirement or failure, and link each focused ticket to #713 and its feature or investigation parent. Frontend issues #12 and #10 were transferred to central issues #985 and #986; use the central identifiers and preserve their report history.
## Required ticket fields
Every new ticket needs these fields on the issue and its Project 12 item; complete them before work advances to **Ready**:
| Field | Required value |
|---|---|
| Issue Type | Native issue: Bug, Feature, Task or Epic. Mirror it in the Project field, using Planning Epic for Epic; Improvement is a Project-only category. |
| Assignees | The person accountable for completing or coordinating the ticket |
| Milestone | The agreed delivery milestone; keep it consistent with the target version |
| Priority | Critical, High, Medium or Low, with an impact-based rationale in the issue |
| Component | The owning implementation component: `qqq-frontend-next` for Next source, or the actual backend/sample component for a dependency. |
| Target Version | The agreed version of the owning component or an explicit pending release decision. A backend dependency in the Next UI 1.0 milestone does not imply a backend 1.0 release. Never imply delivery in an already published RC. |
| Status | The current workflow state below |
Record unresolved ownership or release decisions explicitly while a ticket remains in **Backlog**. Do not invent dates, estimates or a new release version to fill a field. A browser assertion failure alone does not establish high product severity.
## Workflow and evidence
- **Backlog / Ready:** capture the expected behavior, scope, acceptance criteria, required fields and dependencies; Ready means actionable work with an owner.
- **In progress:** link the active branch or work and record reproduction, confirmed cause, implementation progress and remaining blockers.
- **In review:** link the reviewable PR, source commit and verification evidence; keep unmet acceptance criteria visible. A local fix or passing subset does not imply merge or release.
- **Done:** close only after the accepted fix is integrated and every acceptance criterion is verified, or an explicit approved disposition is recorded. Link the merged PR/commit and evidence. Identify whether the change is unreleased or delivered, with the version and release link when published.
## Updating an issue
- Put current status and remaining acceptance criteria at the top. Preserve the original report as history.
- Record expected/actual behavior, reproduction, environment, source commit, run and artifact evidence. State whether the cause is established.
- Separate implementation evidence, passing scoped tests, owner visual approval and final release approval. They are distinct requirements.
- Resolve all original acceptance criteria before closing, or document an explicit approved disposition. A later passing run alone does not explain an intermittent failure.
- Keep external-provider exclusions visible. Controlled provider fixtures do not prove a real Auth0 tenant, Google account or QuickSight dashboard.
- Update issue links after a fix or new finding. Repository plans, session notes, release notes and test/visual ledgers support the issues; they are not competing live backlogs.
## Release and design constraints
Preserve the original Next design at `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard is the behavior and metadata compatibility reference. Prioritize primary-browser functional depth and disclose secondary-browser failures.
[RC9](releases/1.0.0-RC.9.md) is the published prerelease for application testing. Its immutable-tag acceptance run has known Firefox/tablet failures; consult the release notes and #985 for the exact evidence. The browser fixes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are [unreleased](releases/unreleased.md).
Every release and changelog entry must link its central QQQ issues and distinguish product changes, test/infrastructure corrections, verification and unresolved work. A published RC's tag and artifacts remain immutable; subsequent fixes require a separately authorized version and release. Update tickets and Project 12 with the actual delivery version and release link after publication.
Final 1.0 remains held for the owner's requested application soak and renewed, explicit final approval ([#712](https://github.com/QRun-IO/qqq/issues/712)). Passing CI, an issue marked Done or elapsed time does not release that hold. This project does not authorize releasing QQQ 4.1.
+28
View File
@@ -0,0 +1,28 @@
# PLAN: Next UI complete feature acceptance and default adoption (QRun-IO/qqq#649)
## Goal
Make Next the fully tested, working default QQQ admin UI: every inventoried supported
feature has executable acceptance coverage that passes against the real sample backend and
a production Next build, and every documented default launch path starts Next.
## Approach
1. Inventory UI-facing features from source (sample metadata, backend enums/routes, Material
behavior) into `docs/acceptance/feature-matrix.md`; each row names its fixture, scenarios,
test IDs, result and issue links.
2. Add a Playwright acceptance project (`tests/acceptance/`) that boots the QRun-owned sample
JAR on an ephemeral loopback port and serves `next build` output with `next start`, using
same-origin forwarding. Tests carry matrix IDs; `scripts/acceptance-gate.mjs` fails when a
required row has no passing test or a test is skipped/flaky.
3. Fix demonstrated defects with regression tests (#550, #645, #538, #539, #541, others found),
filing or reusing a QQQ issue for each.
4. Update default entry points (quickstart, sample README, root README, website, wiki,
starter/template) while keeping Material selectable.
5. Run all gates, publish versioned artifacts through normal workflows, record evidence.
## Branches
- `qqq-frontend-next`: `feature/GH-649-next-acceptance` (from `main`)
- `qqq`: worktree `../qqq-649`, branch `feature/GH-649-next-default` (from `origin/develop`)
## Open questions (for James)
- Commits, pushes, PRs, image/tag publication and dependency changes need explicit approval
per repository rules; work proceeds locally until then.
+22
View File
@@ -0,0 +1,22 @@
# Plan: API playground contrast (#724)
## Reproduction and scope
Fresh, fingerprinted desktop captures preserve the original Next composition. RapiDoc's Expand all text in both the original design and RC6 renders RGB(29, 78, 216) on RGB(42, 43, 44), a contrast ratio of 2.12:1 at 16px normal weight. This inherited defect fails the 4.5:1 requirement. Browser regression checks also found light-mode contact-link contrast of 2.36:1 and authentication-status contrast of 3.00:1.
## Implementation and verification
- [x] Retain failing browser contrast checks inside RapiDoc's shadow DOM.
- [x] Apply existing dashboard primary-text and error tokens at the component boundary. Pair dark accent backgrounds with inverse button text. Preserve layout and dependencies.
- [x] Verify normal and hovered controls in light/dark Chromium desktop and mobile, plus live Preferences theme switching.
- [x] Run types, lint, production export, all 1,901 unit tests and existing bundle budgets.
- [x] Directly inspect four fresh screenshots after color transitions settle.
- [x] Independent review approved with no remaining findings; prepare signed isolated commit.
Evidence: `test-results/visual-review/api-playground-contrast/` in the original checkout. Final focused report: four passed, zero failures, skips, flaky outcomes or report errors. These are targeted checks, not a complete acceptance gate. Initial toggle verification incorrectly asserted an HTML attribute: React 19 sets RapiDoc's non-reflected Lit property. A subsequent audit sampled active color transitions. Both runs are retained; the corrected test awaits the property, Lit render and actual shadow-root animations without fixed sleeps.
## Remaining limits
This proves the fixture's visible playground content, hover states and theme switching, not all expanded endpoint/authentication states or arbitrary custom palettes. The existing narrow-screen inner horizontal clipping remains open; this color correction does not redesign the playground.
This branch is based on RC6 source `7ddf45f` and excludes the separate TIME fix `276b65b`. Neither follow-up is part of the immutable RC6 tag. Final 1.0 remains on hold.
+31
View File
@@ -0,0 +1,31 @@
# PLAN: Next UI 1.0 query filter parity
## Goal
Complete the supported Material Dashboard query-filter workflows in QRun-IO/qqq#715 and #718, and prove them with real-backend acceptance rows.
## Approach
Build on the preserved wave 2 worktree. Keep one `QQueryFilter` as the source of truth for basic and advanced modes, with the existing `useRecordQuery` state and saved-view serialization. Share the existing metadata-driven operator and value controls between quick-filter chips and the advanced builder. Use the v1 metadata settings from backend PR #787 for weekday options and help.
## Files affected
- `src/components/query/RecordQuery.tsx`, `RecordQueryToolbar.tsx`: mode switch, quick filters, sort, clear confirmation, advanced preview.
- `src/components/query/QuickFilterBar.tsx`: basic-mode chips, field picker, operator/value editor and accessible clear/remove actions.
- `src/components/query/FilterBuilder.tsx` and its extracted helpers: complete advanced operator and value behavior from #718.
- `src/lib/utils/quick-filter-utils.ts`, `filter-utils.ts`, `saved-view-utils.ts`, `src/lib/hooks/use-record-query.ts`: metadata defaults, criteria edits, mode reconciliation, persistence and view round-trips.
- `tests/acceptance/matrix/query.json` and `tests/acceptance/specs/query/`: real-backend evidence for every applicable #715/#718 behavior.
## Steps
1. [x] Add meaningful unit tests for quick-filter defaults, complex-filter rejection, edits, weekday operators, date expressions and saved-view round-trips; finish any missing utility behavior.
2. [x] Wire instance filter settings and add the basic/advanced switch with a reason tooltip when basic cannot represent the current filter.
3. [x] Build and test basic quick-filter chips, add-field menu, operator/value editing, clear/remove behavior and advanced preview.
4. [x] Add sort picker, clear-all confirmation that preserves sort, and per-row validation; cover keyboard and touch behavior.
5. [ ] Add real-backend acceptance rows and tests, then run typecheck, lint, unit, static-export budget and the five-project browser gate after integration.
## Constraints
- Render field labels from metadata and preserve readable join and virtual-field behavior.
- Keep the filters and saved views interoperable with Material Dashboard JSON.
- Preserve unrelated wave 2 worktrees. Do not release QQQ 4.1.
+11
View File
@@ -0,0 +1,11 @@
# Script completion compatibility
Goal: close the autocomplete gap in the parity ledger while retaining the original Next textarea editor and its keyboard accessibility. Authorized by the active Next UI 1.0 completion goal.
- Preserve syntax overlay, split panes, per-file language, undo, read-only behavior and Tab escape.
- Opt script editors into a caret-adjacent suggestion list: QQQ API/logger methods, current-file identifiers, and highlighter language keywords. No new dependency or backend contract.
- Typing a prefix or Ctrl+Space opens suggestions; arrows select, Enter/Tab insert, Escape dismisses; pointer selection retains textarea focus. Composition input must not be intercepted.
- Replace the current token at the caret, preserve surrounding text, and avoid duplicating an existing call parenthesis.
- Verify unit regressions, real-backend script edit/test/save/undo behavior, focus/keyboard and visual states. Update guides and ledger only to the extent proven.
Verified: baseline Chromium 95847c2 passed 556 tests; backend PR #913 merged. Autocomplete passes 1,840 unit tests, types, affected lint, production export and bundle budgets; 15 Chromium developer/script workflows and 10 focused cases across all five profiles. Both review findings fixed after failing regressions. A native textarea probe explained WebKit undo grouping; completion now has a loaded-revision undo/redo test without changing native history behavior. Light/dark screenshots inspected; whole-family visual review remains open.
+17
View File
@@ -0,0 +1,17 @@
# Sidebar icon label assertion (#728)
## Evidence
RC6 candidate WebKit failed NAV-002 because raw link text included decorative legacy icon codepoints. The same assertion was reproduced against the public RC6 JAR after explicitly awaiting the fixture's legacy glyph. This is a test assertion defect: accessible link names should match metadata labels, while decorative SVG text remains hidden from assistive technology.
## Correction
Await the legacy glyph, then assert exact link count and each accessible label in backend order. Check that rendered legacy SVGs remain `aria-hidden`. Retain nested-app, parent expansion and child label assertions. No product code or release artifact changes.
## Verification
Retain red and focused Chromium/WebKit/mobile reports under `test-results/visual-review/sidebar-icon-labels/` in the original checkout. The focused check exercises the actual public RC6 JAR with backend `e0d57dd`. Targeted ESLint and diff checks are required; independent review precedes integration. Original CI failure remains recorded, not erased by a later focused pass.
Focused result: three passed, zero failures, skips, flaky outcomes or report errors. Targeted ESLint and diff checks passed.
Independent review approved with no findings. This change verifies navigation semantics; glyph appearance remains covered by the separate legacy-icon workflows.
+10
View File
@@ -0,0 +1,10 @@
# Plan: TIME block seconds compatibility (#728)
Preserve Next layout while making standalone and process TIME inputs accept and submit seconds, matching the shared form editor and QQQ TIME values.
1. Reproduce native step validation failure with standalone and process tests; retain a regular form control.
2. Apply the existing one-second input step to both plain block paths.
3. Verify edited/seeded values through actual Chromium workflows, including backend process readback; run affected tests and relevant quality gates.
4. Keep this follow-up separate from the unchanged RC6 candidate while its hosted CI runs.
Verification: two focused component regressions failed before the fix (regular form control passed); three real-backend Chromium scenarios failed at native step assertions. After the two-line correction, all 66 affected component tests and eight desktop/mobile backend workflows passed, including independent SQL checks. Full unit suite, type/lint, production build and budgets passed. This is isolated follow-up work, not part of RC6 candidate `7ddf45f`.
+332
View File
@@ -0,0 +1,332 @@
# State Management Guide
This document explains when to use each state container in the QQQ Frontend Next codebase. Each container has a specific purpose — choosing the wrong one leads to stale data, lost state on navigation, or unnecessary complexity.
## Decision Matrix
| If the state is… | Use… |
|---|---|
| Data fetched from the server (records, metadata, widgets, processes) | TanStack Query |
| Form field values and validation errors | React Hook Form |
| Filters, pagination, sort order — things a user should be able to bookmark or share | URL search params |
| User preferences that survive a session (density, column widths, saved views) | `useLocalStorage` |
| Application-wide UI state (modal stack, page header, accent color, branding) | QContext |
| Ephemeral UI state local to one component (dropdown open, hover, toggle) | `useState` / `useReducer` |
---
## TanStack Query — Server State
**Rule:** Never use `useState` for data that comes from the server. TanStack Query owns all server state.
The query client is configured in `src/lib/query-client.ts` with:
- 5-minute stale time — metadata and record lists stay fresh without excessive refetching
- 10-minute GC time — recently visited pages are snappy when navigating back
- Smart retry: never retries 4xx errors; retries 5xx and network failures up to 3 times with exponential back-off
- Global `QueryCache.onError` and `MutationCache.onError` — most errors are automatically toasted without per-call handling
### Query key factory
All query keys are defined in `queryKeys` in `src/lib/query-client.ts`. Always use the factory, never inline string arrays.
```typescript
import { queryKeys } from '@/lib/query-client'
// Fetch a single record
useQuery({
queryKey: queryKeys.tableRecord('order', recordId),
queryFn: () => getRecord('order', recordId),
})
// Invalidate all records for a table after a mutation
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords('order') })
// Invalidate everything (e.g. after logout)
queryClient.invalidateQueries({ queryKey: queryKeys.all() })
```
### Key hierarchy (broadest → narrowest)
```
['qqq']
['qqq', 'metadata', 'all']
['qqq', 'metadata', 'table', tableName]
['qqq', 'records', tableName]
['qqq', 'records', tableName, recordId]
['qqq', 'records', tableName, 'count', filterHash]
['qqq', 'processes', processName, processUUID, 'status', jobUUID]
['qqq', 'widgets', widgetName, params]
['qqq', 'possibleValues', 'table', tableName, fieldName, searchTerm]
['qqq', 'search', searchTerm]
```
Broad invalidations cascade down. For example, `queryKeys.tableRecords('order')` invalidates both the list query and all individual record queries for that table.
### What belongs in TanStack Query
- Record lists and counts
- Single record fetches
- All metadata (tables, processes, apps)
- Widget data
- Process status polling
- Possible values / enum lookups
- Global search results
- Auth session metadata
### What does NOT belong
Do not use TanStack Query for UI state (modal open/close, form field values, user preferences). Those belong in the containers described below.
---
## React Hook Form — Form Field State
**Rule:** React Hook Form owns field values and validation state for forms only. Do not use it for non-form UI, and do not reach into the form state from outside the form.
Forms are built with Zod schemas derived from field metadata. The schema is constructed by `zodFromMetadata()` in `src/lib/utils/zod-from-metadata.ts`.
```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { zodFromMetadata } from '@/lib/utils/zod-from-metadata'
function EntityForm({ tableMetaData, record }: Props) {
const schema = zodFromMetadata(tableMetaData)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: record ?? {},
})
// Map a 400 validation error back to individual fields
const onSubmit = async (data: FieldValues) => {
try {
await saveRecord('order', data)
} catch (error) {
if (getErrorStatusCode(error) === 400) {
const fieldErrors = extractFieldErrors(error)
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field, { message })
}
}
}
}
}
```
### What belongs in React Hook Form
- All form field values
- Validation errors (field-level and form-level)
- Dirty/touched state
- Submit handling
### What does NOT belong
Do not use React Hook Form for filter criteria in the query page — those live in URL params and reducer state managed by `useRecordQuery`.
---
## URL Search Params — Shareable State
**Rule:** Any state a user should be able to bookmark, share, or restore after a page refresh goes in the URL.
Use Next.js `useSearchParams` and `useRouter` to read and write URL state. This is the right home for:
- Active filters and quick search term
- Current page and page size
- Sort column and direction
The `useRecordQuery` hook syncs filter/pagination state to and from the URL automatically. Components should read these values from the hook, not from `useSearchParams` directly.
```typescript
import { useSearchParams, useRouter } from 'next/navigation'
// Reading
const searchParams = useSearchParams()
const page = Number(searchParams.get('page') ?? '1')
const sort = searchParams.get('sort') ?? ''
// Writing — replace so the back button doesn't accumulate filter changes
const router = useRouter()
const params = new URLSearchParams(searchParams)
params.set('page', String(nextPage))
router.replace(`?${params.toString()}`)
```
### Key naming conventions in URL params
| Param | Values | Purpose |
|---|---|---|
| `page` | integer | Current page (1-based) |
| `pageSize` | 10, 25, 50, 100 | Rows per page |
| `sort` | `fieldName:asc` or `fieldName:desc` | Active sort |
| `q` | string | Quick search term |
| `filter` | base64-encoded JSON | Serialized filter criteria |
### What belongs in URL params
- Filters, quick search
- Pagination (page, page size)
- Sort order
- Any state that makes a link shareable
### What does NOT belong
Do not put ephemeral UI state (modal open, panel open) in the URL. Do not put server data in the URL.
---
## localStorage — User Preferences
**Rule:** Preferences that should survive across sessions and browser restarts go in localStorage via the `useLocalStorage` hook.
The hook lives at `src/lib/hooks/use-local-storage.ts`. It is SSR-safe (returns `initialValue` on the server), cross-tab synced (listens to `storage` events), and has a stable setter that accepts a value or an updater function matching the `useState` API.
```typescript
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
const [density, setDensity, removeDensity] = useLocalStorage<'compact' | 'standard' | 'comfortable'>(
'qqq-order-density',
'standard'
)
```
### Higher-level hooks built on `useLocalStorage`
| Hook | Key | Contents |
|---|---|---|
| `useUserPreferences` | `qqq-user-preferences` | Default page size, density, view mode |
| `useColumnConfig` | `qqq-{tableName}-columns`, `qqq-{tableName}-column-order`, `qqq-{tableName}-column-widths` | Column visibility, order, pixel widths |
| `useSavedViews` | `qqq-{tableName}-saved-views` | Named filter snapshots |
### Key naming convention
```
qqq-{tableName}-{purpose} # table-scoped
qqq-user-preferences # global user preferences
qqq:{scope}:{purpose} # alternative colon-separated form
```
Always prefix with `qqq-` to avoid collisions with other apps that might share the same origin.
### What belongs in localStorage
- Table density (compact / standard / comfortable)
- Column visibility and order per table
- Column pixel widths per table
- Saved views (named filter snapshots)
- Global user preferences (default page size, default view mode)
- Dismissed banners or one-time notices
### What does NOT belong
Do not store server data in localStorage — use TanStack Query's cache. Do not store state that should be shareable — use URL params.
---
## QContext — Application-Wide UI State
**Rule:** Use QContext for state that must be accessible anywhere in the authenticated app tree and does not fit a more specific container.
The provider is `QContextProvider` in `src/lib/context/q-context.tsx`. Access it with the `useQContext()` hook. The hook throws at runtime if called outside a provider, which catches integration mistakes early.
```typescript
import { useQContext } from '@/lib/context/q-context'
function RecordQueryPage({ tableMetaData }: Props) {
const { setPageHeader, setTableMetaData } = useQContext()
useEffect(() => {
setPageHeader(tableMetaData.label)
setTableMetaData(tableMetaData)
}, [tableMetaData])
}
```
### What QContext contains
| Field | Type | Purpose |
|---|---|---|
| `pageHeader` | `string \| ReactNode` | Content rendered in the page header area |
| `accentColor` / `accentColorLight` | `string` | Theme accent colors (hex) |
| `tableMetaData` | `QTableMetaData \| null` | Metadata for the currently viewed table |
| `tableProcesses` | `QProcessMetaData[] \| null` | Processes for the current table |
| `dotMenuOpen` | `boolean` | Whether the ellipsis action menu is open |
| `modalStack` | `string[]` | Ordered list of open modal identifiers for keyboard nav |
| `pushModalOnStack(id)` | function | Called when a modal opens |
| `popModalOffStack(id)` | function | Called when a modal closes; warns if id is not on top |
| `clearModalStack()` | function | Clears all modal identifiers (e.g. on navigation) |
| `keyboardHelpOpen` | `boolean` | Keyboard shortcut overlay visibility |
| `userId` | `string \| undefined` | Current authenticated user identifier |
| `branding` | `QBrandingMetaData \| null` | Company name, app name, URLs from backend |
| `pathToLabelMap` | `Record<string, string>` | URL path segment → human-readable breadcrumb label |
### Modal stack usage
The modal stack ensures that `Escape` closes only the top-most modal and that focus is restored correctly. Every modal or sheet component must push on open and pop on close.
```typescript
const { pushModalOnStack, popModalOffStack, modalStack } = useQContext()
const modalId = 'edit-record-modal'
// On open
pushModalOnStack(modalId)
// On close
popModalOffStack(modalId)
// Check if this modal is on top (for Escape handling)
const isTopModal = modalStack[modalStack.length - 1] === modalId
```
### What belongs in QContext
- Application-wide UI state that has no more specific home
- Page header content (set by each page on mount)
- Modal stack (for coordinated keyboard navigation)
- Accent colors and branding
- The currently active table metadata (consumed by header, breadcrumbs, action menus)
### What does NOT belong
Do not put server data in QContext (use TanStack Query). Do not put form state in QContext (use React Hook Form). Do not put user preferences in QContext (use localStorage).
---
## useState / useReducer — Local Transient State
**Rule:** Use `useState` or `useReducer` only for state that is local to a single component or a small subtree, ephemeral (lost on unmount is acceptable), and neither server data nor shareable state.
```typescript
// Good — ephemeral open/close that doesn't need to survive navigation
const [dropdownOpen, setDropdownOpen] = useState(false)
const [isHovered, setIsHovered] = useState(false)
// Good — complex multi-field local state with clear action types
const [state, dispatch] = useReducer(recordQueryReducer, initialState)
```
### What belongs in useState / useReducer
- Dialog, dropdown, tooltip open/close
- Hover and focus state
- Local form wizard step (if not shareable)
- Complex local state with many fields that transition together (handled by `useReducer`)
### What does NOT belong
- Server data (use TanStack Query)
- State that should survive navigation or page refresh (use URL params or localStorage)
- State needed by distant parts of the tree (use QContext or lift appropriately)
---
## Summary
```
Server data → TanStack Query (queryKeys factory, automatic toasts on error)
Form fields → React Hook Form + Zod
Shareable/bookmarkable → URL search params
User preferences → useLocalStorage (key: qqq-{tableName}-{purpose})
App-wide UI state → QContext (useQContext hook)
Local/ephemeral UI → useState / useReducer
```
+190
View File
@@ -0,0 +1,190 @@
# QQQ Frontend Type Glossary
All types listed here are re-exported from `@/types` (the `src/types/index.ts` barrel).
Application code should always import from `@/types`, not from individual module files,
so that internal type organisation can change without affecting import paths.
```typescript
import type { QInstance, QTableMetaData, QRecord, QQueryFilter } from '@/types'
```
---
## 1. App & Auth
| Type | Source file | Purpose |
|------|-------------|---------|
| `QInstance` | `metadata.ts` | Top-level descriptor returned by `/metaData/instance`; the single source of truth for all apps, tables, processes, reports, widgets, and branding. **Key fields:** `apps`, `tables`, `processes`, `widgets`, `branding`, `appTree`, `environmentValues` |
| `QAuthenticationMetaData` | `metadata.ts` | Describes the auth provider type (`AUTH_0`, `OAUTH2`, `TABLE_BASED`, `FULLY_ANONYMOUS`, `MOCK`), the provider-specific values (client ID, base URL, audience) needed to boot the correct auth flow, and the pre-sign-in `branding` (`QLoginBranding`) the login page shows. |
| `QBrandingMetaData` | `metadata.ts` | Controls visual identity: company name, app name, logo URL, accent color, notification banners, and custom CSS injected via `data-qqq-id` selectors. |
| `QThemeMetaData` | `metadata.ts` | Backend-supplied Tailwind CSS token overrides (`primaryColor`, `accentColor`, `mode`). Applied on top of the default theme by the theme provider. |
| `Banner` | `metadata.ts` | A single notification banner (text, severity `info|warning|error`, optional color, dismissible flag) rendered at the top of the dashboard layout. |
### QInstance example
```typescript
const instance: QInstance = {
apps: { crm: { name: 'crm', label: 'CRM', children: [], ... } },
appTree: [{ name: 'crm', label: 'CRM', type: 'APP', ... }],
tables: { person: { name: 'person', label: 'People', fields: {}, ... } },
processes: {},
reports: {},
widgets: {},
branding: { companyName: 'Acme', companyUrl: 'https://acme.com', appName: 'Admin' },
helpContents: {},
environmentValues: {},
}
```
---
## 2. Metadata
| Type | Source file | Purpose |
|------|-------------|---------|
| `QTableMetaData` | `metadata.ts` | Full descriptor for a QQQ table: fields, sections, join relationships, capabilities, and per-user CRUD permissions. Drives the entire record query, view, create, and edit experience. **Key fields:** `name`, `label`, `primaryKeyField`, `fields`, `sections`, `capabilities`, `readPermission`, `insertPermission`, `editPermission`, `deletePermission` |
| `QFieldMetaData` | `metadata.ts` | Descriptor for a single table column: data type, required/editable/hidden flags, max length, numeric bounds, possible-value source, display format, and adornments. **Key fields:** `name`, `label`, `type`, `isRequired`, `isEditable`, `isHidden`, `possibleValueSourceName`, `adornments` |
| `QProcessMetaData` | `metadata.ts` | Descriptor for a multi-step wizard process: identity, permissions, input record bounds, and the ordered list of `QFrontendStepMetaData` objects to render. |
| `QFrontendStepMetaData` | `metadata.ts` | A single step in a process wizard: label, ordered component list, and optional form/view/record-list field arrays used by the step renderer. |
| `QFrontendComponent` | `metadata.ts` | A single UI component descriptor within a process step — a `type` (from `QComponentType`) plus arbitrary `values` passed as props to the matching renderer. |
| `QAppMetaData` | `metadata.ts` | A top-level app grouping: sidebar navigation children, dashboard widget names, and labeled sections grouping tables, processes, and reports. |
| `QAppTreeNode` | `metadata.ts` | One node in the recursive sidebar navigation tree; the `type` field (`TABLE`, `PROCESS`, `REPORT`, `APP`) determines the route. |
| `QAppSection` | `metadata.ts` | A labeled card group on an app dashboard page, listing the table, process, and report names belonging to that group. |
| `QWidgetMetaData` | `metadata.ts` | Dashboard widget descriptor: renderer type string, permissions, grid column span, optional toolbar dropdowns, reload/export button flags, and help content. |
| `QTableSection` | `metadata.ts` | A collapsible card section on record view/edit pages: field names to display, optional embedded widget name, display tier, and grid column span. |
| `QExposedJoin` | `metadata.ts` | A join relationship made available for query filters and record views: cardinality (`isMany`), joined table metadata, and the path of `QJoinMetaData` hops. |
| `FieldAdornment` | `metadata.ts` | Discriminated union of all adornment shapes (`LINK`, `CHIP`, `TOOLTIP`, `ERROR`, `FILE_DOWNLOAD`, `SIZE`, `REVEAL`, `CODE_EDITOR`, `RENDER_HTML`, `FILE_UPLOAD`). Narrow with `Extract<FieldAdornment, { type: 'LINK' }>`. |
### QFieldMetaData example
```typescript
const field: QFieldMetaData = {
name: 'firstName',
label: 'First Name',
type: 'STRING',
isRequired: true,
isEditable: true,
isHidden: false,
isHeavy: false,
maxLength: 100,
adornments: [],
}
```
---
## 3. Records & Queries
| Type | Source file | Purpose |
|------|-------------|---------|
| `QRecord` | `records.ts` | A single backend record returned by the tables API. `values` holds raw typed data; `displayValues` holds pre-formatted strings ready for rendering. May include `associatedRecords` for joined/child data, and `errors`/`warnings` for bulk operations. |
| `QPossibleValue` | `records.ts` | A single option from a possible-value source, used to populate autocomplete and select inputs. `id` is the stored value; `label` is the user-visible text. |
| `QAuditRecord` | `records.ts` | An audit log entry for a record: who changed it, when, what action (`INSERT`/`UPDATE`/`DELETE`), and the list of `QAuditFieldChange` field-level diffs. |
| `QQueryFilter` | `query.ts` | Root filter object sent to `/tables/{name}/query` and `/tables/{name}/count`. Combines `criteria` with `AND`/`OR`, supports nested `subFilters`, and carries `skip`/`limit` for pagination. |
| `QFilterCriteria` | `query.ts` | A single field-level predicate: a `fieldName`, a `QCriteriaOperator`, and a `values` array that may contain literals or dynamic expressions (`NowExpression`, `NowWithOffsetExpression`, etc.). |
| `QFilterOrderBy` | `query.ts` | A sort clause: `fieldName` and `isAscending`. Multiple entries form a compound sort. |
| `QueryJoin` | `query.ts` | A join instruction sent alongside a `QQueryFilter` when fields from a related table are needed in query results. Specifies `joinTable`, join type (`INNER`/`LEFT`/etc.), and optional alias. |
| `NowWithOffsetExpression` | `query.ts` | A dynamic filter value that resolves to "now minus/plus N days/weeks/months/years" at query time, enabling "last 7 days" style saved filters. |
### QQueryFilter example
```typescript
const filter: QQueryFilter = {
criteria: [
{ fieldName: 'status', operator: 'EQUALS', values: ['ACTIVE'] },
{ fieldName: 'createdDate', operator: 'GREATER_THAN', values: [{ type: 'NOW_WITH_OFFSET', offsetValue: 30, offsetUnit: 'DAY', isNegativeOffset: true }] },
],
booleanOperator: 'AND',
orderBys: [{ fieldName: 'lastName', isAscending: true }],
skip: 0,
limit: 25,
}
```
---
## 4. Processes
| Type | Source file | Purpose |
|------|-------------|---------|
| `QJobStarted` | `processes.ts` | Initial response after submitting a process step; provides `processUUID` and `jobUUID` for subsequent status polling. |
| `QJobRunning` | `processes.ts` | Intermediate polling response while a job executes asynchronously; provides a `message` and optional `current`/`total` progress counts for the progress bar. |
| `QJobComplete` | `processes.ts` | Terminal success response; carries output `values`, optional `nextStep`/`backStep` navigation hints, and any `processMetaDataAdjustment` that modifies remaining wizard steps. |
| `QJobError` | `processes.ts` | Terminal failure response; provides a technical `error` string and an optional `userFacingError` message for display in the wizard UI. |
| `QJobResponse` | `processes.ts` | Discriminated union of `QJobStarted \| QJobRunning \| QJobComplete \| QJobError`. Narrow by checking for the discriminating property (`jobUUID`, `message`, `values`, or `error`). |
| `ProcessMetaDataAdjustment` | `processes.ts` | Runtime modifications to the process wizard applied after a step completes: `addedSteps`, `removedSteps`, and partial `modifiedFields` overrides. |
### QJobResponse narrowing example
```typescript
function handleJobResponse(response: QJobResponse) {
if ('error' in response) {
// QJobError
showToast(response.userFacingError ?? response.error)
} else if ('values' in response) {
// QJobComplete
advanceToStep(response.nextStep)
} else if ('message' in response) {
// QJobRunning — update progress bar
setProgress({ message: response.message, current: response.current, total: response.total })
} else {
// QJobStarted — store jobUUID and start polling
setJobUUID(response.jobUUID)
}
}
```
---
## 5. Widgets
| Type | Source file | Purpose |
|------|-------------|---------|
| `WidgetData` | `widgets.ts` | Base shape for all widget data payloads; the `type` string is the discriminant used to select the correct renderer. |
| `ChartWidgetData` | `widgets.ts` | Recharts widget payload: `chartType` (`line`/`bar`/`pie`/`area`), `labels` array, and `datasets` array of `ChartDataset` series. |
| `ChartDataset` | `widgets.ts` | A single Recharts data series: human-readable `label`, ordered `data` values aligned with the parent chart's `labels`, and optional `color`. |
| `StatisticsWidgetData` | `widgets.ts` | KPI tile payload: headline `title`, `value`, optional `unit`, and optional `trend` object with direction (`up`/`down`/`flat`), numeric magnitude, and period label. |
| `HtmlWidgetData` | `widgets.ts` | Raw HTML widget payload; the `html` string **must** be sanitized with DOMPurify before use in `dangerouslySetInnerHTML`. |
| `RecordGridWidgetData` | `widgets.ts` | Tabular record list widget: `tableName`, optional `columns` (field name strings) or `fields` (full `QFieldMetaData[]`), `records` array, and optional `totalCount`. |
| `BlockData` | `widgets.ts` | Discriminated union of all block element types renderable inside a `BlockWidgetData`: `text`, `big_number`, `up_or_down`, `progress`, `button`, `icon`, `image`, `audio`, `divider`, `input`, `html`. |
| `BlockWidgetData` | `widgets.ts` | Ordered heterogeneous block widget: a `blocks` array of `BlockData` variants and an optional `layout` (`vertical`/`horizontal`/`grid`). |
---
## 6. Enums
| Type | Source file | Purpose |
|------|-------------|---------|
| `QFieldType` | `enums.ts` | String-union of all primitive field data types: `STRING`, `INTEGER`, `LONG`, `DECIMAL`, `BOOLEAN`, `DATE`, `TIME`, `DATE_TIME`, `TEXT`, `HTML`, `PASSWORD`, `BLOB`. Determines which renderer, input component, and Zod validator to use. |
| `QCriteriaOperator` | `enums.ts` | String-union of all filter comparison operators used in `QFilterCriteria.operator`: `EQUALS`, `NOT_EQUALS`, `IN`, `NOT_IN`, `CONTAINS`, `STARTS_WITH`, `LESS_THAN`, `GREATER_THAN`, `IS_BLANK`, `IS_NOT_BLANK`, `BETWEEN`, and more. |
| `Capability` | `enums.ts` | String-union of backend-declared table capabilities (`TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`) that gate UI elements such as Create, Edit, and Delete buttons. |
| `QAppNodeType` | `enums.ts` | String-union identifying the entity kind of a sidebar navigation node: `TABLE`, `PROCESS`, `REPORT`, `APP`. Drives route prefix selection in `QAppTreeNode`. |
| `QComponentType` | `enums.ts` | String-union of all UI component types renderable inside a process step: `EDIT_FORM`, `VIEW_FORM`, `RECORD_LIST`, `VALIDATION_REVIEW_SCREEN`, `BULK_LOAD_FILE_MAPPING_FORM`, `PROCESS_SUMMARY_RESULTS`, `HTML`, `WIDGET`, and more. |
| `AdornmentType` | `enums.ts` | String-union of all field adornment identifiers (`LINK`, `CHIP`, `SIZE`, `ERROR`, `RENDER_HTML`, `REVEAL`, `CODE_EDITOR`, `FILE_DOWNLOAD`, `FILE_UPLOAD`, `TOOLTIP`). Mirrors the discriminant property of the `FieldAdornment` union for use in switch statements and type-guard utilities. |
---
## Quick-Reference Import Cheat Sheet
```typescript
// App & auth
import type { QInstance, QAuthenticationMetaData, QBrandingMetaData, Banner } from '@/types'
// Metadata
import type {
QTableMetaData, QFieldMetaData, QProcessMetaData,
QFrontendStepMetaData, QWidgetMetaData, FieldAdornment,
} from '@/types'
// Records & queries
import type { QRecord, QPossibleValue, QQueryFilter, QFilterCriteria } from '@/types'
// Processes
import type { QJobResponse, QJobComplete, QJobError, ProcessMetaDataAdjustment } from '@/types'
// Widgets
import type { WidgetData, ChartWidgetData, StatisticsWidgetData, BlockData } from '@/types'
// Enums
import type { QFieldType, QCriteriaOperator, Capability } from '@/types'
```
+303
View File
@@ -0,0 +1,303 @@
# Browser matrix — real-backend acceptance (QRun-IO/qqq#649, #708)
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
## September 30 — post-RC4 report and menu checkpoint
Local production export at `a8e4d3b`:583 Chromium tests and all391 required feature rows passed, zero failures/skips/flaky results, three approved external-service exclusions. This includes the uniform table backgrounds, record overview spacing and hidePreview compatibility fixes. Retained full report/gate: `report-preview-full-chromium/` in local visual-review evidence.
The subsequent column-menu height correction passes28 targeted Chromium/WebKit cases. The valid red run failed both320px-window cases and passed both720px cases; final checks cover bounds, keyboard/focus, Copy/sort, standalone query actions and variants. Hosted predecessor36739388053 WebKit remains failed578/1: Copy full query values was outside the viewport before variant switching. Its exact scenario passed locally before correction; hosted resolution is unproven. Other predecessor jobs remain pending at this checkpoint. These scoped results do not establish complete browser compatibility.
The acceptance suite runs against the owned sample backend and the production static
export (javalin mode). The default gate runs Chromium only; the documented matrix is the
five configured Playwright projects below. CI runs Chromium separately from phone/tablet
so the primary workflow gate can finish independently. Prioritize Chromium
workflow depth for RC1 and final 1.0; the complete final browser claim still requires all five projects.
## Verification order
First complete workflows in desktop Chromium against the real backend: normal defaults,
required and invalid inputs, dependent controls, edit/cancel, save/reopen, permission
boundaries, error recovery, and database or downloaded-file results. Expand browser and
viewport coverage after those behaviors work. Keep existing secondary results visible;
a passing row or test count does not establish that every path within a feature works.
For example, WID-073 checks report column totals and the exported value distribution
against SQL for the active filter, then verifies cancellation leaves saved data unchanged.
Opening the statistics dialog alone is insufficient evidence of that workflow.
## RC3 published checkpoint — 2026-09-29
Published source `f1a6a3a` / `v1.0.0-RC.3` passed **570 Chromium tests and all 391 required rows** in the [candidate run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36652168977), with zero failures, skips or flaky results. The tested PR merge has the same Git tree as the tag; backend commit was `cba758507dcc54cf282a6638ab50a7d3a1904c7c`.
The candidate run is complete: mobile **545 passed**; Firefox **569 passed, one failed** (WID-005: Widget Gallery heading timeout); WebKit **568 passed, two failed** (NAV-056: application-request access-control errors; QRY-030: density-menu timeout); tablet **543 passed, two failed** (QRY-030: density-menu timeout; REC-046: WebKit internal navigation error). No skips or flaky results. Reports and traces are retained; causes remain unproven. The overall candidate run failed. The [tag run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36654410984) passed **570 Chromium tests and all 391 required rows**. Tag WebKit passed **568 and failed two** (NAV-016 application-request access-control errors; QRY-030 density-menu timeout); tag Firefox passed **568 and failed two** (QRY-007 error toast not found; WID-073 Render Report action timeout). Tag mobile passed **545 tests**; tablet passed **543 and failed two** (NAV-026 widget-request access-control errors; QRY-030 density-menu timeout). The tag run is complete and failed overall, with zero skips or flaky results. Reports and traces are retained; no complete cross-browser pass is claimed.
The public Maven JAR passed signature/checksum checks, normal dependency resolution in a separate consumer, and seven affected Chromium workflows with independent database readback. Both image architectures are published; the actual arm64 image passed app-route and all 21 referenced JavaScript asset checks. See [RC3 release notes](../releases/1.0.0-RC.3.md) for immutable artifact checksums and limitations. Final 1.0 remains on hold.
## RC2 published checkpoint — 2026-09-29
Published source `0905cfb` / `v1.0.0-RC.2` passed **563 Chromium tests and all 390 required rows** in both its PR and tag runs. The [completed tag run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36642612560) also passed all **538 mobile tests**. WebKit passed 561 and failed QRY-030 (density menu) and RPT-012 (internal navigation error); Firefox passed 561 and failed PRC-061 (bulk-load default selection) and INT-011 (failed-save feedback); tablet passed 537 and failed QRY-030. No tests skipped or passed through retries. The overall run failed, and failure causes remain under investigation.
The earlier PR run had a different set of secondary-browser failures. Both sets remain recorded in the [RC2 release notes](../releases/1.0.0-RC.2.md); a passing later scenario does not establish why an earlier failure occurred. The published Maven JAR was independently downloaded, signature/checksum verified, resolved by a fresh consumer and exercised in five Chromium workflows. Both container architectures are published; the arm64 image passed runtime HTML/JavaScript checks. RC2 is immutable and final 1.0 remains on hold.
The preparation checkpoints below are historical results and do not supersede this published-artifact evidence.
## Post-RC1 checkpoint — 2026-09-29
The report capability candidate `2221999` passed **562 Chromium tests**, all 390 required rows, zero failures/skips/flaky tests in [CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36633453712). WebKit passed 561 with one QRY-030 failure: the density option did not appear after a trigger tap. Its trace is retained and the cause remains unproven. Firefox passed 560 with two failures: NAV-014 reported an owned embedded-page request failure during navigation; SEC-003 timed out awaiting its initial record row, before the create-restriction assertions. The final screenshot shows the row, and the trace shows a roughly 15-second delay before the query was sent; the cause remains unproven. Mobile passed all 537 tests; tablet passed 536 with one QRY-030 density-menu timeout matching the desktop WebKit symptom. The run is complete and failed overall. These results precede the child-export correction.
The subsequent `542ac47` local full Chromium run passed 562 and failed WID-046: the generic parent CSV export was hidden while child dropdowns awaited selection, despite the parent's independent CSV payload. The correction preserves the selection restriction for specialized child export and restores generic payload export. Its new regression was observed failing before the fix; 29 focused tests, all 1,857 unit tests, and the production build pass. The corrected export then passed **563 Chromium tests and all 390 required rows**, with zero failures/skips/flaky tests. A clean local JAR matches the tested export byte-for-byte. Exact-commit CI and published-artifact verification remain pending; this local result does not establish artifact availability.
The preceding d937507 [CI run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36626338487) passed **558 tests each in Chromium and WebKit**, all 390 required rows. Firefox passed **556 with two failures**: NAV-014 reported `NS_ERROR_FAILURE` for the owned embedded page during breadcrumb navigation; QRY-007 timed out hovering an application-theme error toast after it detached. Logs and traces are retained for diagnosis. All five focused breadcrumb/error-theme scenarios subsequently passed locally in Firefox against RC2 without source or test changes. That recheck does not establish the cause or resolution of the CI failures. Mobile passed 533; tablet passed 532 and failed REC-019 with a WebKit internal navigation error while opening a field-lab record. Its log and trace are retained. These results do not establish a complete cross-browser pass or erase the RC1 results below.
## RC1 checkpoint — 2026-09-29
Published candidate `e901df9` / `v1.0.0-RC.1`: Chromium **547 passed**, no failed/skipped/flaky tests, all 389 required rows passed. WebKit **546 passed, 1 failed** in NAV-034 legacy table-process redirect navigation, with an internal WebKit resource-loading error; trace retained for investigation. The completed Firefox run had 546 passes and one WID-021 widget visibility timeout; phone/tablet had 1,043 passes and one tablet SEC-002 internal WebKit navigation error. The separate tag run also had failures; see the release notes for both runs. [CI run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36601699322). This does not establish a complete cross-browser pass.
The actual Maven Central JAR additionally passed Sleep Interactive defaults/validation/completion and report-context filtered statistics/CSV checks. The published arm64 container passed a runtime/HTML smoke check; its manifest also includes amd64. See [RC1 release evidence](../releases/1.0.0-RC.1.md).
```bash
export QQQ_SAMPLE_JAR=/path/to/qqq-sample-project-<version>-jar-with-dependencies.jar
# primary workflow gate, fresh static export
QQQ_ACCEPTANCE_BROWSERS=chromium node scripts/acceptance.mjs
# subsequent compatibility gate, all five projects, fresh static export
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet node scripts/acceptance.mjs
# the same with explicit ports (use distinct ports for concurrent runs)
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
QQQ_ACCEPTANCE_BACKEND_PORT=18793 QQQ_ACCEPTANCE_FRONTEND_PORT=13793 \
node scripts/acceptance.mjs
# the touch projects only, reusing out/ (a filtered run is a partial gate)
QQQ_ACCEPTANCE_BROWSERS=mobile,tablet node scripts/acceptance.mjs --skip-build
```
| Project | Playwright device | Engine (Playwright 1.64.0-alpha-2026-10-01) | Runs |
|----------|-------------------|----------------------------|------|
| chromium | Desktop Chrome | Chrome for Testing 155 | every spec |
| firefox | Desktop Firefox | Firefox 156 | every spec |
| webkit | Desktop Safari | WebKit 26.6 | every spec |
| mobile | Pixel 7 (412 × 839, touch) | Chromium | tests tagged `@mobile` |
| tablet | iPad (gen 7) (810 × 1080, touch) | WebKit | tests tagged `@mobile` or `@tablet` |
## Phone and tablet scope (QRun-IO/qqq#708)
Below 768 px the UI uses a different layout by design:
- lists are card lists;
- filters open in a bottom sheet;
- record sections are an accordion;
- record actions are in an action sheet;
- navigation is a modal drawer.
The tablet in portrait (810 px) gets the desktop layout (grid, sidebar, tabs) with a touch
pointer. Specs that assert the desktop layout (grid columns and cells, column resizing,
grid keyboard navigation) do not describe phone behavior, and skipping them is not
allowed: skips fail the gate. The touch projects therefore run only tagged tests:
- `mobile` runs tests whose title carries `@mobile`;
- `tablet` runs tests tagged `@mobile` or `@tablet` (`grep` in
`tests/acceptance/playwright.config.ts`).
Tagged tests also run on the desktop projects. They are either layout-neutral (helpers
`listCell`, `columnCells`, `listRows`, `navigation`, `recordList`, `openRecord` and
`recordAction` pick the grid or the cards, the tabs or the accordion) or pinned to a
412 px touch viewport, so every phone scenario is also checked in Firefox and WebKit at
phone width.
### Coverage rule, enforced by the gate
When a touch project runs, `scripts/acceptance-gate.mjs` requires every required row to
have a passing test in `mobile` and in `tablet`. The only exception is a row that records
why it is not a touch scenario (`"desktopOnly": "<reason>"`, which currently no row uses).
The result is in `summary.phone`: `covered`, `desktopOnly`, `uncovered`. The desktop-layout
variants that stay untagged (the grid-keyboard INT-001, the grid-loading INT-009, the
desktop REC-004 tab test) have phone counterparts for the same rows.
### Touch targets and layout checks
On a coarse pointer every control is at least 44 × 44 CSS px (WCAG 2.5.5). This is a
`@media (pointer: coarse)` rule in `src/styles/globals.css`, plus `pointer-coarse:`
classes in components. It covers:
- buttons, inputs, selects, tabs, menu items and options;
- a label wrapping a checkbox, radio or switch;
- breadcrumbs, sidebar entries, record links and widget block links.
Links in running text and data values keep their inline size. Mouse layouts are unchanged
at every width, because Playwright emulates `pointer: coarse` only when `hasTouch` is set.
`tests/acceptance/support/touch.ts` provides the checks:
- `expectNoHorizontalScroll`: the page never scrolls sideways; wide data scrolls inside
its own container;
- `expectTouchTargets` / `expectTouchReady`: no visible control below 44 px, applied only
under a coarse pointer.
The phone and tablet specs call these on the shell, lists, filter sheet, column
configuration, bulk bar, record view and forms, dialogs and sheets, every process screen
type, dashboards and reports.
Phone behaviors with their own rows and tests:
| Phone behavior | Rows |
|----------------|------|
| Navigation drawer: tap, backdrop, keyboard focus trap, Escape, focus return | NAV-026, NAV-037 |
| Shell on phones and tablets: header, breadcrumbs, sidebar, skip link, not-found | NAV-038, NAV-014 |
| Card list: grid columns in order, formatted values, selections, loading | QRY-008, INT-009 |
| Filter sheet: modal dialog, focus in and out, Escape, scrolls inside | QRY-009 |
| Column configuration by touch | QRY-024 |
| Bulk actions from a card selection | QRY-036 |
| Saved views and export menus on a phone | QRY-055 |
| Record header and phone action sheet | REC-058, INT-003 |
| Forms with every editor type on phones and tablets | REC-059, INT-008 |
| Tooltips and help by tap (fields, widget labels, widget help) | REC-036, REC-039, WID-043, WID-044 |
| Dashboards: one column below 1024 px, wide tables scroll in their card, dropdown/date/reload/export by tap | WID-022, WID-040, WID-019, WID-045–WID-048 |
| Process screens of every type touch-ready | PRC-001–PRC-047 |
| Sign-in, logout, session expiry; accessibility scan at phone width | SEC-020–SEC-023, INT-014, INT-005 |
| TABLE_BASED password sign-in, failure, expiry and logout | SEC-034–SEC-037 (QRun-IO/qqq#700) |
| Login page branding before sign-in | NAV-033 (QRun-IO/qqq#703) |
| Search dialog with record search | NAV-030 (QRun-IO/qqq#701) |
| Keyboard use on a phone (card, action sheet, edit, save) | INT-001 |
The gate also fails any configured project that ran no tests.
## Results
Recorded 2026-09-26 on macOS (Darwin 27) for the Next UI 1.0 integration (QRun-IO/qqq#713):
the sample from the qqq `feature/next-1.0` backend (`a55c71a2d`, with the v1 supplemental
metadata, developer-mode and table-variant process routes of #714) and the Next static export
of `feature/next-1.0` (`e3bea6e`: the batch 1-3 streams, Material parity #714 and phone and
tablet coverage #708), built from a clean `.next`. One run of the full gate with the five
projects:
Tests passed / run, per spec area and project (the touch projects run the `@mobile` tests only):
| Area | chromium | firefox | webkit | mobile | tablet |
|---|---|---|---|---|---|
| navigation | 59/59 | 59/59 | 59/59 | 56/56 | 56/56 |
| performance | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 |
| processes | 61/61 | 61/61 | 61/61 | 54/54 | 54/54 |
| query | 68/68 | 68/68 | 68/68 | 68/68 | 68/68 |
| records | 90/90 | 90/90 | 90/90 | 84/84 | 84/84 |
| security | 73/73 | 73/73 | 73/73 | 67/67 | 67/67 |
| widgets | 93/93 | 93/93 | 93/93 | 91/91 | 91/91 |
| **total** | **449/449** | **449/449** | **449/449** | **425/425** | **425/425** |
Gate (`test-results/acceptance/gate.json`, 2,197 tests, 44.3 min, retries 0, commit `e3bea6e`):
```json
{
"summary": { "passed": 347, "failed": 0, "missing": 0, "excluded": 3,
"phone": { "covered": 347, "desktopOnly": 0, "uncovered": 0 } },
"byProject": {
"chromium": { "passed": 449, "failed": 0, "skipped": 0, "flaky": 0 },
"firefox": { "passed": 449, "failed": 0, "skipped": 0, "flaky": 0 },
"webkit": { "passed": 449, "failed": 0, "skipped": 0, "flaky": 0 },
"mobile": { "passed": 425, "failed": 0, "skipped": 0, "flaky": 0 },
"tablet": { "passed": 425, "failed": 0, "skipped": 0, "flaky": 0 }
},
"problems": []
}
```
Every required row passes in every desktop project and in both touch projects; no row is
`desktopOnly`. PRC-039, WID-033 and SEC-030 are `required: false` pending approval and are
reported as excluded.
## Differences found between engines
Fixed:
- **WebKit, aborted prefetches.** WebKit raises "<url> due to access control checks." as
a page error for same-origin Next.js route prefetches and RSC payload fetches that a
document navigation cancels; the server answered 200 and Playwright never sees the
request. Traces confirmed every report named a `/app/...` route, `__next.*.txt` or
`?_rsc=` payload within a millisecond of a main-frame navigation. The shared diagnostics
fixture ignores exactly these (same origin, Next route or payload URL, within one second
of a navigation) and lists them under `interruptedFetches`; API, cross-origin and
unrelated access-control failures still fail the test.
- **WebKit, document title.** Next.js re-applies the static metadata title ("QQQ Admin")
after client navigations, in WebKit after the layout set the metadata title (NAV-023,
NAV-006). `useDocumentTitle` keeps the breadcrumb title in place.
- **Firefox, rich text.** Firefox stores a typed space before a formatting change as
`&nbsp;`; the editor now stores engine-neutral HTML (REC-020).
- **Firefox/WebKit, computed styles.** A `CSSStyleDeclaration` returned from `evaluate()`
only serializes in Chromium; WID-057 reads the properties inside the page.
- **WebKit keyboard order.** WebKit, like Safari, moves Tab only between form fields;
keyboard specs use Option+Tab there (`tabKey` in `specs/security/support/ui.ts`).
- **Aborted requests.** A request aborted by the test is reported as `net::ERR_FAILED`
(Chromium), `NS_ERROR_FAILURE` (Firefox) or "Blocked by Web Inspector" (WebKit).
- **Provider logout in Firefox.** Starting an in-app navigation just before leaving for
the identity provider made Firefox log "Failed to fetch RSC payload". Provider logout and
sign-in retries now leave the page without a client navigation first.
- **Set-Cookie visibility in WebKit.** WebKit hides `Set-Cookie` from page responses, so
SEC-033 checks the logout response from Node.
- **Phone defects (QRun-IO/qqq#694).** The card list showed "No records found" while
loading; it now shows busy placeholder cards. The record action sheet moves focus in on
open, closes on Escape, and returns focus to its trigger, also when a dialog opened from
one of its items closes.
The fixes above describe earlier checkpoints. Open failures from the RC1 runs are recorded in the RC1 checkpoint and release notes; they have not been waived.
## RC4 candidate density touch correction
The recurring QRY-030 density-menu failure was reproduced in Linux WebKit using
Playwright 1.58.2's official Noble image. Instrumented events showed that touch
pointer-down opened the menu, then synthetic mouse focus returned to its trigger;
Radix treated that focus as outside the non-modal menu and dismissed it. macOS
WebKit did not reproduce this event sequence.
The RC4 candidate source applies the existing Selection/Views/Export trigger guard to
Density. It ignores outside interactions only when they target that same trigger;
other controls still dismiss the menu. No layout, dependency or timeout changed.
Both the existing density-selection test and the new touch/keyboard/outside-dismissal
regression passed with the selection-menu workflow: **nine checks across Linux
WebKit, tablet WebKit and Chromium, zero failures/skips/flaky results**. The new
regression failed before the fix. An initial added-test run used an incorrect
`textbox` locator for the searchbox; it was interrupted, retained, corrected and
rerun. The full unit suite passed 1,884 tests; production export, types, affected
lint and independent review also passed. The combined candidate at `f2cdf03` passed **577 Chromium tests and all 391
required rows**, with zero failures/skips/flaky results and three approved exclusions.
The preceding numeric-wheel correction separately passed its 576-test Chromium gate.
Both corrections were integrated before RC4 publication at
`f2cdf03`, superseding its earlier preparation checkpoint `1dbe1d9`. Published
RC3 does not contain them. Historical RC3/precursor failures above remain
valid evidence for those revisions; this focused result does not certify the whole
browser matrix.
## Completed precursor CI before RC4 integration
Run `36658766595` at `1283bac07e81741bfadc34bf7fe1c7805604d16f` is terminal.
Chromium passed 574 tests and all 391 required rows. Mobile passed 547; tablet
passed 544 and failed three: NAV-015 internal WebKit navigation, NAV-056 API
access-control errors, and QRY-030 density touch. Firefox passed 573 and failed
WID-024 waiting for the record heading. WebKit passed 573 and failed QRY-030.
All profiles had zero skips or flaky outcomes; general CI and image checks passed.
These results predate the report-editor, numeric-wheel and density corrections.
The Linux density regression is corrected and locally verified as described above;
other secondary failures remain open pending diagnosis or new candidate evidence.
Hosted checks for the integrated RC4 candidate and tag are complete; see the RC4 published checkpoint below.
## RC4 published checkpoint — 2026-09-30
Published source `3c409f9` / `v1.0.0-RC.4` passed **577 Chromium tests and all 391 required rows** in both [candidate](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36664932061) and [tag](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36714593674) acceptance. Both used backend `e0d57dd31b979b17215370546ef4e26cd8a38d21`; the candidate merge tree matches the tagged source.
Both acceptance runs completed and failed overall because of secondary-browser failures. All profiles had zero skips or flaky outcomes; failures were retained, not waived or silently retried.
| Browser | Candidate result | Tag result |
| --- | --- | --- |
| Chromium | 577 passed | 577 passed |
| Mobile | 547 passed, 1 failed: REC-054 script-suggestion listbox bounding-box timeout in dark mode | 548 passed |
| Firefox | 576 passed, 1 failed: WID-026 record-heading timeout | 576 passed, 1 failed: WID-019 dashboard-heading timeout |
| WebKit | 576 passed, 1 failed: REC-046 internal navigation error on a read-only user's copy route | 576 passed, 1 failed: REL-005 internal navigation error returning to the pet list |
| Tablet | 547 passed, 1 failed: NAV-026 widget-request access-control errors during drawer navigation | 547 passed, 1 failed: SEC-002 internal navigation error opening a denied ledger create route |
Density scenarios passed in every profile of both runs. The other failures remain open; a later pass does not erase the earlier failure or prove full browser compatibility.
The actual public Maven JAR passed signature/checksum verification, fresh-consumer dependency resolution and 17 affected real-backend Chromium workflows. The public ARM64 container served the application and all 21 referenced JavaScript assets. These checks establish artifact delivery and the stated workflow coverage, not full compatibility or visual approval. See [RC4 release notes](../releases/1.0.0-RC.4.md) for checksums and remaining requirements. Final 1.0 remains on hold for owner testing and renewed approval.
+5
View File
@@ -0,0 +1,5 @@
# ESB integration acceptance
The ten required [ESB matrix scenarios](../../tests/acceptance/matrix/esb.json) pass in Chromium, Firefox and WebKit: 30 cases with no failures, skips or retries. They exercise the stock sample’s embedded Artemis broker, table-event delivery into `syncPerson`, service/table/process views, permission denial, subscription browsing, trigger pause/resume/restart and confirmed dead-letter replay. A further 38 Chromium navigation, permission and table Developer-view cases, plus 12 security-variant and search cases, pass without skips or retries. The main and security fixtures use separate broker ports. These are focused ESB results; general Next browser/parity and final release acceptance remain tracked separately.
The backend integration fixes are [QQQ PR #980](https://github.com/QRun-IO/qqq/pull/980), based on `5bf02d54`: normal sample ESB registration, API documentation route isolation and nullable subscription/dead-letter pause state. Frontend verification passes 105 focused ESB tests, all 1,951 unit tests with coverage thresholds, type checking, the production static export and bundle budgets. The [acceptance README](../../tests/acceptance/README.md) gives the commands and fixture boundaries; [Next #10](https://github.com/QRun-IO/qqq-frontend-next/issues/10) and [QQQ #739](https://github.com/QRun-IO/qqq/issues/739) track delivery. RabbitMQ and broker management behavior are covered by the backend conformance suite; these browser tests use Artemis without an HTTP management endpoint, so unavailable broker actions remain hidden.
+444
View File
@@ -0,0 +1,444 @@
# Next UI feature acceptance matrix
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the
acceptance gate results. Each row is a supported QQQ UI feature or use case with executable
real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.
Rows: 394. Required rows passing in the recorded runs: 391.
Recorded runs: `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-full-chromium/gate.json` (2026-10-01T01:03:05.420Z), `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-ci-chromium/gate.json` (2026-10-01T01:23:18.171Z).
Corrected RC8 source cd3d473: 602 local and 602 hosted Chromium cases passed, with zero failed/skipped/flaky cases or report errors. Hosted run 36798799502. Three approved external-service exclusions remain. Earlier failed runs are retained and described in docs/releases/1.0.0-RC.8.md; this matrix records the corrected primary runs only. Other browser profiles and whole-product compatibility/visual approval remain separate.
The additional ESB integration scenarios and their focused three-browser results are recorded in [ESB acceptance](esb.md). The historical full-suite counts above predate those ten new matrix rows.
## Navigation, application structure, branding and icons
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| NAV-001 | Direct link to a table list renders seeded rows | success | | prime-test-database.sql | pass (chromium) | |
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under their parent | admin sees Miscellaneous, Nav Empty App, Nav Level One, People App, Sample Widgets Dashboard, Sharing Demo in backend order; Greetings App appears only under People App (regression: it was duplicated at top level); group children use metadata labels | noPets persona: Pet and Pet Note are absent from the sidebar; backend returns 404 for their metadata and 403 for a query | stock sample apps + NavigationFixtures (Nav Level One, Nav Empty App); persona noPets | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | expand three nested groups and open Nav Deep Item; grid rows match nav_deep_item; deep link to /app/navDeepItem expands all three groups and marks the item current | | NavigationFixtures: navLevelOne > navLevelTwo > navLevelThree > navDeepItem | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | Dashboard is current on /app; click app, table, process; URL, content and aria-current follow; collapse hides children, expand shows them | | stock sample (Miscellaneous app) | pass (chromium) | |
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | sidebar glyph per declared Material name, color applied, image path loads; app home entries and section headings show declared icons; command palette entries show declared icons | no declared icon renders the type fallback glyph | sample icons (stars, person, widgets, share, pets, local_shipping, badge) + NavigationFixtures (path icon, colored icon, inventory_2, inbox, layers) | pass (chromium) | [#538](https://github.com/QRun-IO/qqq/issues/538) |
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access show not-found and the backend denies them | sidebar, app home, landing page and command palette omit City, Greet People, Simple Sleep and Nav Hidden Note | /app/city and /app/greet show the not-found state; backend returns 404 and 403 | sample city (hidden table), greet and simpleSleep (hidden processes); NavigationFixtures navHiddenNote (hidden, NOT_PROTECTED) | pass (chromium) | |
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | /app/navHiddenNote lists nav_hidden_note rows; breadcrumbs show its enclosing apps | | NavigationFixtures navHiddenNote (isHidden, PermissionLevel.NOT_PROTECTED) | pass (chromium) | |
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each entry navigates | Miscellaneous: Actions Sleep Interactive, Simple Throw; Data Carrier, Field Lab, Pet Species (labels, not names); section heading shows its label and declared icon; clicking an entry opens the table or process | hidden or unpermitted section entries are omitted (navHiddenNote, noPets persona) | sample greetingsApp and miscellaneous sections | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-009 | App home table entries show the backend record count | each Data entry reads 'N total records' equal to the SQL count | | sample carrier, fieldLab, petSpecies, person, pet, petNote rows | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | People App shows Clone People and the Greetings App child app (regression: crashed on missing widgets); Nav Level One lists its child app and navigates to it; Nav Empty App shows the empty state | viewer persona: People App shows only the Greetings App child app (no denied process) | sample peopleApp; NavigationFixtures navLevelOne, navEmptyApp; persona viewer | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared widget | sidebar click opens the dashboard; one widget region per declared widget label, in order | | sample SampleWidgetsDashboard (11 widgets) | pass (chromium) | |
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding accepted without warnings | sidebar shows /samples-logo.png with alt QQQ Sample, loaded, linking to the dashboard; favicon link points to /kr-icon.png; primary color variables equal the branding accent color; no 'schema validation' console warning for branding without companyName/companyUrl | | sample branding (appName, logo /samples-logo.png, icon /kr-icon.png) + NavigationFixtures accentColor #1d4ed8 | pass (chromium) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized HTML, severity and colors | each slot shows its message in the right place with severity and colors from metadata | unknown slots are not rendered | NavigationFixtures banners: QFMD_TOP_OF_SITE (INFO text), QFMD_TOP_OF_BODY (WARNING HTML), QFMD_SIDE_NAV_UNDER_LOGO (SUCCESS, colors, additional styles) | pass (chromium) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb navigates | /app/person: People App / Greetings App / Person; /app/navDeepItem/2/edit: Nav Level One / Nav Level Two / Nav Level Three / Nav Deep Item / 2 / Edit; process and create pages; clicking a crumb opens that app home | no breadcrumbs on the dashboard root | sample person/greetInteractive; NavigationFixtures navDeepItem | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | Dashboard \| QQQ Sample; Person \| Greetings App \| People App \| QQQ Sample; record label title on the record page | unknown route title is Not Found \| QQQ Sample | stock sample | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-016 | The site root redirects to the dashboard | / replaces itself with /app (back does not return to /) | | stock sample | pass (chromium) | |
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata by label | counts equal the navigable tables, processes, apps and their widgets from backend metadata; quick actions create only navigable insertable tables and run only navigable processes, by label (regression: listed internal and table-bulk processes, crashed on apps without widgets); application cards open app homes | viewer persona: no create or process quick actions | stock sample + NavigationFixtures; persona viewer | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | app home, nested app home, table, process, record view, record edit, create, saved view | | stock sample + NavigationFixtures | pass (chromium) | |
| NAV-019 | Browser refresh keeps the route, its query string and the rendered content | refresh on a table with a query string, a record view, and an app home | | stock sample | pass (chromium) | |
| NAV-020 | Browser back and forward traverse navigation history with the right content | dashboard > app home > table > record, then back three times and forward twice | | stock sample | pass (chromium) | |
| NAV-021 | An unknown /app/{name} shows an in-shell not-found state with a link to the dashboard | /app/noSuchThing shows 'Page not found' naming the segment; the link opens the dashboard; no failed requests | | stock sample | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | /app/person/1/no/such and /no-such-page respond 404 and render the 404 page; Go Home reaches the dashboard | only the document 404 is reported | stock sample | pass (chromium) | |
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | ?code=NAV-B2 opens record 2 (Tide Chart); ?email=... opens that person | no match, several matches (?shelf=North), unknown field, no parameters: error message, no navigation | NavigationFixtures navDeepItem (unique key code); sample person emails | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context, filters, and navigates by keyboard | filter 'pet' shows Pet, Pet Note, Pet Species with type and app; Enter opens the first; '.' opens the palette; Escape closes it | hidden objects are not listed | stock sample + NavigationFixtures | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by label (matched locally, alongside record search when the backend offers it: NAV-030) | typing a label lists matching pages with app context; choosing one navigates; a viewed record is offered as a recent record; Enter without a selection opens /app/search?q= with the matches, surviving refresh; search dialog contains Tab and Shift+Tab with empty or populated results; Clear returns to the input; Escape and backdrop dismissal restore keyboard and pointer openers | no search request fails (regression: every keystroke produced a 404 from a missing /search endpoint); with no searchable tables no request is made at all (NAV-032) | stock sample | pass (chromium) | [#653](https://github.com/QRun-IO/qqq/issues/653) |
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | hamburger opens the drawer; choosing a table navigates and closes it; close button and a backdrop tap close it; drawer controls are 44 px touch targets | | stock sample, 412x839 touch viewport | pass (chromium) | |
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | metadata counts equal the backend's; framework shows the bundled Next.js version (regression: hardcoded 'Next.js 15') | | stock sample | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report page | sidebar entry with its icon opens /app/navDeepItemReport with the report heading, title and breadcrumbs; app home Reports group lists and opens it | | NavigationFixtures navDeepItemReport (child of Nav Level Three, icon assessment) | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | sidebar shows only Dashboard; landing shows You do not have permission to access any apps. | app home links show the not-found state | harness persona noApps (every permission except App HAS_ACCESS) - requested: not yet in AcceptanceSampleServer | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/search over each table's searchFields) finds, after the matching pages, by record label with the table label; results open by keyboard and the search page survives refresh | '/' dialog: 'COCO' finds the pet Coco (label and table label), case-insensitively; ArrowDown and Enter open /app/pet/{id}; the request searches the readable searchable tables (person, pet, savedView) with a per-table limit of 5; header box: 'Sample' lists the matching people (database order, limit 5) after pages; Enter opens /app/search?q=Sample whose Records region lists every match (limit 25) before and after refresh; a link opens the record | a one-character term is not sent (unit), and a recent record already found is not listed twice (unit) | stock sample + NavigationFixtures (search fields on person, pet, savedView) | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) |
| NAV-031 | Record search respects table read permissions and record security locks, in the UI and the API | noPets: 'ey' lists the matching people only; the UI never asks for pets and the metadata advertises no pet search fields; admin: the backend returns the pets matching 'EY' for tableNames [pet]; alice finds her saved view 'Alice People View' with the saved view table label, in the UI and from the API; bob, with whom the view is shared read-only, finds it from the API (the lock admits shared readers) | noPets: POST /qqq/v1/search with tableNames [pet] returns no results; an unscoped search returns no pets; casey (neither owner nor shared reader): the locked saved view is not found in the UI or from the API | stock sample + NavigationFixtures + QueryFixtures (alice's saved view shared read-only with bob); noPets persona; sharing demo users | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) |
| NAV-032 | Without searchable tables (the backend declares no searchFields) global search stays local and never calls a search endpoint | the variant's v1 metadata lists no table with searchFields; header box, '/' dialog and search page keep the local labels and matches, with no Records group or region | no request to /qqq/v1/search or /search is made while typing, opening the dialog or loading the search page | security variant (stock sample + SecurityFixtures) | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) [#653](https://github.com/QRun-IO/qqq/issues/653) |
| NAV-033 | Login branding before sign-in: logo, app name, accent color, favicon and title from GET /qqq/v1/metaData/authentication (no session), never the banners | unauthenticated authentication metadata carries exactly appName, logo, icon and accentColor; after logout and a fresh load the login card shows the loaded logo and QQQ Sample; primary color variables and the Sign in button use the accent; favicon /kr-icon.png; title Sign in \| QQQ Sample | no banner text in the unauthenticated response or on the login page | sample branding (appName, logo /samples-logo.png, icon /kr-icon.png) + NavigationFixtures accentColor #1d4ed8 and three banners | pass (chromium) | [#703](https://github.com/QRun-IO/qqq/issues/703) [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-034 | Material table-scoped URLs: /app/{table}/{process} opens the table process (or an instance process); /app/{table}/{id}/{process} runs it for that record and returns to it | /app/person/person.bulkEdit?recordsParam=recordIds&recordIds=1,2 replaces itself with the run (init sends 1,2; Back returns to the table); /app/person/prcQuickTask opens the instance process with returnTo the table; /app/person/1/clonePeople runs for record 1 (init and SQL clone) and Return lands on the record; /app/person/2/bulkEdit resolves the table process by suffix; Cancel returns to the record | /app/person/1/noSuchProcess shows the in-shell not-found state and starts no process; report URLs (/app/{table}/{report}) are not covered: the backend frontend report metadata (QFrontendReportMetaData) has no tableName, so no report can match (#732) | sample person.bulkEdit, clonePeople (table processes), prcQuickTask (ProcessesFixtures, instance process) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) [#732](https://github.com/QRun-IO/qqq/issues/732) |
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | record Actions > Clone People runs for the record and Return lands on the record; Bulk Edit from /app/person?filter=isEmployed: returnTo is the launching path and query; after the edit (SQL) Return restores the same filter and the filtered rows | the unmatched record (Morgan) stays out of the returned grid | sample person, clonePeople, person.bulkEdit | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| NAV-036 | Material app settings (MaterialDashboardAppMetaData): showAppLabelOnHomeScreen and includeTableCountsOnHomeScreen turned off hide the home-screen title and the table counts | v1 metaData carries supplementalAppMetaData.materialDashboard for the app; The app home has no visible title (a screen-reader-only h1 remains) and the table entry shows no count; No count request is made for the table | Apps without the settings keep their title and counts (Material defaults) | NavigationFixtures.java navQuietHome app (navQuietItem table) with MaterialDashboardAppMetaData(false, false) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| NAV-037 | Mobile navigation drawer is a keyboard-usable modal dialog: focus moves in on open, Tab stays inside, and Escape, the close button or a chosen link close it and return focus to the menu button | Enter on the menu button opens the Navigation dialog with focus inside; Tab and Shift+Tab visit every drawer control and never reach the page behind; Escape, the close button and choosing Carrier close it; focus returns to the menu button; Carrier opens | Tab never leaves the open drawer (QRun-IO/qqq#708: focus stayed on the menu button; Firefox could not reach the drawer) | stock sample (Miscellaneous app), 412x839 touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| NAV-038 | Shell on phones and tablets: skip link, header controls, breadcrumbs, navigation entries, user menu, app homes and not-found links are 44 px touch targets, and the page never scrolls sideways | skip link is the first Tab stop and 44 px tall when shown; header, breadcrumbs, sidebar or drawer entries and the user menu meet the 44 px target; no sideways page scroll with a four-level breadcrumb trail | under a mouse (pointer: fine) the desktop sizes are unchanged | stock sample + NavigationFixtures (navDeepItem three apps deep); mobile (Pixel 7) and tablet (iPad, desktop layout with touch) projects | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| NAV-055 | Material Dashboard CSS and test hooks remain available on navigation, banners, app homes, forms, record views and query controls | Material data-qqq-id and class selectors identify corresponding Next UI controls; application CSS keyed to those selectors changes the rendered style; record and query actions retain the expected behavior and button variants | | stock sample with NavigationFixtures and RecordsFixtures; application CSS applied in css-hooks.spec.ts | pass (chromium) | [QRun-IO/qqq-frontend-next#731](https://github.com/QRun-IO/qqq-frontend-next/issues/731) |
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the branded header | v1 metaData publishes the selected Material theme settings; the rendered dashboard applies its primary token, branded header colors and tagline, and custom CSS; Every theme scenario resets instance metadata before navigation; Preferences exposes accessible Light and Dark choices; keyboard changes, real reload persistence, reset and light/dark dialog accessibility are verified; Application theme override is explained in Preferences without erasing the saved browser appearance choice; Pivot validation meets text contrast in default light, default dark and a configured orange error theme; API playground visible text and hovered controls meet contrast in light/dark themes; Preferences changes update the rendered palette without reload | | AcceptanceSampleServer /acceptance/theme; reset removes the theme before each test | pass (chromium) | [QRun-IO/qqq-frontend-next#719](https://github.com/QRun-IO/qqq-frontend-next/issues/719) |
## Performance budgets on the production export: large tables, heavy dashboards and long processes (QRun-IO/qqq#710). Bundle budgets are checked separately by scripts/perf-budget.mjs.
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| PRF-001 | large table: a 10,000-row, 40-column table opens with every column and the exact first page within budget | all 40 columns have a sort header; the first 25 rows carry the exact computed values; the total reads 10,000; time from navigation to the first page is within PERFORMANCE_BUDGET.firstPageMs | only one page of rows is in the DOM (25 rows, 1,000 cells), not the table | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | page size 250 shows ids 10000..9751 with 10,000 cells within PERFORMANCE_BUDGET.pageMs; next page shows 9750..9501 and last page 250..1 (page 40), each within PERFORMANCE_BUDGET.pageMs; the backend is asked for skip/limit 250 | the DOM never holds more than one page (250 rows) | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | sorting by Name ascending shows Row 00001..Row 00025 within PERFORMANCE_BUDGET.queryMs; Count 04 equals 0 narrows to the 40 matching rows (the SQL answer) within PERFORMANCE_BUDGET.queryMs | | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-004 | heavy dashboard: 24 widgets (statistics, bar, line and pie charts, 100-row tables) load once each within budget | every widget finishes loading within PERFORMANCE_BUDGET.dashboardMs of navigation; statistics show their exact counts, chart data tables their exact points, tables their 100 rows | each widget's data is requested exactly once | tests/acceptance/fixture/PerformanceFixtures.java prfDashboard | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result promptly | progress messages advance while the job runs; the result shows Row Count 10000 and the SQL total; the result appears within PERFORMANCE_BUDGET.processResultMs of the job finishing | status is polled no more than once per POLL_INITIAL_MILLIS (1.5 s) and never concurrently | tests/acceptance/fixture/PerformanceFixtures.java prfLongRun | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
## Processes: steps, components, lifecycle, bulk load/edit/delete
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list results render | recordIds 1,2 -> setup prefix/suffix -> results view values and both greeted rows | | sample greetInteractive | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#656](https://github.com/QRun-IO/qqq/issues/656) |
| PRC-002 | All-records run from a filter selection with no criteria | filterJSON {criteria:[]} greets every seeded person (count checked against SQL) | | sample greetInteractive | pass (chromium) | |
| PRC-003 | Zero-record runs from a filter that matches nothing | clonePeople input 0, validation on 0 with no lines, 0 processed | greetInteractive record-list step with zero records is refused ("Missing input records.") | sample greetInteractive, clonePeople | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-004 | Processes launched from an app (no records) | app shortcut opens Component Lab without records | greetInteractive without records is refused at init ("Missing input records.") | prcLab app, sample greetInteractive | pass (chromium) | |
| PRC-005 | Process launched from selected rows of a table query | select Avery and Casey, Actions -> Greet Interactive -> both greeted | | sample person + greetInteractive | pass (chromium) | |
| PRC-006 | Minimum and maximum input records | 1-2 selected records run and are listed | 0 or 3 explicit records show a message without init; filter selecting 5 shows the backend refusal; API filter selecting 0 is refused | prcBounds (min 1, max 2) | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | HELP_TEXT, HELP_TEXT, VIEW_FORM, EDIT_FORM, HTML, EDIT_FORM in order | | prcComponents mixed screen | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | plain lines; Show/Hide preview text | | prcComponents | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | Lab Inputs section holds only its fields; values saved (SQL prc_lab_run) | | prcComponents | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-010 | Required input validation on screens and in the backend | filled required field proceeds | empty required field blocks without a request; API step without the required input is refused | prcComponents | pass (chromium) | |
| PRC-011 | Process possible-value fields | options from the process route; chosen id saved, label shown on review; search filters options | | prcComponents labColor (prcColor enum) | pass (chromium) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
| PRC-012 | VIEW_FORM labels and backend values | Introduction and Status values | | prcComponents | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-013 | HTML component renders the step html value, sanitized | markup rendered | script and onerror removed, never executed | prcComponents mixed.html | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-014 | DOWNLOAD_FORM delivers the generated file | download bytes equal the generated file | unregistered file path refused (403) | prcComponents compute step | pass (chromium) | |
| PRC-015 | Step help content for process screens | help text shown | | prcComponents helpContent (PROCESS_SCREEN) | pass (chromium) | |
| PRC-016 | Back to the backend back step with entered values | Back restarts at the back step with values (possible-value label shown); resubmitting saves again | no Back without a backend backStep | prcComponents review, prcWizard longDetails, greetInteractive | pass (chromium) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#667](https://github.com/QRun-IO/qqq/issues/667) |
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | short route drops a screen from the stepper; values logged (SQL) | | prcWizard | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | field relabelled and required on the later screen | empty relabelled required field blocks | prcWizard long route | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-019 | Async job progress (message, N of M, progress bar, updated time) then continue | RUNNING progress shown while polling; finished screen and SQL log | | prcProgress | pass (chromium) | [#657](https://github.com/QRun-IO/qqq/issues/657) |
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | confirm dialog; cancel step row (SQL) from a screen and while working | Stay on Page keeps the run | prcProgress cancelStep | pass (chromium) | |
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | user-facing message; internal detail toggle; expanded details meet contrast requirements in light, dark and application themes; simpleThrow "I always throw."; Close returns to the app | | prcFailures, prcComponents, sample simpleThrow | pass (chromium) | |
| PRC-022 | Retry after an error restarts with the original record selection | retry loads the selected records 2 and 4 | | prcFlaky (fails once) | pass (chromium) | [#658](https://github.com/QRun-IO/qqq/issues/658) |
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | Submit on the step before the last screen; Return only on the last screen; noMoreSteps ends early | | prcComponents, prcWizard, prcEarlyFinish | pass (chromium) | |
| PRC-024 | Linear stepper and step position | all step labels; current step marked; Step i of n | | prcComponents | pass (chromium) | |
| PRC-025 | RECORD_LIST paging and page size over process records | 1-10, 11-20, 21-23; page size 25 | next disabled on the last page | prcManyRows (23 generated records) | pass (chromium) | |
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | Input: 2 Person records.; radio switches the button label; preview 1 of 2 / 2 of 2 | | sample clonePeople | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | four lines with counts and messages; link filters to the refused record | nothing persisted by validation | sample clonePeople + inserted clone persons | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-028 | Process summary results after execution and persisted effects | result lines and processed count; clones persisted (SQL); skip validation executes immediately | refused and nested records not cloned | sample clonePeople | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-029 | Bulk edit: switched-on fields only, review and result | edited fields persisted for selected records only (SQL); result lines | no switched-on field blocks without a request | enricher person.bulkEdit | pass (chromium) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-030 | Bulk delete: review, validation and result | selected records removed (SQL) | validation deletes nothing | enricher person.bulkDelete | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | uploaded CSV mapped and inserted (SQL) | missing required file blocks | enricher person.bulkInsert + prcUploadArchive | pass (chromium) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) [#543](https://github.com/QRun-IO/qqq/issues/543) |
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | Back from review keeps the mapping; Back from mapping to upload | unmapped required column blocks without a request | enricher person.bulkInsert | pass (chromium) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-033 | Bulk edit with a file matched by key fields | rows matched by Id updated (SQL) | | enricher person.bulkEditWithFile | pass (chromium) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-034 | Upload instructions HTML with downloadable template | template CSV downloads with the header row | | enricher person.bulkInsert upload.html | pass (chromium) | |
| PRC-035 | Named WIDGET fetched with the process UUID and values | widget text built from process values | | prcWidgets prcHtmlWidget | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-036 | Named composite WIDGET seeded from process values | seeded blocks rendered, no fetch | | prcWidgets prcCompositeWidget | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-037 | Ad hoc composite WIDGET: interpolation, conditional blocks, action buttons, submit-on-enter inputs | Approve submits actionCode (SQL); Enter submits the scan code (SQL) | conditional block hidden | prcWidgets ad hoc blocks | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-038 | GOOGLE_DRIVE_SELECT_FOLDER screen keeps its other components working | help text and edit form submit (SQL); picker explains it is not configured | required export note blocks | prcDrive | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-039 | Google Drive folder selection through the Google Picker | pick a folder and submit its id | | needs GOOGLE_APP_CLIENT_ID, GOOGLE_APP_API_KEY and a Google account | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): Google Drive folder picker needs a real Google OAuth client and account; the rest of the step is covered by PRC-038; real-service testing is follow-up work.) | |
| PRC-040 | Reload mid-process starts a fresh run | new process UUID, first screen, empty inputs; earlier effects kept (SQL) | | prcComponents | pass (chromium) | |
| PRC-041 | Expired session mid-process returns to sign-in | 401 redirects to login | nothing saved | prcComponents + expired persona | pass (chromium) | [#668](https://github.com/QRun-IO/qqq/issues/668) |
| PRC-042 | Process permission enforcement | revoked permission shows the permission message | step refused, nothing saved; direct link offers no process; API init refused (403) | prcComponents + noProcesses persona | pass (chromium) | |
| PRC-043 | Default process values from the link (defaultProcessValues) | sleepMillis preset drives an async sleep to screen1; labName prefilled; normal navigation shows a required sleep duration with a 1000ms default and completes to screen1 | clearing sleep duration blocks submission on screen0 | sample sleepInteractive, prcComponents | pass (chromium) | |
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | auto-focused input submits on Enter (SQL) | no Next/Cancel buttons rendered | prcScanner (format scanner) | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | screen repeats with fresh inputs and counted rounds; noMoreSteps ends with Return | no stepper or Step i of n | prcLoop (STATE_MACHINE) | pass (chromium) | |
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | Save As stores the mapping (SQL); choosing the profile restores the mapping and is named on review; Delete removes it (SQL) | a duplicate name is refused | SavedBulkLoadProfileMetaDataProvider on owned saved_bulk_load_profile tables | pass (chromium) | [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-047 | A run with no frontend screens completes with a completion screen | completion heading and message; backend effect persisted (SQL) | | prcQuickTask (backend step only) | pass (chromium) | |
| PRC-048 | Process-field possible values load and search through the v1 API | the process combobox loads its options from the v1 process route; the chosen value reaches the process and persists; a v1 search returns the exact match | an unknown process field is 404 | prcComponents | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| PRC-049 | Processes run on the v1 process routes (upload, step, back step, records, metadata) | a bulk load uploads its file, maps, reviews, steps back (isStepBack=true) and inserts through /qqq/v1/processes/... and /qqq/v1/metaData/process/...; the inserted row is in the database; this session can cancel its own run | an unsafe upload filename is 400 before anything is stored; an unknown run is refused; (another session's run is refused 403 by records and cancel - backend unit tests) | person.bulkInsert, prcComponents | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) [#543](https://github.com/QRun-IO/qqq/issues/543) |
| PRC-050 | Processes the instance adds to every query and record screen (Material processNamesToAddToAllQueryAndViewScreens) | v1 /metaData names the process in supplementalInstanceMetaData.materialDashboard (the only supplemental entry v1 publishes; the unversioned route's javalin server configuration stays off v1); casey receives it with no table and in no app (API); the person query Actions menu lists it last, after a divider, and runs it on the selected rows with tableName=person (SQL); the record actions menu of qryBin (no processes of its own) runs it on that record (SQL); the phone record action sheet runs it on that record (SQL) | a user whose metadata omits it (alice): not offered on query or record screens and its run page is not found (no SQL rows); noProcesses persona: absent from the metadata, not offered on query or record screens, and the backend refuses it (403, no SQL rows); a user whose metadata omits the process gets an empty v1 processNamesToAddToAllQueryAndViewScreens list (no hidden process names); the v1 and unversioned init routes refuse the process for a user without permission | ProcessesFixtures.java prcTagRecords (no table, in no app) named by MaterialDashboardInstanceMetaData; a metadata customizer lists it only for the sample user casey, so other areas keep their menus; person, qryBin | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | every search posts the screen values as the values map of the v1 JSON body; choices narrow to the chosen Category and change when it changes; the chosen id is recorded (SQL) and its label shows on the result screen | Category empty: the unset variable makes no matches for a form, shown as No options found; the backend refuses an out-of-filter id lookup and returns only the filtered choices | prcSpecimenPick specimenId (prcSpecimen source filtered on category = ${input.category}) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| PRC-058 | Saved bulk load profile menu (Material SavedBulkLoadProfiles): new profile, update after the confirm, rename, save as a copy, delete, unsaved-change count with its list, Reset All Changes, New Bulk Load Profile, Reset to Empty or Suggested Mapping | a new profile is named in the Save New Bulk Load Profile dialog and Enter saves it (SQL row owned by the user); the heading names it; a change shows 1 Unsaved Change with the list of changes; Save asks Update Existing Bulk Load Profile and Enter confirms; the same row holds the new mapping (SQL); Rename keeps the row id under the new name; Save As stores a separate copy, which is then in use; a saved profile's changes are listed (header row, file column) and Reset All Changes restores its mapping; New Bulk Load Profile leaves the profile for an empty mapping; Reset to Suggested Mapping and Empty Mapping replace the mapping, and the suggested one is what the review screen shows | Enter in the delete dialog does not delete (SQL count unchanged); only the Delete button does, and the screen starts a new, empty mapping | enricher person.bulkInsert; SavedBulkLoadProfileMetaDataProvider on owned saved_bulk_load_profile tables | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-059 | Saved bulk load profile ownership: yours vs shared with you, and only the owner may save, rename or delete a profile | the query process returns both profiles with their owners; the menu lists alice's under Your Saved Bulk Load Profiles and bob's under Bulk Load Profiles Shared with you; on bob's profile Save, Rename and Delete are disabled with the not-owner reason, the changes list says why, and no Save link is offered; Save As is enabled; alice saves her own copy (SQL row owned by sample:alice) | bob's row is unchanged (label, owner and mapping, SQL) and no store or delete request named it; the backend has no ownership lock on saved bulk load profiles, so the UI is the only guard, as in Material | sample users alice and bob; bob's profile stored through storeSavedBulkLoadProfile and shared READ_ONLY with alice in shared_saved_bulk_load_profile | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-060 | Bulk load file mapping form fidelity: field help, column tooltips, repeated-header warning, header-row toggle re-mapping by header name, scroll to the first error, bulk-edit Key Fields / Fields To Update and the unmapped key field error | help for the header row, layout and table key fields shows under each and describes its control; a mapped column's tooltip names its fields; a repeated header is flagged and not offered as a column; without a header row columns are letters; a mapping to the repeated header is cleared with a warning when headers come back; off and on again the step posts header names (First Name, Last Name, Email) and the first Email column is inserted (SQL); an unmappable submit brings the first field error into view; bulk edit shows Key Fields (Select table key fields to continue.) and Fields To Update (Clear if empty only there); choosing Id names it as an unmapped key field until a column is chosen; the matched records update (SQL) | no step request is sent while a field has no column | enricher person.bulkInsert and person.bulkEditWithFile; ProcessesFixtures.primeBulkLoadHelp help content keyed process:person.bulkInsert;field:hasHeaderRow\|layout and process:person.bulkEditWithFile;field:tableKeyFields | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-061 | Typed bulk load default values, searchable value mapping and the grouped, searchable Add Fields menu (Material QHierarchyAutoComplete) | the Person default value is chosen by searching person possible values and shows its label; Birth Date takes a date; Is Employed takes Yes or No; the Add Fields menu groups Pet and Pet Note, searches, disables mapped fields with a tooltip and adds the WIDE note field twice (Pet Note: Note (1) and (2)); species values are mapped by searching the species possible values; the pets, their person, species and birth date and both notes each are inserted (SQL) | a default value left empty is refused with A value is required. before any step request | enricher pet.bulkInsert (pet with its notes association, WIDE layout) and person.bulkInsert | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-062 | Composite INPUT_FIELD metadata editors share process validation and multipart submission | Choose a labeled possible value, edit currency and multiline code, update a widget filter without changing columns, upload BLOB and FILE_UPLOAD data and verify backend-read content; Desktop wheel scrolling over a plain numeric block preserves its value; intentional keyboard edits and touch input changes are returned by the backend process review | Required uploads block submission; code-editor Enter does not submit the process | prcBlockEditors | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-063 | INPUT_FIELD autofocus and trimmed Enter values on subsequent process screens | Plain and metadata editors receive configured focus, placeholders and previous process values; Enter saves trimmed text verified in SQL; ordinary result screens receive heading focus | First blur does not show premature validation; required whitespace cannot advance either input screen | prcInputFocus | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | Unchanged seeded timestamps preserve seconds and instant across plain/shared blocks and regular form fields, including the repeated autumn DST hour; Edited timestamps crossing daylight saving submit UTC values verified from backend SQL; TIME inputs in plain/shared blocks and forms retain seconds for unchanged and edited submissions, verified through SQL readback. | Zoned values must not disappear from datetime-local controls or lose their timezone on submission | prcInputDateTimes with browser timezone America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-065 | Configured Google Drive shared-folder selection and process submission | Select a shared-drive folder and submit ID, name, token and other required input through the real process; independently verify database outcome | Reject a file and retry; cancel a replacement selection clears the destination and token; missing required note cannot advance | prcDrive with owned Google SDK script responses and real configured backend CSP | pass (chromium) | [#704](https://github.com/QRun-IO/qqq/issues/704) |
| PRC-066 | INPUT_FIELD case behaviors preserve caret and submitted process values | Backend uppercase/lowercase metadata transforms a middle-of-value edit without moving the caret; Enter and Submit persist exact values verified through independent SQL; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Submit persists the exact transformed text through independent SQL; Unicode case expansion preserves continued typing in STRING and TEXT fields; submitted values are verified through independent SQL; Explicitly configured PASSWORD case behaviors preserve masking and show/hide values; Enter and Submit persist Unicode-expanded values verified through independent SQL | Typing alone does not submit or persist the process; Multiline Enter does not submit or navigate away | prcInputCase / prcTextCase | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
## Querying, relationships, saved views, export and bulk selection
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | Sort matches SQL; Pin right is reflected in the grid; Filter opens the field editor; Hide removes the column | | QueryFixtures.java qryItem | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
| QRY-091 | Column menu copies page or full query values in backend sort order | Page copy contains the first ten ids; Full copy contains all eleven ids | | sample carrier (11 rows) | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
| QRY-092 | Column chooser searches, groups, counts and marks changed layouts | Search narrows fields; Groups collapse and expand; Hiding a field marks the columns dirty; Keyboard opening focuses the popup and Tab reaches search; Explicit close and Escape restore Columns focus; outside click keeps the clicked control focused | Unknown search shows an empty message | QueryFixtures.java qryItem | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
| QRY-093 | Last saved view and unsaved edits survive reopening and reload | Dirty filter survives reload; Table route opens the last saved view; Reset restores the backend view; Displayed filter updates the URL before a delayed page navigation completes; immediate reload preserves it, along with unrelated parameters, hash and history length | | sample Alice People View | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-094 | Counted quick views render and open their saved-view route | Count reflects the backend filter; Quick-view button opens the filtered view | | sample Alice People View marked as a quick view in H2 | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-095 | A new query defaults to fifty rows per page | Fifty visible rows; Pagination reports fifty of the full count | | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-096 | A saved view reconciles removed fields and warns the user | Obsolete criteria and sort are removed before querying; Warning names the removed field; Heading names the active saved view | | sample Alice People View with retiredField references in H2 | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | Headers use field labels; Rows equal SQL select ordered by id desc; Possible-value cells show labels, booleans Yes/No, money/decimals formatted; Original Next uniform row backgrounds in light and dark mode, with opaque pinned cells and selection highlighting | Null boolean renders empty, not No; Hidden and heavy (TEXT) fields are not columns | QueryFixtures.java qryItem (8 rows, every field type) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-002 | Pagination with Material page sizes and URL state | Page 2 shows the remaining rows; Page sizes 10/25/50/100/250; Page and size survive reload via URL | Next is disabled on the last page | sample (person, pet, petNote, carrier, petSpecies) carrier (11 rows) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | Ascending and descending match SQL order by; Sort persists across reload | Third click returns to the PK-descending default | QueryFixtures.java qryItem | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-004 | Column visibility and order persist per table | Hide/show and reorder columns; Order and visibility survive reload; Show/hide all per table group | Another table keeps its own columns | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-005 | Column widths, density and card view | Resized width persists; Density changes row height; Card view shows the same records | Keyboard resize separator exposes its value | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | Filter, sort and page are restored after Back; Refresh shows a row changed through the API | No stale cached rows after refresh | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-007 | A failed query reports the backend error with a retry | Backend error text shown; Retry reloads after the failure clears; Query error text and the front error notification meet text contrast in light, dark and configured application themes; Retry repeats the backend request; hover remains readable; Dismiss hides the error; resetting the invalid filter restores SQL-verified records | No rows from the failed request are shown | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-010 | Text operators match Material labels and backend semantics | equals, does not equal, contains, starts/ends with, is any of, is like, is empty/not empty; results equal SQL | does not equal includes empty values (NOT_EQUALS_OR_IS_NULL); Incomplete criteria are not sent | QueryFixtures.java qryItem.name/code | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-011 | Number operators including ranges and lists | equals, greater/less (or equal), is between, is any of; results equal SQL; Values sent as numbers | not between and none of exclude the bounds/list | QueryFixtures.java qryItem.quantity/price | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-012 | Date operators and relative date expressions | is/after/before/between with dates; today, N days ago, start of this/last month send backend expression JSON and match SQL | Clearing an expression restores a plain value input | QueryFixtures.java qryItem.receivedDate (relative to today) | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-013 | Date-time operators convert local input to UTC and support relative values | Local input sent as UTC instant; now and N hours ago expressions match SQL | Grid shows formatted date-times, not raw ISO | QueryFixtures.java qryItem.checkedAt | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#679](https://github.com/QRun-IO/qqq/issues/679) |
| QRY-014 | Boolean, blob and long-text operators | equals yes / equals no / is empty for booleans; blob is empty / not empty; long text contains | Blob offers no value operators | QueryFixtures.java qryItem.isActive/photo/notes | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | is / is any of / is none of with label chips; Link-loaded ids render labels; Results equal SQL | Search with no match shows no options | QueryFixtures.java qryItem.ownerId (person), speciesId (petSpecies enum) | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#667](https://github.com/QRun-IO/qqq/issues/667) |
| QRY-016 | AND/OR and nested groups combine as declared | AND group, OR group and nested subfilter match SQL | Removing the last criterion clears the filter | QueryFixtures.java qryItem | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | Reload keeps the filter; Back/forward switches filters; Plain JSON filter link opens filtered | Malformed filter param falls back to no filter | QueryFixtures.java qryItem | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-018 | Backend-only operators from links render and filter correctly | Each operator from a link renders its label and returns the SQL rows | FALSE matches no records; builder does not crash | QueryFixtures.java qryItem | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-019 | Quick search combines with the advanced filter | Search term ANDs with Is Active = yes | Clearing search restores the filtered rows | QueryFixtures.java qryItem | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | Join column 'Owner: First Name' hidden by default, shown on demand; Join filter and sort match SQL LEFT JOIN | Joins only sent when used; rows without a match are kept | QueryFixtures.java qryItem -> person (MANY_TO_ONE) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | Repeated rows per note; Count shows (N distinct) | Row selection dedupes repeated ids | QueryFixtures.java qryItem -> qryItemNote (ONE_TO_MANY) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-022 | Column statistics aggregate the filtered rows | Value distribution and count/distinct/sum/avg/min/max equal SQL under the filter | Sort by value/count | QueryFixtures.java qryItem (QUERY_STATS) | pass (chromium) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-023 | Tables without QUERY_STATS offer no column statistics | No statistics button on headers | Process not launched | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-030 | Selection menu: this page, full query result, first N, clear | Banners and counts for page, all and subset; Touch opens and toggles the density menu without synthetic trigger focus dismissing it; Enter, Escape and outside search focus retain their normal behavior; Pinned selection targets remain exposed and respond at both edges for every density | Subset dialog rejects invalid N | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-031 | Bulk Edit launches with the checked record ids | recordIds param equals checked rows | No selection shows 'No records were selected to Bulk Edit.' | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-032 | Bulk Delete of the full query result sends the whole filter | filterJSON has no skip/limit; backend recordCount equals SQL count | Current page paging is not sent | sample (person, pet, petNote, carrier, petSpecies) carrier (page 2) | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-033 | First-N selection under a filter launches with that limit | filterJSON limit N with the filter and sort | N larger than the result is capped | QueryFixtures.java qryItem | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | Bulk Load/Edit/Edit With File/Delete then table processes; Launch carries the selection | Process min/max record checks block launch with a message | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#662](https://github.com/QRun-IO/qqq/issues/662) |
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | No Create, bulk or write process entries | Direct insert/delete API calls return 403 | viewer persona on sample (person, pet, petNote, carrier, petSpecies) | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#671](https://github.com/QRun-IO/qqq/issues/671) |
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | File name, headers and rows equal SQL; Join columns exported | Hidden columns excluded | QueryFixtures.java qryItem | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-041 | XLSX and JSON exports carry the same rows | XLSX sheet and JSON array equal SQL rows | JSON keys limited to exported fields | QueryFixtures.java qryItem | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-042 | Nothing to export disables the formats | Empty filter result disables CSV/XLSX/JSON | No export request sent | QueryFixtures.java qryItem | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-043 | Backend refuses exports of unreadable tables | Export of a readable table succeeds | Export of pet returns 403 without data | noPets persona | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-050 | Your saved views are listed and open on their own route | 'Your Saved Views' lists backend views; /savedView/{id} applies filter/columns | Empty group message | sample sharing demo (Alice People View) | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-051 | Saving a new view stores filter, sort, columns and page size | saved_view row viewJson matches Material RecordQueryView | Unsaved-changes indicator clears after save | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-052 | Update, rename and delete your view | Update keeps unrelated view JSON; Rename and delete change the saved_view row | Reset All Changes restores the stored view | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-053 | Duplicate names and unknown views are reported without changes | Duplicate name error shown in the dialog | Unknown view id shows an error; saved_view unchanged | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | Listed under 'Views Shared with you' for bob; Opens with its filter | Owner-only actions disabled; storeSavedView by bob refused | QueryFixtures.java (Alice People View shared with sample:bob READ_ONLY) | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | Picker lists /variants; Rows equal the chosen store; Choice remembered and changeable; export uses it | Filter narrows options | QueryFixtures.java qryStock on qryVariantMemory (qryStore) | pass (chromium) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | Prompt shown with a choose button | No query sent; API query without a variant fails | QueryFixtures.java qryStock | pass (chromium) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
| QRY-062 | A table without count, export or write capabilities offers none of them | Rows listed without a total; Export disabled with explanation; No create or bulk actions | Backend insert refused | QueryFixtures.java qryLedger (no TABLE_INSERT/UPDATE/DELETE/EXPORT/COUNT) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-063 | Enum-backed Pet Species table is read-only | Metadata omits write capabilities; no Create or bulk writes | Insert returns 403, not 500 | sample (person, pet, petNote, carrier, petSpecies) petSpecies | pass (chromium) | [#692](https://github.com/QRun-IO/qqq/issues/692) |
| QRY-064 | Backend refuses count and export for a table without those capabilities | Query still succeeds | v1 count, legacy count and export return 403 | QueryFixtures.java qryLedger | pass (chromium) | [#693](https://github.com/QRun-IO/qqq/issues/693) |
| QRY-065 | Export streams from the v1 export route | the export menu posts {format, filename, fieldNames, filter} to /qqq/v1/table/{table}/export and downloads every row; the v1 route streams the same rows as CSV | a table without the export capability is refused (403) by the v1 route | qryItem, qryLedger | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| QRY-066 | Variant options come from the v1 variants route | the variant picker lists the stores from GET /qqq/v1/table/{table}/variants; the v1 route lists every option with its type and id; a table without variants lists none | an unknown table is refused (403) | qryStock (qryStore options) | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | v1 table metadata carries supplementalMetaData.materialDashboard.gotoFieldNames (API); Go To... on the query toolbar and the record view header offers the primary key, then each key, labelled by field labels; primary key, single-field and two-field keys each open the one matching record (EQUALS query, id from SQL); no match shows "Record not found."; a partial key matching two bins shows "More than 1 record was found..." (counts from SQL); Enter in an input submits its option; Escape closes and returns focus to the Go To button; a table with GET but no QUERY capability opens Go To automatically and it cannot be dismissed | a table without gotoFieldNames has no Go To button on its query or record screen; no navigation on no match or several matches | QueryFixtures.java qryBin (gotoFieldNames [[code], [aisle, shelf]]), qryLocker (GET without QUERY), qryItem (no Go To) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order (Material bySection order) | Headers read Id, Name, Code, Price, Quantity; Rows equal SQL select ordered by id desc | The metadata field-map order (price first) is not used | QueryFixtures.java qryOrdered (qry_item rows; fields declared price, quantity, code, id, name; sections identity [id, name, code], details [price], stock [quantity]) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | Page 5 at 250 per page reads Showing 1,001–1,234 of 1,234; The page shows exactly the last 234 ids by SQL | No unformatted range such as 1001–1234 | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | r sends a new query request and shows a value changed through the API (SQL confirms the change); f opens the Advanced Filters panel and a second f keeps it open; n opens the create form | keys typed in quick search are text (no filter panel, no navigation); viewer persona: n does nothing (f still works); the backend refuses the insert (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and return to the query | Bulk Delete of the South store's full query result counts its one record; Every init and step request carries tableVariant {type: qryStore, id: 2}; Return comes back to the variant query, which is empty for South while North keeps its two rows | Without the variant the backend could not read the variant table's records | QueryFixtures.java qryStock (memory backend keyed by qryStore variants) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | First Name quick filter finds the SQL-matching records; Type-specific defaults gate Boolean until chosen, default possible values to any-of and date-time to after; A multi-value chip summarizes extra values; A saved Basic view restores its criterion and SQL rows | No application-specific field names are embedded in the UI; Cancel discards an un-applied quick-filter draft | sample person and QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | A field offered by metadata appears as a chip after selection and reload; Joined and virtual fields query SQL-backed records | Field choice is scoped to this table | sample person and QueryFixtures.java qryItem/qryCaseItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | Repeated Name conditions disable Basic with a focusable reason tooltip; URL criteria become chips; OR criteria open Advanced with a reason | Clicking Basic does not discard either condition | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | Name and Quantity conditions filter SQL rows; deleting Name in the inline editor preserves Quantity | Removing one condition does not reset the whole query | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | Name ascending order matches SQL before and after confirmed clear; Condition status explains missing and complete values on keyboard focus | Cancel leaves conditions in place | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | Selecting a weekday sends WeekdayOfDate and returns the SQL-matching rows; Date-time weekday sends WeekdayOfDateTime with configured UTC arguments and returns matching rows | The weekday list uses names rather than bare numbers | QueryFixtures.java qryItem with dated rows and Material weekday settings; AcceptanceSampleServer.java H2 date functions | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-086 | Relative date presets expose their evaluated date and filter records | Seven days ago renders a live date and returns the recent SQL-backed rows; A date-time preset retains its relative expression, evaluates on focus and returns recent rows | A relative value remains an expression instead of fixed text | QueryFixtures.java qryItem with relative dates | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-087 | Bulk pasted values become a multi-value filter | Two pasted record names return the matching SQL-backed rows | The dialog previews each value before adding it | QueryFixtures.java qryItem | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-088 | Instance query help accompanies the bulk paste dialog | The dialog displays the configured help text | Help text comes from instance metadata | QueryFixtures.java instance-level QUERY_SCREEN help | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-089 | String filter input honors field case behavior from v1 metadata | Uppercase typing is normalized and queries the lowercase stored code | The normalized value remains visible in the input | QueryFixtures.java qryCaseItem code with TO_LOWER_CASE | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-008 | Phone card list: readable records, touch-sized selection, query selections and paging | One card per record with the SQL ids and names, values wrap instead of being cut off; Tapping the 44 px label around a card checkbox selects it without opening the record; A full-query selection checks every card; paging and rows per page work on cards; Tapping a card opens its record | No horizontal page scroll and no control below 44 x 44 px | sample (person, pet, petNote, carrier, petSpecies) carrier (11 rows), 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-009 | Phone filter sheet fits the viewport, scrolls inside and manages focus | Filter opens a modal sheet that takes focus; Many conditions scroll inside the sheet, which stays inside the viewport; Cards behind the sheet show the filtered records (SQL); Escape or Close closes the sheet and returns focus to Filter, which counts the conditions | No horizontal page scroll and no control below 44 x 44 px in the sheet | QueryFixtures.java qryItem, 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | The panel opens inside the viewport with touch-sized controls; Hiding a column removes it from the cards (phone) or the grid (tablet) and persists; Escape closes the panel and returns focus to its button | The panel never runs off the left edge of a phone | QueryFixtures.java qryItem | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-036 | Bulk actions launch from a phone card selection | Checked cards show the selection banner; The Actions menu fits the phone; Bulk Edit launches with exactly the checked ids | No horizontal page scroll while the menu is open | QueryFixtures.java qryItem, 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | The saved views menu lists your view inside the viewport; Escape closes it; The export menu fits the phone and downloads a CSV | No horizontal page scroll and no control below 44 x 44 px in either menu | sample sharing demo (Alice People View), 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| REL-001 | Possible-value cells show the related record label and link to it | Owner cell shows the person label; Link opens /app/person/{id} | Enum values are not links | sample (person, pet, petNote, carrier, petSpecies) pet.personId | pass (chromium) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | Panel rows equal SQL children; View All filters /app/pet and Back returns | Parent without children shows an empty panel | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| REL-003 | Adding a child from its parent links it and shows it immediately | New pet stored with person_id of the parent; Panel refreshes | Join field not editable; no other rows changed | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium) | |
| REL-004 | Three-level associations: each pet shows only its own notes | Notes equal SQL per pet | Other pets' notes absent | sample (person, pet, petNote, carrier, petSpecies) person -> pet -> petNote | pass (chromium) | |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | Renamed pet shown in parent panel and list; Deleting a pet removes its notes | Other rows unchanged | sample (person, pet, petNote, carrier, petSpecies) pet, petNote | pass (chromium) | |
| REL-006 | Denied child reads show an unavailable panel without data or actions | Panel says related records are unavailable | No data, View All or Create; API query 403 | noPets persona; qryShelter whose associated qryShelterPet is denied with DenyBehavior.DISABLED | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | childRecordList and rowBuilder show SQL-matching members; Composite join on code + review date | Household without members shows empty | QueryFixtures.java qryHousehold/qryMember (members, care group / primary, scheduled reviews) | pass (chromium) | |
| REL-008 | Adding through a composite association stores every join value | New member stored with household_code and review_date from the parent | Aliased association unaffected; other rows unchanged | QueryFixtures.java qryHousehold/qryMember | pass (chromium) | |
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | Person and its pets removed | Other pets and notes unchanged; list reflects SQL | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium) | |
## Records, fields, editors, adornments, validation and persistence
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | label heading; T1 header fields; T2 section cards; currency, grouped integer, Yes/No, date | values match independent SQL readback | prime-test-database.sql (person 1) | pass (chromium) | |
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | NULL date shows an em dash; hidden field absent from view and from the GET payload; hidden section not rendered | hidden value stays stored (SQL) | person 4 (NULL birth date); recordLab hiddenCode + hidden legacy section | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-003 | Record view of a missing record shows Record Not Found and a way back | not-found panel; back to the table list | 404 read is the only failed request; no generic error toast | person 999 (absent) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-004 | Record view tabs and list view expose every visible section | Overview shows all T2 sections; Overview field rows retain original Next typography and spacing in light and dark themes; a section tab shows only that section; list view shows every section; Arrow/Home/End keys move tab focus; Enter/Space activates a named panel with one tab stop; phone accordion supports keyboard activation | | fieldLab record created in the test | pass (chromium) | |
| REC-005 | Create form validates required fields in the browser before any request | required markers and messages for each required field | no insert request, no row | person | pass (chromium) | |
| REC-006 | Create persists the entered values and opens the new record | success toast; navigates to /app/person/<new id>; view shows the entered values | non-editable fields are not offered on create; row matches SQL readback | person | pass (chromium) | |
| REC-007 | Backend enforces required fields when the UI is bypassed | direct insert without Name is rejected with the backend message | no row written | fieldLab | pass (chromium) | |
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | clean cancel returns immediately; dirty cancel asks Stay/Leave; Stay keeps typed values; Leave discards | no insert or update request; no row created or changed | person | pass (chromium) | |
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | fields prefilled from the record; Save disabled until a change; view shows the new value without reload | only the changed field is submitted; other columns unchanged in SQL | person 1 | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-010 | Edit shows non-editable fields read-only and never submits them | read-only controls with the stored values | controls are disabled; create date unchanged in SQL after save | person 1 (id, createDate, modifyDate) | pass (chromium) | [#680](https://github.com/QRun-IO/qqq/issues/680) [#537](https://github.com/QRun-IO/qqq/issues/537) |
| REC-011 | Edit of a missing record shows the load failure instead of a form | error message, no form fields | 404 is the only failed request | person 999 | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | dialog names the record; Cancel keeps it; Delete removes it and returns to the list; success toast | no Resource not found toast; no 404 read of the deleted record; row gone in SQL | person 5 | pass (chromium) | [#541](https://github.com/QRun-IO/qqq/issues/541) |
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | source values prefilled without the key; Full Copy loads the descendant draft on selection; returning to Base Copy preserves the base form; save creates a new row and opens it; source unchanged | unique-key conflict on copy shows the backend message and keeps the form; download-URL files are not copied | person 1; recordLab 1 | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | single-line input; multi-line textarea keeps line breaks in view and SQL | | fieldLab | pass (chromium) | |
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | LONG beyond 2^53 saved and shown exactly; DECIMAL with 4 places | fractional LONG rejected in the browser; backend rejects a fractional LONG sent directly | fieldLab | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | create form starts at the default (true); switching to false persists false; view shows Yes/No | unchanged boolean is not rewritten on edit | fieldLab (booleanValue default true) | pass (chromium) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-017 | DATE and TIME editors round-trip values and clearing | date input value and view; time input value and backend 12-hour display; clearing a date stores NULL | | fieldLab | pass (chromium) | |
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | typed local time stored as the UTC instant; view shows yyyy-MM-dd hh:mm:ss AM EDT; edit form shows the local time | unchanged date-times are not rewritten; non-editable date-times read-only | fieldLab; browser zone America/New_York | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | fixed America/Chicago display; display in the record's own timeZone; daylight-saving boundary | invalid zone falls back to UTC | fieldLab | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | formatted text rendered on the view | script content is not executed | fieldLab htmlValue | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | view shows the mask; typing a new password saves it | editing another field keeps the stored password (SQL) | fieldLab passwordValue | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| REC-022 | BLOB upload and download without a download adornment | file chosen on create is stored byte-for-byte; view offers the bytes as a download | | fieldLab blobValue | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | user id and dates filled on create; modify day updated on edit, create day kept | NONE leaves the value empty | fieldLab | pass (chromium) | |
| REC-024 | Max-length policies: truncate, ellipsis, pass-through and reject | truncate, ellipsis and pass-through stored as declared | reject shows the backend message, keeps the form and writes nothing | fieldLab (maxLength 8) | pass (chromium) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-025 | Case and whitespace normalization on save | upper, lower, trim variants, remove-all-whitespace and unchanged stored as declared | | fieldLab | pass (chromium) | |
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | clip to 0/100 and 0.01/99.99 | out-of-range and exclusive-boundary values show the backend message and write nothing | fieldLab | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-027 | Unique keys are enforced with the backend message on create and edit | distinct values save | duplicate Name on create; duplicate normalized key on edit; form keeps values, row unchanged | fieldLab (name, normalizedKey) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-028 | LINK adornment: record links and URL links | toRecordFromTable opens the referenced record; URL opens in a new tab (target _blank) | a non-http value is not turned into a link | recordLab (ownerId, website) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-029 | CHIP adornment colors and icons by value | label is the display value; color from color.<value>; icon from icon.<value> | undeclared value uses the default color | recordLab status | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-030 | SIZE adornment sets the query grid column width | column is 100px wide | undeclared columns keep the default width | recordLab shortCode (width small) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | view shows the code and its language; Format JSON / Reset Format; editor saves edited code | invalid JSON reports a formatting error | recordLab config (json) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-032 | RENDER_HTML adornment renders sanitized HTML | markup rendered | script removed and not executed | recordLab htmlNote | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-033 | REVEAL adornment masks, reveals and copies the value | masked by default; reveal shows the exact stored value; hide masks again | | recordLab apiToken (PASSWORD + REVEAL) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | file name from the companion field; file name from the format; open and download return the stored bytes | record without a file shows no link | recordLab attachment (heavy, fileNameField) and notesFile (fileNameFormat) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | drag-and-drop zone and button formats; current file shown on edit; new file replaces bytes and file name | removing the file clears bytes and name | recordLab attachment (dragAndDrop), notesFile (button) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-036 | TOOLTIP adornment shows its text on the value | tooltip on hover and keyboard focus | | recordLab hint (staticText) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#685](https://github.com/QRun-IO/qqq/issues/685) |
| REC-037 | WIDGET adornment renders the field value as the named widget | widget content from the record | the value is not offered as an editable field | recordLab summaryWidget | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-038 | ERROR adornment highlights the value | value shown with error styling | | recordLab problem | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-039 | Field help content follows screen roles and formats | view: READ_SCREENS text and VIEW_SCREEN HTML; edit: WRITE_SCREENS markdown rendered and EDIT_SCREEN text; create: INSERT_SCREEN text | help for other screens is not shown | recordLab title/website/ownerId help | pass (chromium) | [#681](https://github.com/QRun-IO/qqq/issues/681) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| REC-040 | Section help content on the record view | HTML help under the section heading | | recordLab presentation section | pass (chromium) | [#681](https://github.com/QRun-IO/qqq/issues/681) |
| REC-041 | Table help content slots are delivered in table metadata | v1 table metadata carries the slot help | | recordLab summary slot | pass (chromium) | |
| REC-042 | Audit history lists who changed what, newest first | edit then open Audit: message, user and per-field sentences; audit rows independently verified in SQL | unaudited record shows no audits | recordLab (FIELD audits) | pass (chromium) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-043 | Audit history without process permission reads the audit table | same entries via the audit table query | no process request is made | recordLab; persona noProcesses | pass (chromium) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-044 | Record label format is used for the record's title and dialogs | heading; delete dialog; audit title | | recordLab (Lab: %s) | pass (chromium) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | edit shows the stored label, not the id; create default shows its label; keyboard search and select saves the id | | recordLab status/ownerId | pass (chromium) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | no Edit, Copy or Delete; create and edit URLs show the permission message | direct insert, update and delete are 403 and change nothing | persona viewer | pass (chromium) | |
| REC-047 | Forms are labelled and keyboard operable | every control has a label; keyboard-only create saves the record | invalid fields expose aria-invalid and a described error | person | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-048 | Server validation errors show the backend message once and keep the form | alert and toast show the backend message; typed values remain | no generic Something went wrong toast | fieldLab | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-049 | pages of a table outside the app tree use the table label in the document title and breadcrumbs | create page title is "Create Scheduled Report \| Scheduled Report \| QQQ Sample"; record view title is the record label, then Scheduled Report | the table name (scheduledReport) is not shown in the breadcrumbs | scheduledReport (memory, not in the app tree) for savedReport 1 | pass (chromium) | [#707](https://github.com/QRun-IO/qqq/issues/707) |
| REC-051 | Table-field possible values search and resolve through the v1 API | owner search sends a v1 POST with the search term and lists the exact match; ids and labels resolve to the exact option; the chosen owner persists | a source table the persona cannot read is refused (403) by the v1 route; an unknown field is 404 | recordLab | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| REC-050 | Record create, view, edit and delete run on the v1 record routes | the create/edit/delete flow sends POST, GET, PATCH and DELETE to /qqq/v1/table/... and persists each step; record-v1 associations are written and read back with includeAssociations | a missing required field is a 400 with the backend message and nothing is written; a read-only persona is refused (403) insert, update and delete on v1 and the row is unchanged | person, fieldLab, qryHousehold | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | #audit opens the audit dialog; closing it clears the hash and a reload does not reopen it; #/launchProcess=person.bulkEdit replaces the URL with the run for the record (init recordIds=2); Cancel returns to the record; #employmentInfo selects the Employment Info tab and scrolls it into view; a later hash change selects Dates | | sample person, person.bulkEdit | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues=/disabledFields= prefills and locks | the backend-format #/createChild=pet link on person 3 opens Add Pet with Person preset and disabled; Create closes the dialog, clears the hash, submits personId=3 and SQL shows the pet linked to person 3; /app/pet/create#/defaultValues={name, personId}/disabledFields={personId} prefills both, locks Person and saves them (SQL); /app/pet/create#defaultValues={speciesId} (no slash) prefills Species without locking | locked fields are disabled; unlocked preset fields stay editable | sample person -> pet (personId), petSpecies | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the form's current values | every search posts the form values as the values map of the v1 JSON body; create: choices narrow to the chosen Category and change when it changes; edit: the stored Item label shows, a new Category changes the choices; the chosen id persists (SQL) and the record view shows its label | Category empty: the unset variable makes no matches for a form, shown as No options found; an item outside the filter is not offered, even when searched by name; the backend refuses an out-of-filter id lookup and returns only the filtered choices | recPvPick itemId (recPvItem source filtered on categoryId = ${input.categoryId}) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | developer page for a table keeps its metadata sections and shows the API Docs & Playground section; with no application APIs it shows "This table is not available in any APIs." and no API or Version selectors; OpenAPI download and new-tab buttons remain fully visible before interaction at desktop, phone and 320px widths in light/dark modes; pointer/touch downloads yield the real specification and keyboard navigation opens its URL; specification labels fit wider application font metrics at desktop and phone widths without content clipping | a viewer persona sees the same empty state and cannot fetch any table spec | recordLab has no application APIs; person is exposed through the owned Acceptance API | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script creation | versions list with CURRENT on the current revision and per-file code for the selected version; an older version offers Edit and Activate; save a new version with a commit message (SQL: new script_revision row, its file and script.current_script_revision_id; persists after reload); test run output and log lines equal the backend testScript response; a failing run shows the error the backend returns; logs list the script_log rows of the selected version with their lines; docs show the script type help text and sample code; create a script for an empty associated script field; local QQQ helper and keyword suggestions support keyboard and pointer/touch insertion, native undo/redo, both-theme accessibility, and SQL-verified revision persistence | a viewer cannot save or test a revision (no Edit or Test offered; the backend refuses storeScriptRevision and testScript); a viewer cannot create a script (no Create Script offered; the backend refuses the create route); a table without associated scripts shows none | scriptLab (RecordsFixtures: Alpha with script 101, revisions 101-102, logs 101-102; Beta without a script) on the scripts tables from WidgetsFixtures | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | e, c and n open edit, copy and create with the right values; a opens the audit history; d opens the delete confirmation, Escape keeps the row (SQL), confirming deletes it (SQL); ? lists Create a New Record, Edit/Copy/Delete/Audit the current Record with their keys | no shortcut fires while the audit or keyboard help dialog is open; keys typed in the Jump to input are text; viewer persona: n, e, c, d do nothing (a still works); the backend refuses insert and delete (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and the phone action sheet fits, scrolls and keeps focus | The title stays on one line and never overlaps the view toggle, Audit or record actions; neither the page nor its main region scrolls sideways; The back link, value links and header controls are touch-sized (44 x 44 px) under a coarse pointer; The phone Actions sheet fits the screen, takes focus, keeps Tab and Shift+Tab inside, closes by Escape or a backdrop tap with focus back on its trigger, and runs Edit | A read-only user gets no phone Actions trigger (asserted in REC-046) | recordLab 1 (Lab: Alpha); phone (412 px touch), tablet (810 px touch) and desktop viewports | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| REC-059 | Record forms on phones and tablets: every editor type fits without sideways scrolling, controls are touch-sized, possible-value lists open on screen and the phone action bar stays reachable | Create Field Lab: no sideways scroll, every control at least 44 x 44 px by touch; on a phone Save stays on screen; boolean, date, time, text and rich text values persist; Edit Record Lab: the owner list opens inside the screen with touch-sized options; the picked owner persists | | fieldLab create (every field type), recordLab 1 edit (possible values, code editor, password reveal, file uploads) | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON record field | the region field starts from stored JSON and appears in the record edit form; changing the region persists the new JSON in inputValues and survives reload | clearing the required region blocks the write; the transient region field is absent from the table multipart payload | accWidgetHost 1 with accHostRecordVariables and hidden inputValues field | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
| REC-062 | Inline enum possible values filter by label prefix without a lookup request; CHIP adornments style options and chosen IDs persist | v1 metadata exposes the inline enum and the edit form shows the held label; a label-prefix search filters options and the CHIP adornment colors and icons them; choosing High stores HIGH in SQL and shows High after reload | a substring that is not a prefix offers no options; the picker makes no possible-values request | recordLab priority field with an inline enum and CHIP adornment | pass (chromium) | [QRun-IO/qqq-frontend-next#721](https://github.com/QRun-IO/qqq-frontend-next/issues/721) |
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | v1 table and field metadata expose the Material supplemental adjuster settings; the table onLoad route supplies a note before render; the name onChange route changes a field label and clears its value; the field rule clears status | a backend lock message disables Save for a preset name | FormAdjusterFixtures.java adjusterLab table with a table onLoad adjuster, name onChange adjuster and clear-target field rule | pass (chromium) | [QRun-IO/qqq-frontend-next#720](https://github.com/QRun-IO/qqq-frontend-next/issues/720) |
| REC-064 | Record sections render qualified fields from readable exposed joins | record GET sends a selected LEFT join with qryItemJoinPerson; the joined person's first name appears in the Details section | unreadable join paths are removed from sections and GET descriptors by the same permission filter as query pages | QueryFixtures.java qryItem joins person and shows person.firstName in Details | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | Selecting South Store shows S-KIWI on the record view and edit/copy forms; Every South record GET includes tableVariant {type: qryStore, id: 2}; Switching to North Store shows N-APPLE for the same record ID with variant id 1 | The duplicate ID must not show North Store's N-APPLE value | QueryFixtures.java qryStock (same record ID in North and South variants) | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | Unrelated edit omits unchanged timestamp; Copy sends the original zoned instant and preserves source/copy SQL timestamps | Copy must not select the first occurrence of a repeated hour or truncate fractional seconds | fieldLab; browser America/New_York; repeated DST hour with subsecond precision | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
## Permissions, sessions, authentication and interaction quality
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend refuses | noPets sees Person but no Pet / Pet Note in navigation; person record neither shows nor requests the hidden pet association | direct link /app/pet shows unknown resource with no data request; API query/get of pet returns 403, table metadata 404 | tests/acceptance/fixture/AcceptanceSampleServer.java personas (noPets) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denial without loading data | admin lists the ledger rows; noPets sees the Security Ledger link | noPets list, record, create and edit links show the permission message and request no records; API query/get/insert/update return 403 and rows are unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityLedger, base name pet, noPets) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | viewer list has no Create button and the dashboard has no Create quick action | /app/person/create shows the permission message with no form; POST /data/person returns 403 and the person count is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | viewer record view has no Edit action | /app/person/1/edit shows the permission message without loading the record; PUT /data/person/1 returns 403 and the name is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothing | record view and bulk action bar have no Delete | /app/person.bulkDelete is unknown to the viewer; DELETE /data/person/1 and bulkDelete init return 403; the row remains | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-006 | Copy requires insert permission: copy not offered and copy link refused | viewer record view has no Copy action | /app/person/1/copy shows the permission message; nothing is inserted | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-007 | Process permission denied (hidden): processes not offered in navigation or launcher, links do not start them, backend refuses | viewer has no Actions menu; noProcesses' Actions menu offers only the table's bulk actions (table rights) and no standalone process; no process links; noProcesses keeps table rights (Create offered) | /app/clonePeople and /app/greetInteractive are unknown and make no process request; legacy and v1 process init return 403; no person is cloned | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer, noProcesses) | pass (chromium) | |
| SEC-008 | Process disabled for a user (DenyBehavior.DISABLED): shown disabled, link explains the denial, backend refuses with no side effect | admin runs Security Audit and one audit row is written; viewer sees the audit tile disabled and no dashboard quick action for it | viewer direct link shows the permission message and never calls init; process init returns 403 and the audit log stays empty | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityAudit process) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-009 | Widget restrictions: hidden widgets absent, disabled widgets show a permission state without loading data, backend refuses | admin sees both pet widgets with their content; noPets sees the open bulletin widget | noPets: hidden widget absent, disabled widget shows "You do not have permission to view this data." and no /widget request; GET /widget/<denied> returns 403 | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetWidget, securityPetDisabledWidget) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-010 | App restrictions: hidden apps absent from navigation and unknown by link; disabled apps keep their children gated | admin sees both pet apps | noPets: Pet Vault App absent and /app/securityPetApp unknown; Pet Disabled App children stay permission-gated | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetApp hidden, securityPetDisabledApp disabled) | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-011 | Record-level security (sharing demo): users see only their own or shared saved views/reports; direct links and writes to others are refused | alice lists and opens her own saved view | bob's list omits alice's view; bob's direct link shows not found; bob's GET/PUT/DELETE of alice's view are refused and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (alice, bob) | pass (chromium) | |
| SEC-012 | Record security lock on a data table: list filtered to the key, other users' records unreachable, forged owner refused | alice sees and edits only her notes (persisted) | bob's direct link to alice's note shows not found; bob's update/delete of alice's note and insert with alice as owner are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityNote locked by sample user) | pass (chromium) | |
| SEC-013 | Field protection: hidden fields never shown or returned, password fields masked and never overwritten by the mask, heavy fields not loaded in lists | REVEAL password can be revealed on request; editing another field keeps the stored password | hidden value absent from list, record, edit form and API responses; masked password shows only the mask; the list query does not carry the heavy BLOB | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityVault) | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| SEC-014 | Capability restrictions: tables without insert/update/delete capabilities offer none of them and refuse direct links | admin can list and view the archive | no Create/Edit/Delete offered; create and edit links explain the table cannot be changed; backend insert/update/delete are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityArchive) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-015 | Permission revoked mid-session: stale UI actions are refused by the backend with a clear message, nothing is written, reload reflects the new rights | after reload the viewer rights are shown | saving an open edit form after revocation shows a permission error and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (admin then viewer) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | lands on the requested page with rows; user menu shows Alice (sample) / sample:alice | | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#332](https://github.com/QRun-IO/qqq/issues/332) [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per-user client data is cleared | Sign in returns to the original page | after logout a protected page (full load) stays on login and reads no data; a different user signing in on the same browser never sees the previous user's cached pages or recent records | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-022 | Session expiry: 401 redirects to login with returnTo and re-authenticates back to the page (initial load and mid-use) | expired on first load returns to the page; expired mid-use returns to the clicked record | no hang on "Setting up anonymous session" | tests/acceptance/fixture/AcceptanceSampleServer.java personas (expired) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-023 | Denied sign-in: a 401 from manageSession keeps the user out with an explanation (no silent anonymous fallback) | | login shows "Sign-in was denied", no navigation or data is rendered | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-024 | Post-login returnTo is limited to in-app paths (open-redirect guard) | in-app returnTo with query string is honoured | absolute, protocol-relative, backslash and javascript: targets land on /app; paths that normalize to another origin (/.//host, /app/..//host, /app/../\host) land on /app | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend token exchange, session, reload resume) | authorize request carries S256 challenge, state, client id, scopes and /token redirect; backend exchanges code + verifier with client secret; user_session row stored; provider identity is displayed; reload resumes without the provider | | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| SEC-026 | OAUTH2 sign-in failures: provider denial, tampered state and failed code exchange are reported and create no session | | a state-matched access_denied shows the provider description once; a state mismatch shows a generic error and is rejected before any token exchange; a rejected code exchange shows the failure; no user_session row | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-027 | OAUTH2 logout: backend session deleted, provider end-session called, next visit requires provider sign-in | user_session row deleted and provider session ended | protected page after logout returns to the provider login | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#674](https://github.com/QRun-IO/qqq/issues/674) |
| SEC-028 | OAUTH2 session revoked server-side mid-use: 401 sends the user through the provider and back to the page | returns to the same page after provider sign-in | revoked session gets 401, not stale data | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-029 | AUTH_0 authorization code + PKCE with an owned Auth0-compatible provider (browser token exchange, backend JWT validation via JWKS) | sign-in and data access through the Auth0 module; logout returns through the provider | provider denial is reported | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=AUTH_0 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-030 | AUTH_0 against a real Auth0 tenant | real tenant sign-in/logout | | Requires an Auth0 tenant QRun does not own in CI | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real Auth0 tenant; the AUTH_0 flow is covered against an owned Auth0-compatible provider by SEC-029/SEC-031; real-service testing is follow-up work.) | |
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in again | deep link loads data as Anonymous | after logout the page stays signed out until Sign in | SecurityAcceptanceServer -Dqqq.security.auth=FULLY_ANONYMOUS | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, never silently treated as another type | | login shows "Unsupported authentication type: ACCEPTANCE_UNKNOWN" and no data or session is requested | SecurityAcceptanceServer -Dqqq.security.auth=UNSUPPORTED (v1 authentication metadata names type ACCEPTANCE_UNKNOWN; TABLE_BASED is supported since QRun-IO/qqq#700, see SEC-034..SEC-037) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-033 | Server-side logout invalidation: v1 logout expires every session cookie it issued and an ended Auth0 session cannot be replayed | logout response expires sessionUUID and sessionId | a logged-out Auth0 sessionUUID replayed against the API gets 401 | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#674](https://github.com/QRun-IO/qqq/issues/674) [#675](https://github.com/QRun-IO/qqq/issues/675) |
| SEC-034 | TABLE_BASED sign-in: a deep link asks for username and password under the app branding, POST /qqq/v1/manageSession with Authorization: Basic creates a stored session, the session user is shown and a reload resumes it | login form shows the logo and app name before any session exists and reads no data; credentials travel once as Basic credentials (password with colons), never in the body or browser storage; lands on the requested page with rows; user menu shows Tess Table (table-based) / tess.table; one session row for the user, matching the sessionUUID cookie; reload keeps it | without the session cookie the backend refuses a read (401) | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-035 | TABLE_BASED sign-in failure: a wrong password or unknown user is refused with one message, stores no session, and the form recovers | correct credentials after a refusal sign in to the requested page | empty fields are named and nothing is sent; wrong password and unknown user both show "Incorrect username or password."; no session row is stored and no data is read; the password field is cleared | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-036 | TABLE_BASED session expiry: a session idle past the inactivity timeout gets a 401, the user signs in again and returns to the record they opened | expired mid-use returns to the clicked record after signing in again | the expired session is refused by the backend ("Session is expired.") | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) + /acceptance/expire-table-sessions | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-037 | TABLE_BASED logout: POST /qqq/v1/logout deletes the stored session and clears both cookies, protected pages stay on the form, and the next user signs in as themselves | a different user signs in on the same browser and is shown as themselves | the ended session cannot be replayed with either cookie (401); after logout a protected page (full load) stays on the form and reads no data | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-038 | Dashboard documents carry a strict Content-Security-Policy (script-src self plus the hash of each inline script, object-src none, base-uri/form-action self, frame-ancestors none), X-Frame-Options DENY, Referrer-Policy and Permissions-Policy | login, token, app, list, record, edit and 404 documents send the policy and headers; the script-src hashes equal the hashes of the inline scripts in each served document; pages hydrate and navigate under the policy with no violation | another web site framing the dashboard gets no content; an injected inline script and inline event handler do not run and are reported as violations | tests/acceptance/fixture/AcceptanceSampleServer.java (javalin mode); with QQQ_ACCEPTANCE_MODE=standalone the container image server (standalone/qqq-server.mjs) | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) [#734](https://github.com/QRun-IO/qqq/issues/734) |
| SEC-039 | The policy allows the configured identity provider origin (OAUTH2 discovery, AUTH_0 browser token exchange) and no other connection target | OAUTH2: connect-src is self plus the provider origin; sign-in completes with no violation; AUTH_0: connect-src is self plus the Auth0 domain; the browser token exchange completes with no violation | a fetch to any other origin is refused and reported (connect-src) | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2\|AUTH_0 | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) |
| SEC-040 | Application override hook and metadata-derived origins: the application adds its own origins (QApplicationJavalinServer.withNextDashboardSecurityHeadersCustomizer); QuickSight and custom component bundle origins come from widget metadata | the QuickSight embed, block image and audio from the loopback service and the custom component bundle load with no violation; the policy lists exactly the configured origins | a frame from an origin the application did not configure is refused and reported (frame-src) | tests/acceptance/fixture/WidgetsFixtures.java loopback service | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) [#734](https://github.com/QRun-IO/qqq/issues/734) |
| SEC-041 | The login page explains known sign-in error codes and never displays text taken from the link | direct links with access_denied and other known codes show their safe message; other well-formed codes show the code; Try again signs in through the provider | free text in ?error= is replaced by a generic message | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-042 | Rendered HTML (field values, widgets, process steps, help, banners) cannot add forms, form controls, style sheets or page-covering positioning | formatting in a stored HTML value is kept; the stored value is unchanged in the database | a stored form with a password input, a style element and a position:fixed overlay render as plain content | tests/acceptance/fixture/RecordsFixtures.java fieldLab.htmlValue | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-043 | Per-user browser data (recently viewed records, stored identity) is bound to the signed-in identity | the same user keeps their recently viewed records across reloads | a different user signing in on the same browser without a logout does not see the previous user's recent records | tests/acceptance/fixture/AcceptanceSampleServer.java personas and sample users | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-044 | Session cookies (sessionUUID, sessionId) are SameSite=Lax for the whole site, and Secure when the request arrived over HTTPS | manageSession and API responses set the cookies with Path=/ and SameSite=Lax; the browser stores them as Lax | over plain HTTP the cookies are not Secure (sign-in keeps working); behind X-Forwarded-Proto https they are | tests/acceptance/fixture/AcceptanceSampleServer.java | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-048 | Session cookies (sessionUUID, sessionId) are HttpOnly: script in the page cannot read them, and resuming a session from the cookie never hands its token to script | after sign-in (MOCK, TABLE_BASED, OAUTH2) the browser stores both cookies HttpOnly, naming the current session; a reload resumes the session through POST /qqq/v1/manageSession with an empty body; the response carries the session values and no uuid | document.cookie never shows sessionUUID or sessionId; an in-page POST /qqq/v1/manageSession {} (what injected script could send) answers without the session token | tests/acceptance/fixture/AcceptanceSampleServer.java (MOCK); SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED and OAUTH2 with tests/acceptance/support/fake-oidc.ts | pass (chromium) | [#733](https://github.com/QRun-IO/qqq/issues/733) |
| SEC-049 | With HttpOnly session cookies the backend ends and replaces sessions: logout expires both cookies, an idle-expired or revoked session sends the user to sign in again, and a new password or OAuth2 sign-in points both cookies at the new session | TABLE_BASED: a first visit shows the form without a session request; an idle-expired session is refused, the form returns without a resume attempt, and signing in again returns to the record; OAUTH2: a session revoked on the server sends the user through the provider and back, with new HttpOnly cookies; logout: the server deletes the session and expires both cookies; a protected page stays signed out without a session request | a logout that never reached the server leaves the earlier user's cookies; the next user's sign-in replaces both, and a reload keeps the new user | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED and OAUTH2 with tests/acceptance/support/fake-oidc.ts | pass (chromium) | [#733](https://github.com/QRun-IO/qqq/issues/733) |
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | edit saved by keyboard persists (SQL) | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-002 | Keyboard: run a process through its steps without a pointer | greetInteractive completes and shows results | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | keyboard delete confirmation deletes the row (SQL) | Escape cancels without deleting; focus returns to Delete | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#682](https://github.com/QRun-IO/qqq/issues/682) [#683](https://github.com/QRun-IO/qqq/issues/683) [#694](https://github.com/QRun-IO/qqq/issues/694) |
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | command menu navigates to a table | Escape closes and returns focus | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-005 | Automated accessibility scan (axe WCAG 2.1 AA) of login, dashboard, list, record, edit form and process step | no serious or critical violations on each page | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-006 | Form labels and ARIA: every field has an accessible label, required fields are marked, invalid fields announce their error | labels match metadata labels | submitting empty required fields sets aria-invalid and a described error | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | | missing required value is named and focused; backend rejection is shown verbatim; SQL unchanged | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-008 | Responsive layout at phone width (Pixel 7): navigation drawer, list, record and form usable without horizontal scrolling | drawer navigation to a list, open and edit a record on a phone viewport | no horizontal page overflow | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | busy indicator while the real request is held, then rows | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#694](https://github.com/QRun-IO/qqq/issues/694) |
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | retry succeeds | the failure is explained, not a blank page | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | exactly one row inserted (SQL) | second click while pending is ignored | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | record edited through the API shows the new value on revisit | record deleted through the API shows not found | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#683](https://github.com/QRun-IO/qqq/issues/683) |
| INT-013 | Empty state: a query with no results explains it and offers the create action | no-match filter shows the empty message and Create | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | Try again after recovery signs in | network failure shows "Sign-in failed" | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
## Dashboards, widgets, reports, saved and scheduled reports, and sharing
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | WARNING alert renders strong HTML and each bullet as sanitized HTML | hideWidget and empty alerts render nothing (WID-063) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlert | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-002 | barChart widget: one bar per label in backend colors | bars carry per-point backgroundColors; accessible data table has exact values | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-003 | chart output from an untyped widget renders as a bar chart | metadata type absent, payload type chart, values rendered | zero-value month has no drawn bar | sample greetingsApp/PersonsByCreateDateBarChart | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-004 | divider widget renders a rule without card chrome | hr rendered, no label or card | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accDivider | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-005 | fieldValueList widget: labels, display values, prefix icons, indentation | display value wins, zero shown, icon color, indent | empty field list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accFieldValueList | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-006 | generic widget renders its common payload fields (sublabel, footer) | sublabel and sanitized footer HTML shown | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accGeneric | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-007 | horizontalBarChart widget: horizontal bars with negative values left of zero | negative bar extends left of the zero line; height from payload | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accHorizontalBarChart | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-008 | html widget (no-code) renders sanitized user HTML | velocity output with i/b/u and centered wrapper | empty html renders an empty body (WID-052) | sample SampleWidgetsDashboard/SampleHTMLWidget | pass (chromium) | |
| WID-009 | lineChart widget: line with a point per label and description | points carry exact values | | sample SampleWidgetsDashboard/SampleLineChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-010 | smallLineChart widget: compact line with title and HTML description | title, strong description text, 5 points | | sample SampleWidgetsDashboard/SampleSmallLineChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-011 | location widget: image, title, description, address and footer | owned image loads from the owned service | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accLocation | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | every group and value matches the payload | empty group list shows empty state (WID-052) | sample SampleWidgetsDashboard/SampleMultiStatisticsWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-013 | multiTable widget renders each table with its label and rows | two labelled tables with their rows | empty table list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accMultiTable | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-014 | pieChart widget: colored slices, legend values and subheader | slice fills, legend label: value, main number, up/good change | | sample SampleWidgetsDashboard/SamplePieChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-015 | quickSightChart widget embeds the provider URL (real renderer, owned local provider endpoint) | iframe src is the owned embed URL and its content renders | metadata never exposes the secret key | tests/acceptance/fixture/WidgetsFixtures.java repointSampleQuickSight (greetingsApp/QuickSightChartRenderer against the owned loopback endpoint) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-016 | statistics widget: count, context, colored percentage change and links | decrease with increaseIsGood is bad; increase with increaseIsGood=false is bad; count links to countURL | | sample SampleStatisticsWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStatisticsGood | pass (chromium) | |
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | bars of one label share x; dataset colors | | sample SampleStackedBarChartWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStackedBars | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | states and current link | empty steps show empty state (WID-052) | sample SampleWidgetsDashboard/SampleStepperWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-019 | table widget: headers, rows, alignment and fixed totals row | rows and sticky last row match payload | no rows shows noRowsFoundHTML or default (WID-052) | sample SampleWidgetsDashboard/SampleTableWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-020 | usaMap widget plots each marker with name and coordinates | 3 markers placed west to east by longitude; marker list | empty marker list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accUsaMap | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-021 | process widget runs the sample interactive process inline | form fields render, submit, result greeting shows | viewer persona: process denied contained (WID-061) | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess (greetInteractive) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-022 | parentWidget renders its children in a 12-column grid | children render with their own data | tabs (WID-055), denied/missing children (WID-056) | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentGrid | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | every block value, direction, link and tooltip | no blocks renders an empty composite (WID-052) | sample SampleWidgetsDashboard/SampleBigNumberBlocksWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-024 | childRecordList widget without association binding lists joined child records | rows link to child records, Showing 2 of 3, View All filter link | host with no children shows empty state | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accWidgetHostJoinChild (maxRows 2) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
| WID-025 | customComponent widget loads its bundle and renders the named component | component receives widget metadata and data | missing bundle shows contained error (WID-062) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponent (owned bundle) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-026 | cronUI widget shows the record's schedule expression, description and time zone | expression, backend description and time zone | record without expression shows No schedule set | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostCron | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-027 | dynamicForm widget shows labeled values from the payload | labels and values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostDynamicForm | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-028 | dataBagViewer widget lists versions and shows the selected version contents | newest version current and shown; selecting older version shows its JSON | bag without versions shows empty message | tests/acceptance/fixture/WidgetsFixtures.java dataBag/1 section accDataBagViewer (owned dataBag tables) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-029 | pivotTableSetup widget shows, edits and clears the saved report's pivot definition | rows/values labels from the source table; modal OK updates only the host draft; host Save persists rows, columns and aggregate values with SQL readback and reopen; keyboard row reorder and value move preserve saved configuration; turning the pivot off clears the stored definition on host Save | report without pivot shows message; missing row fields and aggregate functions keep the editor open; Cancel discards added fields without changing the database; used group/value fields are excluded from choices; changing a numeric value to text clears an incompatible aggregate | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned pivot saved report | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-030 | filterAndColumnsSetup widget shows the saved report's filter, sort and columns | criteria with field labels and operator phrases, columns by label | report without filter shows No filters | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned filtered saved report | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-070 | filterAndColumnsSetup edits a saved report's filter, sort, columns and variable values through the host form | Edit Filters and Columns opens the report setup dialog; OK writes queryFilterJson and columnsJson to the host form, and Save persists a changed sort to the backend; assigning a filter variable persists FilterVariableExpression in the backend JSON and shows ${VARIABLE} in the editor; import a saved view, reset changes to that view or reset to a new default draft without updating the saved view | Cancel discards the draft; OK without host Save leaves the backend row unchanged; preview and Open in new window block a query while a filter variable lacks a value; resetting and cancelling leave both the report and all saved views unchanged | prime-sharing savedReport 1 (Pet Species Report, no pivot table); saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-071 | a WIDGET-adorned process form field renders filter setup and writes its own value | the widget request names the adorned field, the editor updates its filter, and the next process screen receives the JSON | other widget output fields are not written to the adorned field's host form | ProcessesFixtures.java prcFieldWidget with a queryFilterJson WIDGET adornment | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-072 | API-versioned report setup loads table metadata and previews through the application API | the report editor loads person metadata and its preview rows and count through the real API-aware v1 routes | an unsupported API version returns 404 | ApiVersionFixtures.java acceptanceApi 2026.Q3; savedReport 102; saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-073 | report setup editor uses the full server-backed grid for paging, sorting and column sizing | grid preview hides selection, changing page size changes the real backend query, header sorting changes the real backend query, and keyboard column resize persists in saved columns JSON; create a report from a query, switch to Audit, save and reopen, verify stored columns and download the CSV; filtered column statistics totals and exported value counts match SQL; closing statistics and cancelling the editor leave saved data unchanged; a table without COUNT still shows real preview rows and page copy matches SQL; variant-backed report previews choose, switch and remember the backend variant for query/count, copy and statistics on ordinary and application-API routes; complete nested criteria mark preview column headers; opening the indicator edits the existing condition and real preview rows match SQL; configured omitted joins disappear from new preview column/filter choices while saved joined values, editable criteria and column positions survive; changed results and reload match SQL; editor Cancel, Basic/Advanced, OK, host Save and reopen preserve joined criteria, sorts, order and width; hidePreview hides only the read-only summary; the editor retains real query rows, sorting and Refresh; Cancel, OK, host Save and reopen preserve the intended sort with the flag enabled and disabled; column menus fit 320px and 720px windows; keyboard End/Escape restores focus and pointer Copy/full-query and sorting remain reachable | selection pseudo-column and incompatible previous-table fields are excluded from saved reports; full-column copy refuses results over the configured limit; an empty preview offers no column copy action; cancellation preserves the saved report; no query before selecting a required variant; an old variant copy response cannot overwrite a later copy; variant changes do not persist report edits; incomplete criteria do not mark columns; opening an indicator adds no condition; read-only preview edits and editor Cancel leave stored report JSON unchanged | savedReport 1 targeting petSpecies without a pivot; saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-031 | rowBuilder widget without association binding shows its rows read-only | column labels from widget fields, row values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostRows | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | current revision marked and its file contents shown; selecting older revision | | tests/acceptance/fixture/WidgetsFixtures.java script/1 (owned memory script with 2 revisions) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | real embed URL | | requires AWS credentials and a QuickSight dashboard (external service) | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real AWS QuickSight dashboard; the widget and renderer are covered against an owned loopback provider by WID-015; real-service testing is follow-up work.) | |
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | order matches metadata; 12 spans the grid; 4 is a third | | sample SampleWidgetsDashboard | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | plain widget has no card border; footers render | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accPlain, widgetGallery/accGeneric | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | icon names, colors and placement | | sample SampleWidgetsDashboard | pass (chromium) | [#538](https://github.com/QRun-IO/qqq/issues/538) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | hover and focus show, Escape hides | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-044 | widget help content (HTML) on the label | help icon shows sanitized HTML help | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accHelp | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-045 | reload button re-renders the widget from the backend | render count increments per reload | no reload button without showReloadButton | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accReload | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-046 | export button downloads the widget's csvData as CSV | file bytes match Material quoting rules | no csvData: no-data message and no download | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accExport, accExportEmpty, accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-047 | PVS dropdown from the widget payload: selection is sent as a parameter and re-renders | options from payload, choice reaches renderer, clearing removes it | non-stored selection is not persisted | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accDropdownHtml | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-048 | DATE_PICKER dropdown sends the chosen date | date reaches the child renderer | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-049 | required dropdowns: the parent waits with the backend message until selections are made | message then children render with selections | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-050 | storeDropdownSelections persists selections across reload; stale stored options are dropped | localStorage qqq.widgets.dropdownData.<widget>.<param>; reload keeps selections | removed option in storage is not selected | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | error message, retry re-requests, neighbor content | no error toast, no console error | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accError + accHealthy | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-052 | empty payloads show descriptive empty states for every display type | table, multiStatistics, charts, stepper, statistics 0, composite, multiTable, field values, map, html | | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accEmpty* | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-053 | malformed payloads are contained to their own widget | chart, table, composite, stepper, multiStatistics show a format notice | no crash, no console error, neighbors render | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accMalformed* | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-054 | widget permissions: a denied widget is absent from metadata, not rendered and not requested; the API denies it | admin sees it | noPets: no card, no request, GET /widget 403, renderer not called | tests/acceptance/fixture/WidgetsFixtures.java widgetPermissions/accDenied (pet.accDenied.hasAccess) with the noPets persona | pass (chromium) | |
| WID-055 | parent widget TABS layout switches tabs and remembers the selected tab | tab click, arrow keys, stored tab restored after reload | | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentTabs | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-056 | parent widget skips denied and unknown children without requests | allowed children render | noPets: denied child absent and not requested; unknown child skipped | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentDenied, accParentMissing | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-057 | composite renders every block type with its values and styles | TEXT, BIG_NUMBER, UP_OR_DOWN_NUMBER, NUMBER_ICON_BADGE, ICON, TABLE_SUB_ROW_DETAIL_ROW, PROGRESS_BAR, DIVIDER, IMAGE, AUDIO, INPUT_FIELD, BUTTON; Unmapped legacy icon names render local glyphs in light/dark desktop/mobile modes; metadata navigation and main tiles preserve color, sizing, existing Lucide mappings and same-origin font delivery | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks and accLegacyIcons | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-058 | composite layouts and slot links/tooltips | FLEX_COLUMN, FLEX_ROW, BADGES_WRAPPER, TABLE_SUB_ROW_DETAILS, FLEX_ROW_SPACE_BETWEEN, FLEX_ROW_CENTER, FLEX_ROW_WRAPPED; link to /app/person with tooltip | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-059 | unsupported block type shows a contained warning | Unsupported block type: OWNED_UNKNOWN; neighbor healthy | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocksUnknown | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-060 | record-view section widgets receive the record context (id, tableName) | record 1 and 2 show their own values | renderer enforces the user's table read | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost sections accHostHtml, accHostFieldValues | pass (chromium) | |
| WID-061 | process widget for a persona without process permission is contained | error shown in the widget; neighbor healthy | process metadata 403, no process run | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess with the noProcesses persona | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-062 | custom component whose bundle cannot load shows a contained error | Error loading MissingOwnedComponent | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponentMissing | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-063 | alerts with hideWidget or without HTML render nothing | no card rendered | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlertHidden, widgetStates/accEmptyAlert | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| RPT-001 | TABLE report runs through its process and downloads CSV matching the database | CSV header labels and every person row from SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-002 | TABLE report downloads XLSX with the same rows | sheet rows match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-003 | TABLE report downloads JSON with the same rows | JSON objects match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-004 | SUMMARY report groups rows with counts and a totals row | pets per owner match SQL group by; Totals row | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetSummaryReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-005 | PIVOT report produces a native pivot table in XLSX | xlsx contains the pivot table and cache over the rows sheet | CSV omits the pivot view (no native pivots) | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetPivotReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#484](https://github.com/QRun-IO/qqq/issues/484) |
| RPT-006 | report input fields: required validation and the value filters the rows | minimumId filters rows >= value; Standard report inputs retain compact widths and an inline Generate Report action without a process header | empty required input blocks submit | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonInputReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-007 | report permissions: restricted report denied in UI and by the server | admin can run it | noPets: no-permission message, legacy route 403 | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accRestrictedReport with the noPets persona | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-008 | reports appear in app navigation and open their run page | sidebar REPORT nodes navigate to the report page | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports app | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#651](https://github.com/QRun-IO/qqq/issues/651) |
| RPT-009 | saved report view shows its filter/columns and pivot setup | filter criteria, columns, pivot rows/values labels | | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned saved reports | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| RPT-010 | saved report renders through renderSavedReport and downloads the saved columns and rows | format chosen, CSV contains the saved columns and every species | | prime-sharing savedReport 1 (Pet Species Report) | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#661](https://github.com/QRun-IO/qqq/issues/661) |
| RPT-011 | streaming report route for reports without a process | CSV streamed from /reports/{name}?format=csv matches SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accStreamedReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-012 | scheduled report: create a schedule for a saved report and view its cron schedule | schedule persisted with cron description; record view shows the schedule widget; Add new on the saved report opens the Add Scheduled Report dialog over it with Saved Report preset and locked (Material #/createChild=); Create submits savedReportId=1, closes the dialog and reloads the child list | invalid cron expression rejected with message | scheduledReport (memory) for savedReport 1 | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) [#714](https://github.com/QRun-IO/qqq/issues/714) |
| RPT-013 | sharing: the owner shares a saved report with a user (Read-Only) | dialog lists Bob; shared_saved_report row READ_ONLY | | prime-sharing savedReport 1 owned by alice | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#444](https://github.com/QRun-IO/qqq/issues/444) |
| RPT-014 | sharing: change a share's scope to Read and Edit | row scope READ_WRITE | | prime-sharing savedReport 1 | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-015 | sharing: remove a share | row deleted, list empty | | prime-sharing savedReport 1 | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-016 | sharing record security: another user cannot see an unshared report until it is shared | bob sees it after sharing (UI list + API) | before sharing: not in bob's list, GET 404 | prime-sharing savedReport 1 (alice), user bob | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-017 | sharing: only the owner may share | Share disabled with reason for the non-owner | server rejects a non-owner share; no row | tests/acceptance/fixture/WidgetsFixtures.java savedReport owned by casey shared with alice | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#371](https://github.com/QRun-IO/qqq/issues/371) |
| RPT-018 | sharing a saved view through the share dialog | shared_saved_view row for Casey | | prime-sharing savedView 1 owned by alice | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| WID-064 | cronUI schedule editor on record forms: Basic days/hours/minutes, Advanced expression, live description, validation, time zone | Basic mode builds weekdays, hours and minutes; the live description equals the cronDescription QQQ stores; Basic mode builds selected dates, Clear resets the schedule, and the Advanced hint names the part under the keyboard caret; Advanced mode edits the stored expression; the live description equals the backend widget description; expression and time zone persist (SQL); an expression Basic mode cannot show opens Advanced with Basic disabled and the reason | an out-of-range expression shows the error live and marks the input invalid; saving without a schedule shows the required error in the editor, focuses it, and saves nothing | scheduledReport (memory) for savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostCron with a time-zone enum possible-value source | pass (chromium) | [#702](https://github.com/QRun-IO/qqq/issues/702) |
| WID-065 | record view mounts only the active layout, so each section widget mounts and loads once | desktop tabs: every section widget is in the DOM once and requested once; no accordion is mounted; phone accordion: only open sections mount; each widget is requested once, on opening | the desktop tab layout is not mounted at phone width | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost | pass (chromium) | [#706](https://github.com/QRun-IO/qqq/issues/706) |
| WID-066 | Widget metadata and widget data come from the v1 API | v1 /metaData lists permitted widgets with hasPermission and the reports map; the dashboard renders its widgets from POST /qqq/v1/widget/{name} and makes no legacy metadata or widget request | a denied widget is absent from v1 metadata, never requested, and refused (403) by POST /qqq/v1/widget without rendering | widgetPermissions (accHealthy, accDenied), accPersonReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| WID-067 | a widget whose payload is a single leaf block (Material block widget) renders that block | The backend payload is the TEXT block itself (blockTypeName TEXT, no blocks list); The widget shows the block's text as a TEXT block | No empty COMPOSITE container is rendered in its place | WidgetsFixtures.java accLeafBlock (COMPOSITE widget whose renderer returns a TextBlockData) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| WID-068 | table widget Material extras: typed cells (numbers with separators, html, htmlAndTooltip, image, composite), hidden helper columns, fr widths, sub-rows, sticky header, column-header help, paging (10 by default, numbered pages, jump-to-page, entries-per-page), export built from columns and rows, and a multi-table widget with an export button and footer per table | Default cells show numbers with thousands separators; html cells render sanitized markup; an htmlAndTooltip cell shows the row's tooltip HTML on hover, focus or tap; an image cell shows the row's image, label and total; a composite cell renders its blocks; Columns of type hidden (tooltip, image helpers) are not shown; fr widths share the width left by fixed widths (2fr is twice 1fr; a 180px column keeps 180px); A row with sub-rows expands and collapses them (nested rows are shaded; a sub-row expands its own sub-rows); The columnHeader=name help slot shows its HTML help on the Name header; Without rowsPerPage 10 of 40 rows show, with numbered page buttons; the entries-per-page select (5-25) changes the page size; more than six pages become a jump-to-page input; the header stays at the top while the fixed-height body scrolls; Export without csvData downloads the Material CSV of every column (hidden ones too), quoted, with link text and without icon glyph text; Each table of a multi-table widget has its own label, export button (csvData or columns and rows) and footer; the widget itself has no export button | Icon glyph text (open_in_new) never reaches the CSV; Collapsed sub-rows are not rendered; A sub-table's footer is not shown under another sub-table | WidgetTableChartFixtures.java widgetTableCharts/accTableCells, accTablePaging, accMultiTableExtras | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| WID-069 | chart widget Material extras: theme color names, legend toggles, single-series legends (horizontal bars, line badges), full pie with percent tooltip, stacked backgroundColor, stacked tooltip and right integer axis, rotating category labels, small line y ticks and grid, bar chart As of line | Pie slices named info and success fill with the Material theme colors; the pie is full (no inner radius); Hovering a slice shows its label, value and percent of the total; Clicking a legend entry hides that slice or series and clicking again shows it; Stacked datasets fill from their singular backgroundColor (a theme name or a CSS color); the tooltip names only the hovered dataset; the y axis sits on the right with whole-number ticks; long category labels turn; A single-series horizontal bar chart has a legend; a line chart names its series in badges above the chart; A small line chart shows y-axis values and a horizontal grid; A barChart widget ends with an As of line naming today's date | A hidden series draws no bars or points; No y-axis tick of the stacked chart is fractional | WidgetTableChartFixtures.java widgetTableCharts/accPieNamed, accStackedExtras, accLineBadges, accHorizontalLegend, accSmallLineTicks, accBarAsOf | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| RPT-019 | render report input step shows only its fields; a report without variables adds no no-fields message | Report Format, Email To and Email Subject are shown; the values widget renders nothing | no "No fields" text on the input step | savedReport 1 (Pet Species Report, no variables) | pass (chromium) | [#707](https://github.com/QRun-IO/qqq/issues/707) |
| RPT-020 | Report files download from the v1 download and report routes | a process report links its file at /qqq/v1/download/... and the file has every row; a report without a process streams from /qqq/v1/reports/{name}?format=csv with every row | an unregistered server file or storage reference is refused (403); a restricted report is 403, an unknown one 404, a missing format 400 | accPersonReport, accStreamedReport, accRestrictedReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| WID-074 | Standalone composite scalar controls and metadata editors | Seeded text, number, date, boolean and native file controls; currency prefix, named possible values and multiline code editor; Choice width and center-click behavior with system and monospace fonts, including a 320px viewport; Backend uppercase/lowercase metadata preserves middle-of-value edits and caret position in standalone blocks; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Desktop wheel scrolling preserves plain and shared numeric block values; intentional ArrowUp edits still work and text inputs retain focus; Native standalone TIME controls increment by one second and retain second-level values. | Code-editor Enter leaves the widget on the same page; Multiline Enter does not submit or navigate away | widgetInputEditors / accTypedInputs | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | UTC timestamp displays the correct local date, time and seconds | Do not slice UTC digits into a local-time field | accTypedInputs in America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
File diff suppressed because it is too large. Load diff
+554
View File
@@ -0,0 +1,554 @@
# Next UI visual review
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
Status: in progress, September 30, 2026. This review is a release requirement. Captured screenshots are not approvals or evidence that a surface has passed review.
The visual reference is the working Next UI before the September 26 parity integrations, commit `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard remains the reference for supported workflows and metadata contracts. The user's September 27–28 correction requires preserving the earlier Next design while completing those workflows.
## Latest checkpoint — September 30, phone process states
Compared original Next `e42ad2b2bcdc76311e13002a22dd70e3ec437192` with the packaged RC8 candidate `f0f0eae98e4b6c883475956a7c59e060d2e5f0a5`, using the same fresh fixture classes and backend. All 24 phone images were inspected: light/dark, original/current, bulk-load mapping at four scroll positions, process progress and expanded internal error. All requested scroll positions were reached, with no setup errors, page errors or document horizontal overflow. Evidence: `rc8-phone-process-review/` manifests and review.
Progress and expanded-error composition retain the original arrangement. Bulk-load field cards and footer retain their arrangement, but saved-profile/reset controls, metadata help and larger touch targets make the upper section taller. Grouped Add Fields and the red removal icon also differ from the original. These are recorded differences, not owner approval. Header breadcrumb crowding remains open. This checkpoint covers these phone states only; whole-product visual and interaction approval remain open.
## Earlier checkpoint — September 30, reachable column menus
VIS-031: column menus were not constrained to available viewport height. A browser probe on the report preview measured a 352px menu starting at y=-147 in a 320px window in both Chromium and WebKit. The shared menu now uses Radix's available-height limit, an 8px collision margin and vertical scrolling, matching existing query menus. Its labels, ordering, typography and normal-height presentation are preserved.
The corrected regression first failed at 320px and passed at 720px in both engines. All 28 final Chromium/WebKit checks pass, including bounded placement, End/Escape and focus return, pointer Copy, sorting, standalone column actions and report variant workflows. Four final menu screenshots were directly inspected. Seven grid component tests, production export, types/lint/license and bundle budgets pass. Evidence: `column-menu-{probe.json,corrected-red-report.json,green-report.json,green-artifacts,unit.log,build.log,budget.log}` under local visual-review evidence. The first regression incorrectly expected a statistics action absent from Person metadata; that test assumption was corrected before the valid red run. No product change addressed that test-only error.
Hosted WebKit run36739388053 failed WID-073 before copying: the trace repeatedly reports Copy full query values outside the viewport. Its report and trace are retained in `row-appearance-ci-webkit/`. The exact workflow passed locally before the change, so this reproduced short-window defect does not by itself prove that hosted failure resolved. A fresh hosted result is still required.
The prior combined table, record-spacing and report hidePreview source `a8e4d3b` separately passed **583 Chromium tests/all 391 required rows**, zero failures/skips/flaky results and three approved external-service exclusions. That full run predates this menu correction; evidence is `report-preview-full-chromium/`. RC4 remains the published version; final 1.0 and whole-product visual approval remain held.
## Earlier checkpoint — September 30, forms, processes and dark widgets
At post-RC4 source `9fb613d`, inspected all 88 images from 64 desktop entries for 16 form/process states in both themes. These include Create, validation, Edit, Copy, Field Lab, process components/help/validation/review, route details/validation/confirmation, cancellation and widget results. All entries have export fingerprints, no setup/page errors and no document overflow. Layouts retain the original Next composition. The broken image in Lab briefing is the deliberate script/onerror sanitization fixture, not a newly broken image feature. A focused axe scan of the default process stepper reports no color-contrast violations or incomplete checks in either theme; its colors were not changed. Evidence: `post-rc4-form-process-review/`. This does not cover all process interactions, bulk-load/progress/error variants or responsive states.
Also inspected 22 fresh dark desktop images (eight entries) covering the full scrolled Widget Gallery, Blocks, Parents, and Tables and Charts pages. Every requested scroll reached its target; no setup/page errors or document overflow occurred. The existing Next card composition remains. Changes include functional maps, typed table cells, chart colors/legends, supplied footer metadata and native dark audio controls. No new visual regression was found in this inventory. This closes the current dark desktop lower-page gap for these four pages only. Evidence: `post-rc4-widget-review/` (11 directly inspected paired sheets). Other themes, responsive layouts, interactive widget states and whole-product approval remain open.
## September 30 — report summary preview flag compatibility
Source comparison found that Material's `hidePreview` hides the read-only summary preview but leaves the editing query available (`FilterAndColumnsSetupWidget.tsx:483-526`). Next incorrectly applied the flag inside the editor too. The bounded correction retains the existing Next editor and scopes this flag to the summary. It is not in published RC4.
A real-backend regression failed for the hidden-summary case while the ordinary case passed. After correction, eight Chromium report scenarios pass with zero failures/skips/flaky results, covering hidden/shown summaries, editor sorting/Refresh, Cancel, OK, host Save/reopen, query permissions, filter indicators and saved omitted joins. All 1,884 units, production export, types, changed-file lint and license checks pass. Four fresh editor images were directly inspected (both themes, flag enabled/disabled); dialog and preview bounds are identical, with no page errors or document overflow. Evidence: `report-preview-{red,green}-report.json`, `report-preview-{unit,build}.log`, and `report-preview-fixed-review/`. Broader embedded-query equivalence is still open.
## Earlier checkpoint — September 30, record detail review
Captured and inspected six desktop record states in light and dark themes against the original Next: small record, Person, selected section with keyboard focus, the field/adornment lab (including its lower scroll position), empty Audit and Delete confirmation. The 24 manifest entries contain 28 images, with no setup/page errors or document overflow. Evidence: `post-rc4-record-review/` under the local visual-review directory. Card placement and dialog composition are retained; added audit sort controls, code expansion and widget chrome remain visible. These captures do not approve populated audits, every field interaction or responsive record states.
Found VIS-030: overview field values inherit 16px/24px typography after the metadata grid migration, increasing plain rows from the original 42px to 46px. Browser measurements on both exports establish the difference (`spacing-probe.json`). Restored the original overview line height while retaining metadata widths and the selected-section layout. Both new Chromium cases failed at 24px versus 20px before the change; all nine affected record workflows pass after it. Fresh measurement matches the original five Person field heights exactly (42px for plain values, 43px for the boolean badge). All 1,884 unit tests, production export/types and changed-file lint pass. Eight fresh after-fix entries (ten images including the lab scroll states) were inspected in both themes with no setup/page errors or document overflow; evidence is `record-spacing-fixed-review/`. The initial browser launch used an inactive default backend port and is retained separately from the valid regression run. The correction is not in published RC4.
## Earlier checkpoint — September 30, query rows and filter review
Compared 14 desktop query/filter states in both themes between the original Next export and the published RC4 JAR: 56 entries, no unexpected setup/page errors or document overflow. The retained baseline export fingerprint matches the earlier review. Inspected table, Basic/Advanced, populated/nested filter, quick editor, columns, column-menu, relative-date, density, selection, Views and Export captures. Three added controls have no equivalent original state and are marked accordingly. Evidence: `rc4-query-review/` under the local visual-review directory. This is a desktop subset, not whole-query or whole-product approval.
Found VIS-029: unconditional alternating row shading added by parity commit `4c7918f` changes the original Next table, especially in dark mode. Restored the original uniform default while retaining selected-row highlighting, opaque pinned cells, theme overrides and the `data-row-parity` customization hook. The correction is on the post-RC4 branch and is not in published RC4. Both new rendered-color tests failed against unchanged RC4, then passed on the corrected export. All 1,884 unit tests, production export/types, changed-file lint and 13 existing Chromium theme/density/pinning workflows passed.
The first browser attempt had a stopped server; a subsequent temporary symlink was not discoverable by the backend classpath scanner. Those setup failures are retained separately. The new pinned-cell test initially matched nested transparent text spans as well as table cells; correcting its selector produced two passing light/dark cases without further product changes. Corrected-build screenshots from that selector-failure run were inspected and show uniform rows with opaque pinned cells; their origin remains explicit. Fresh corrected-export captures are now complete and inspected for the small table, Person table, populated filters and selected rows in both themes (eight entries). Their manifests contain no setup or page errors. Evidence: `row-appearance-fixed-review/` under the local visual-review directory. Broader visual review remains open.
## Earlier checkpoint — September 29, report omitted-join preservation
Inspected the corrected report editor's Filters and sort and Columns tabs in desktop light and dark mode. The existing dialog, tabs, typed filter controls, sort controls, column picker, preview grid and footer remain in use. Saved joined fields remain labelled and editable even when omitted from new choices; the preview still shows their values. This inspection covers the affected state and does not approve the whole report family or the broader original-Next comparison.
Four captures and geometry/runtime results are retained in the local visual-review directory: `report-editor-omission-{light,dark}.png`, `report-editor-omission-columns-{light,dark}.png`, and `report-editor-omission-visual-result.json`. At 1440×1000 the dialog is 1376×936 with 32px margins in both themes, no document overflow and no page errors. The Columns tab keeps its existing internal scrolling. The isolated visual server used a separate sample database and was stopped after capture; owner previews were untouched.
The destructive open-and-OK regression was observed failing before the correction. Current verification: 1,884 unit tests, production export, types, affected lint and three real-backend Chromium report workflows pass; independent review found no further defects. The full current Chromium gate passed 575 tests and all 391 required rows, with zero failures, skips or flaky results. RC3 does not include this follow-up.
## Earlier checkpoint — September 29, script suggestions
Added caret-adjacent suggestions inside the existing script editor: QQQ API/logger helpers, language keywords and file identifiers. The parent dialog, textarea, split panes and syntax overlay are retained. Inspected desktop light/dark captures under `test-results/acceptance/script-completion-evidence`; list bounds and both-theme axe checks pass across all five profiles. This focused review does not approve the whole developer-tools family.
The new interaction passes 1,840 unit tests, types, affected lint, license, static export and bundle budgets; 15 Chromium developer/script workflows and 10 focused cases across all five profiles. Native WebKit groups consecutive edits differently from Chromium: the initial cross-browser assertion failed after a fill plus completion, and a plain-textarea probe reproduced it. Retained failures; no production undo workaround or retry was added. The replacement check starts from a loaded revision and verifies native undo and redo, then separately tests typed-prefix completion and SQL-backed save/reload. Source/caret validation and linear prefix scanning correct both independent review findings.
Baseline full Chromium at 95847c2 is now terminal: 556 passed, all 390 required rows, no failures/skips/flaky tests. Backend PR #913 is merged. The broad original-Next visual review and final release gates remain open.
## Earlier checkpoint — September 29, report filter indicators and API cache correction
Report previews now feed complete nested criteria into the shared grid’s existing filtered-column indicator. Clicking it opens the current filter without adding a condition. The read-only preview keeps edits local. Inspected the real-backend desktop light editor and read-only captures in `report-filter-indicator-final-artifacts`; the existing grid indicator and layouts are reused. The captured editor is scrolled to its active condition. This is a focused inspection, not whole-family approval.
The previous full local Chromium export (303ba61) passed **554 tests**,390 required rows,zero failed/skipped/flaky. The subsequent indicator change passes **1,828 unit tests**,types,affected lint,license,static production export,bundle budget and **30 real-backend Chromium report workflows**. The first focused browser run mistakenly served the previous static export after a standalone build; its missing-indicator failures are retained separately and are not evidence about the corrected artifact. The proper static export was then rebuilt and verified.
Remote eeab664 CI is terminal: Chromium549pass; Firefox WID-070 widget-loading timeout; WebKit NAV-023 internal navigation error; touch WID-072 application-API500,NAV-034 redirect timeout,REL-005 internal navigation error. Logs and traces are retained. The API500 was reproduced as a concurrent static metadata-cache race in QQQ; backend62461e163 fixes atomic initialization and endpoint-test fixture isolation, with110module tests and coverage passing. PR913 remains pending new CI/merge. All40 focused rechecks of the failed workflows pass across Firefox,WebKit,phone and tablet on the corrected export/backend. The original failures remain recorded; a focused recheck does not establish that all navigation and loading failures are resolved. Full final-source/browser-matrix verification and the original Next visual review remain open.
## Earlier checkpoint — September 29, report backend variants
Report previews now reuse the existing Next variant picker for tables that require a store/tenant selection. No query runs before selection; switching starts a fresh preview page and scopes query/count, copy and statistics. The picker works nested inside the report editor. Light/dark desktop captures show it above the parent dialog and the selected variant above the preview, with no horizontal overflow or page errors. No changes were needed to the shared picker's layout or stacking. Captures: `report-variant-picker-{light,dark}.png`, `report-variant-{light,dark}.png` and `report-variant-visual-result.json` in the local visual-review directory. This does not approve the full report family.
Depth verification passes 35 real-backend Chromium scenarios, all 1,826 units, production build, types, affected lint, license and bundle budget. Both ordinary and application-API previews are covered. A review-found delayed-copy race was reproduced then corrected: a response from the previous variant cannot overwrite a later clipboard copy. Independent frontend/backend reviews have no remaining findings in this scope.
The application-API browser run exposed a backend defect: its query/count executors ignored the received variant. QQQ PR #913 applies the ordinary endpoints' session handling. Two HTTP regressions failed then passed; all 109 API middleware tests and coverage pass. Backend build succeeds under its configured static-analysis gates, with warnings. The 35 browser checks used that corrected local middleware JAR; PR CI/merge is pending. Full embedded-query parity, original-Next visual review and release acceptance remain open.
## Earlier checkpoint — September 29, report saved-view resets
Report setup now exposes Reset Changes and Reset to New View beside the selected saved view. The first restores the imported view; the second starts a default draft. Both discard unapplied Basic quick-filter values, and neither updates the saved view or report. The stale quick-filter regression failed for both reset actions before the correction, then passed. Independent review confirmed the correction with no remaining findings in this scope.
The rebuilt export passes all 25 report/sharing Chromium scenarios (zero skips/flaky tests), 1,823 unit tests, types, affected lint, license headers and the bundle budget (1,035.8 KB / 1,050 KB). New depth cases verify copy limits, missing COUNT capability and empty preview behavior against the real backend, with persisted report and saved-view comparisons. This is targeted verification, not complete embedded-query equivalence.
Inspected desktop light/dark captures show the reset links beside the view selector, with readable controls, no dialog horizontal overflow and no page errors. Existing dialog, filter and preview composition is preserved. Evidence: `report-reset-{light,dark}.png`, `report-reset-visual-result.json`, `report-reset-chromium.log` and `report-reset-final-units.log` in the local visual-review directory. The broader original-Next comparison remains open; no complete screen family is approved.
## Earlier checkpoint — September 29, process details and RC1
Fresh Chromium desktop comparisons cover process widgets, results, collapsed internal errors and expanded error details in light and dark modes: 16 original/current captures, with no setup/runtime errors or document overflow. The inspected widget, result and expanded-error pairs preserve the original Next card and action composition. Reload controls remain compact; desktop widget content is slightly shorter than the reference. These checks do not approve the complete process family.
The expanded-detail review led to **VIS-028**, a reproduced accessibility defect in the application-theme configuration: its orange error color yielded 2.8:1 contrast for 12px text. Default light/dark passed. Process details now use the same derived dark/light error-text shades as the query error panel. Text size, position and disclosure behavior are unchanged. The three-theme regression first failed on the application theme, then passed; nine real-backend Chromium error/retry/completion cases and all 1,806 units pass. Build, types and affected lint pass. Evidence: `process-detail-contrast-{red,green,build,unit}.log` and `rc1-process-visual-{light,dark}.log` in the local visual-review directory.
RC1 preparation separately passed the full 544-case Chromium gate (before this one-line contrast correction), clean JAR verification, dependency audit and focused checks. RC1 was subsequently published from `e901df9` on September 29, with Maven Central and GHCR artifact verification recorded in the release notes. See the [RC1 notes](../releases/1.0.0-RC.1.md). The original Next visual review and all remaining Material behavior parity gaps remain final 1.0 work.
## Earlier checkpoint — September 29, appearance preferences
The user-facing Preferences dialog now exposes Light and Dark choices using native radio controls within its existing layout. The choice applies immediately and persists under the existing `qqq-dark-mode` browser/origin key; a fresh profile remains light. An application theme still requires light mode, now with a visible explanation and disabled choices. The saved preference is retained under that override; Reset to Defaults clears it to light along with table and record defaults. No system-following or account synchronization is claimed.
The initial two integration tests failed because the controls were absent. The completed change passes **1,803 unit tests** and **15 real-backend browser cases across all five profiles** (NAV-056 appearance and NAV-038 shell touch/focus). The browser audit initially caught transitional colors while switching palettes; it now waits for actual CSS animation completion before checking the whole visible dialog against WCAG A/AA rules. A subsequent tablet diagnostic caught a metadata request interrupted by the test's immediate reload; the scenario now waits for the application theme to load before reloading. Failure logs/traces are retained under `appearance-transition-*` and `appearance-reload-*`. Build, types, changed-file lint, license and bundle budget pass (1,034.5 KB / 1,050 KB).
Refreshed original/current Preferences captures in both themes at desktop, tablet and phone sizes, including the dialog's own scroll region. Inspected current light desktop/phone top and dark phone bottom: controls remain within the viewport, the footer remains available, and lower layout options are reachable by scrolling. The screenshot tool was corrected to target the Preferences scroll area rather than the phone navigation drawer. Current export fingerprint: `d87518fddea70bd830c3fb10ea3ebaae41410feca77f6b6df2daf56a24370c1a`. The gallery still has 106 light / 87 dark states per viewport; this refresh adds no new state IDs. No whole screen family is approved. Full remote acceptance on the previous pushed head is still running; this targeted result is not release acceptance.
## Earlier checkpoint — September 29, widget width and acceptance setup
The remote phone failure reproduced with a wider native font: the standalone choice stayed 224px while its neighboring scalar input measured 230px. The original width assertion is retained. The metadata-editor minimum is now `min(15rem, 100%)`: 240px where available, capped to 230px at the tested 320px phone viewport. A browser-only trial preceded the one-line source correction; the final export reproduces the trial without injected styles. System, Arial and monospace measurements and 320/393/810/1440px viewports show no document overflow. Plain scalar controls are unchanged.
The Firefox dashboard trace showed its page-opening hook ran before the owned backend session and diagnostics existed. A pre-navigation session assertion failed locally. The hook now initializes those fixtures first and verifies the owned session cookie; metadata and widget reads start after the reset. This corrects a definite fixture precondition. The original 17-second render delay is not claimed independently explained; full CI must verify that it does not recur.
All **105 targeted real-backend cases** pass across Chromium, Firefox, WebKit, phone and tablet: the complete sample-dashboard group and standalone input cases, including system/monospace center clicks and a 320px viewport. All **1,801 unit tests**, production export, types, lint, license and bundle checks pass. The original width and dashboard-heading assertions remain. Logs use `widget-ci-*`, `widget-width-*` and `widget-setup-red-browser.log`; the width red trace is preserved separately.
Refreshed two widget states in both themes at three sizes: 24 original/current entries. Inspected the current desktop editors and light/dark phone open-choice menu. Current captures match the final export with no setup/runtime error or document overflow. The original has no choice menu; its new state is explicitly marked non-equivalent. Recounting all six canonical manifests gives **106 light and 87 dark states per viewport**; this corrects the prior checkpoint's dark count after form captures were added. No whole family is approved.
## Earlier checkpoint — September 29, error readability and refreshed form review
**VIS-027 corrected:** the query error panel failed at 4.24:1 in default light mode, Retry hover at 1.8:1 in dark mode, and the configured orange error theme at 2.52:1. Error text now uses the existing theme-derived red-800 in light mode and red-400 in dark mode shades, with red-300 for dark Retry hover. The visible Sonner error notification separately failed at 4.34:1; its light surface now uses red-800. The configured orange pivot validation also failed at 4.25:1 and now uses red-800 in light mode. Panel, dialog and notification geometry, placement and timing are unchanged.
The broader browser run exposed lazy-fixture leakage: default report-theme tests did not request the backend reset and inherited the preceding configured theme. Its 34 passes / 16 failures are retained as diagnostic evidence. The report spec now requests the reset before every test; a deliberate configured-orange pivot case retains that contrast regression. Query and pivot checks assert the actual root theme. The final run passes **55 real-backend cases across all five profiles**: error panel/notification contrast, Retry hover and request, dismissal, SQL-verified recovery, report-link and pivot contrast, and theme metadata. All **1,801 unit tests**, production export, types, changed-file lint, license and bundle checks pass (1,034.1 KB / 1,050 KB). This is targeted acceptance, not final release acceptance. Logs use `query-error-{complete,pivot,final}-*`; confirmed earlier failures remain in their logs and `query-error-theme-red-artifacts`.
Refreshed original/current create, edit, copy, validation and field-lab forms in both themes at all three sizes:60 entries / 120 images, including scroll sequences. Inspected desktop edit, desktop field-lab middle/bottom, phone field-lab top and edit bottom, dark phone validation, tablet create, and dark desktop field-lab bottom pairs. Their form-body section spacing, field placement and footer positions match the original; the Person currency prefix is metadata-driven. This does not approve uninspected field interactions, modal forms or the whole forms family. Original/current error and Retry-hover captures were also refreshed. Final current-export refresh covers eight states per theme/size (48 entries): five forms, two query errors and pivot validation; no setup/runtime errors or document overflow, all with matching build fingerprints.
Inventory is **105 light / 84 dark states per viewport**. Other captures may carry older export fingerprints and must be checked before approval. No whole family is approved. Current preview on port 18769 serves the final contrast build; original on port 18768 remains the original Next design.
Full remote CI 36580709453 at 30db309 is now **terminal failure**: WebKit passed; phone WID-074 measured choice width 224px versus native scalar 226px, and Firefox WID-014 timed out waiting for the dashboard heading in beforeEach. The later failure snapshot contains that heading. Downloaded logs/traces are in `ci-36580709453-*`; neither failure is waived. The Firefox trace shows backend/diagnostics fixtures were never initialized before the failed page-opening hook. A phone font-loading probe did not reproduce the width failure: system-font scalar 201px and choice 224px stayed unchanged while the downloaded font was held/released. Investigate platform font sizing and hook setup next; do not change geometry or relax assertions without evidence. No merge or publication.
## Earlier checkpoint — September 29, query evidence and column-popup focus
**VIS-026 corrected:** opening Columns with Enter left focus on its toolbar trigger; activating the popup's Close button with Enter then left focus on the page body. The nonmodal popup now receives focus without scrolling or automatically opening a phone search keyboard. Explicit close returns to Columns; Escape keeps its existing focus return, and outside clicks retain focus on the clicked control. Embedded report column editors do not take focus when mounted. The popup layout and field controls are unchanged.
The component regression failed before the fix. A real-browser probe reproduced both focus failures; the corrected Playwright regression then failed at inactive popup focus. Its first attempt used a Testing Library matcher by mistake and is not counted as product evidence. After the fix, 48 affected component tests pass. The broader five-profile backend run passed 28/30; both failures assumed WebKit tabs to a button before search. A plain native-control probe showed Chromium/Firefox tab to Close while this WebKit runner tabs directly to search. The test now verifies those observed native paths while retaining popup focus, search, keyboard close, Escape and outside-click assertions. The final focus check passes all five profiles. Production export, types, lint, license and bundle checks pass. Logs use `column-focus-*`; the confirmed pre-fix artifacts are retained separately.
**Review evidence correction:** some older current captures still showed column widths that had already been fixed. The capture tool now stores a timestamp and SHA-256 fingerprint of the exported files in each new entry. The gallery displays them, flags untracked/older builds, and avoids resetting a newer scenario selection when an earlier fetch finishes. Six theme/viewport image-pair checks and both warning checks pass (`gallery-provenance-check.log`). Captures outside the refreshed query family still require freshness verification; they must not be treated as current approval evidence.
All 27 query states now have refreshed original/current entries in light/dark at desktop/tablet/phone sizes: 324 entries. Current entries have no setup/runtime errors or document overflow and match the final export fingerprint. Three added states cover column-popup keyboard focus, search and no matches. The original has no search control; those baseline captures explicitly state they are not equivalent interaction states. Inspected fresh desktop column configuration/menu/empty/error/loading states, desktop keyboard focus, tablet filtered columns and dark phone no-match presentation. Inventory is now 104 light and 83 dark states per viewport. No full family is approved.
**VIS-027 remains open:** the light query error panel fails a browser contrast check at 4.24:1 for its heading, message and Retry text (required 4.5:1). The dark panel passes. `query-error-contrast-probe.log` contains the measured foreground/background colors and affected nodes. Correct the text contrast while preserving configured status colors and the original panel layout, then verify both default and application themes.
Remote full CI36580709453 at30db309: WebKit passed; Chromium/phone/tablet and Firefox remain running. Keep local corrections unpushed until that run finishes. Full visual review, functional gaps and final release gates remain open.
## Earlier checkpoint — September 29, compact filter controls restored
**VIS-002 corrected for the reproduced desktop height regression:** the populated filter table started at y579, versus y535 in the original Next capture. The unsaved-view status forced a separate full-width row (24px), and the desktop filter header used 44px controls plus padding (20px above the original header height). A browser-only trial isolated both causes before the source change.
The status can now share available toolbar space at tablet/desktop widths; phone wrapping remains. Filter mode and sort controls use a compact 32px minimum on devices without coarse pointers and retain 44px minimums when any coarse pointer is available. The desktop panel header is41px, down from61px; the resulting panel and table positions match the original populated-filter capture. Basic/Advanced, sorting, ownership, save/reset actions and the single editable condition list remain intact. Tablet/phone measurements retain44px targets and no document overflow.
Verification: production export/typecheck, standalone typecheck, changed-file lint, license and bundle checks pass (1034KB/1050KB). All34 affected component tests and50 real-backend cases across Chromium/Firefox/WebKit/phone/tablet pass (QRY-080/083/084/051/093). These are targeted checks, not a whole-suite acceptance result. `filter-layout-red.log` preserves the pre-fix61px header failure; `filter-layout-trial.log` proves the browser-only correction; `filter-layout-green.log` checks the actual final export without injected styles. Other logs use `filter-layout-{unit,build,browser,types,license,budget}`.
Refreshed six current query states in both themes at three sizes:36 entries, no setup/runtime errors or document horizontal overflow. Inspected desktop light/dark populated filters, tablet populated filters and dark phone sheet. Original captures remain unchanged. Inventory stays101light/80dark states per viewport; no whole family is approved. Current preview includes this build. The full remote run36580709453 at30db309 is still running; general and image CI passed. Keep local corrections unpushed until the full run finishes. Full visual review and release gates remain open.
## Earlier checkpoint — September 29, copied-record timestamp persistence
The follow-up found ordinary record edits already protect unchanged timestamps by omitting them from PATCH. Base Copy and both child-row editor hosts submit complete values, however, and were discarding fractional seconds. The real-backend copy regression in America/New_York also reproduced a one-hour shift during the repeated autumn hour: `06:30:07.123456Z` became `05:30:07Z`. Copies and child edits now pass original record values through the existing wire converter; edited values retain normal local-to-UTC conversion.
Verification: **1,799 unit tests and 30 real-backend cases across five profiles pass** (REC-065/018/013/060 and both PRC-064 cases). REC-065 checks unchanged PATCH omission, the copy POST instant and source/copy SQL precision. Separate component regressions prove association and process child-row preservation; these are not claimed as new child-specific real-backend coverage. Production export, types, changed-file lint, license, whitespace and bundle checks pass (1033.9KB/1050KB). Logs use `record-datetime-*`; the confirmed pre-fix trace is retained in `record-datetime-red-artifacts`. The first browser attempt stopped at the existing unreadable-password copy guard; supplying a new fixture password allowed the timestamp defect itself to reproduce.
No layout or style changed in this correction, so existing visual captures were not repeated. Current preview includes the corrected export. Visual inventory remains101light/80dark states per size; no whole family approved. Full CI36580709453 at30db309 remains running across all browser jobs; general CI36580709451 and image36580709514 passed. Keep the new correction local until that full run finishes. Final visual review, complete release verification and publication remain open.
## Earlier checkpoint — September 29, date-time input review
**VIS-025 corrected:** zoned process values were assigned directly to datetime-local controls, which displayed blank fields. Plain standalone blocks sliced UTC digits into a local-time control and discarded seconds. Shared standalone editors displayed local values but sent unzoned text through their action callback. New component regressions and a fixed America/New_York browser context reproduced the failures.
Process fields and standalone blocks now reuse the existing date-time conversion helpers, retaining their native controls and seconds. Process payload conversion occurs after contributors. The converter can retain an original instant when its displayed wall time is unchanged, preserving the second occurrence of a repeated DST hour and hidden subsecond precision. Edited local times become UTC instants. Material's unadorned TEXT/HTML block mapping was inspected and confirmed to use ordinary text controls; no speculative editor redesign was made.
Verification: **1,796 unit tests and 30 real-backend cases across all five profiles pass** (PRC-064 unchanged/edited, WID-075, PRC-063/062 and REC-018). Backend SQL proves unchanged spring and autumn timestamps retain their instants; edits across the spring transition use the correct offset. A unit regression proves unchanged subsecond precision survives. Production export, standalone types, changed-file lint, license, whitespace and bundle checks pass (1033.9KB/1050KB). Logs: `block-datetime-{red-unit,callback-red-unit,precision-red-unit,red-browser,green-unit,unit,build,browser,types,lint,license,budget}.log`; pre-fix browser traces remain in `block-datetime-red-artifacts`.
Paired process timestamp and standalone editor captures were added/refreshed in light/dark at three sizes:24entries, no setup/runtime errors or document overflow. Inspected desktop process pairs, phone process inputs and dark phone standalone controls. The original process blocks show raw text and its regular form timestamps are blank; current controls show dates, seconds and the existing shared editor timezone hint. That hint adds height relative to the original block renderer and remains part of the broader composition review. Captures use UTC; separate acceptance uses America/New_York. Inventory101light/80dark states per size; no whole family approved.
Full CI36573705464 at121468b is now terminal: Firefox and WebKit passed, Chromium/touch failed dependency download before tests. This satisfies the hold on pushing local commits. Final full verification on the new source and the complete release audit remain required; no merge or publication.
## Earlier checkpoint — September 29, input configuration review
**VIS-024 corrected:** advancing a process moved focus from a configured autofocus input to the screen heading. A real-browser regression reproduced the inactive Plain Code field on screen two. The process now preserves focus already inside a marked autofocus editor; ordinary result screens still receive heading focus. Plain and shared metadata editors both pass the transition check.
Enter also inspected trimmed text while submitting the untrimmed process form. PRC-044 reproduced surrounding spaces in the actual SQL record. Plain/shared text blocks now normalize their own value before validation and submission. Required whitespace cannot advance; standalone shared blocks validate the normalized value before invoking their callback. Typed numeric/date/checkbox values and multiline, file and picker keyboard behavior retain their existing paths.
Verification: **1,791 unit tests and 25 real-backend cases across all five profiles pass** (PRC-037/044/062/063 and WID-074). Production export, types, changed-file lint, license, whitespace and bundle checks pass; total JavaScript is 1033.6KB gzip against 1050KB. PRC-063 checks placeholders, retained process values, first blur without premature validation, required whitespace, both autofocus paths, result heading focus and stored SQL text. Pre-fix traces remain in `block-enter-red-artifacts` and `block-focus-red-artifacts`; logs use `block-{enter,focus}-red-*` and `block-input-config-*`.
Added paired plain/formatted input focus captures in both themes at three sizes:24entries, no runtime/setup errors or document overflow. Inspected desktop plain pairs, phone formatted pairs and dark tablet formatted focus. These process cards preserve the original geometry; current required markers and focus rings are visible. Inventory is100light/79dark states per size. This closes the reproduced focus/Enter defects, not the full widget/process configuration audit or any whole-family visual approval.
Both previews use the updated fixtures; the original export is unchanged and the current export includes these fixes. Full remote run36573705464 at121468b: WebKit passed, Firefox running, Chromium/touch failed dependency download before tests. The final full release gate remains open; no merge or publication.
## Earlier checkpoint — September 29, record section keyboard review
**VIS-023 corrected:** record tabs had tab roles but no arrow navigation, single tab stop or tab/panel associations. The real-backend regression failed when Right Arrow left focus on Overview. Tabs now use manual activation: Left/Right/Home/End move focus; Enter/Space opens the section through the existing URL callback. This avoids mounting section widgets merely while moving focus. Tab enters the named panel, with a visible focus indicator. Phone accordion behavior is preserved. The approach follows the [WAI tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
Verification:1786unit tests, production export/typecheck, final types/lint/license/whitespace and bundle1033.5KB/1050KB pass. The related five-profile run passed32cases and found one ambiguous phone test selector (outer accordion and nested section share a region name). The test now follows the trigger’s actual aria-controls and retains name/value assertions; all five keyboard rechecks pass. Existing section/list/deep-link/joined-field checks passed. Logs: `record-tabs-{red-browser,green-browser,keyboard-green,unit,build,types,lint,final-lint,budget}.log`. The pre-fix trace is retained in `record-tabs-red-artifacts`.
Added record-section-focus and refreshed record-small pairs in light/dark at desktop/tablet/phone:24entries, no runtime/setup errors or page overflow. Inspected desktop original/current record card, dark tablet focus and phone accordion focus. Current layout retains the recovered original record composition; focus styling adds no layout spacing. Inventory is98light states/size and77dark. These are reviewed states, not whole-family approval. Configured/denied/error/loading variants and remaining form/widget review continue.
Local changes remain unpushed while fullCI36573705464 at121468b is active: Firefox/WebKit running; Chromium/touch failed during Corepack network setup before tests. General CI and image build passed. No merge or publication.
## Earlier checkpoint — September 29, compact report inputs restored
**VIS-020 corrected for standard report input screens:** restored the compact card, 384px ordinary field maximum, and left-aligned Generate Report action below the inputs. Removed the added visible Input/Step header and divided footer. Cancel remains a secondary inline action. The accessible form name, shared dynamic form, validation, typed values, possible values and process cancellation remain. Configured grid widths and rich editors retain their layout; longer or custom report processes retain their step navigation and components.
Before the change, two unit regressions failed because Generate Report was absent, and the real-browser layout regression failed because the extra Step header was visible. Final verification: **1786unit tests**, production export/typecheck, changed-file lint, license, whitespace and bundle checks pass. **30 real-backend cases across all five profiles pass**, covering report layout/required validation/database-matched output and shared mixed/filtered/required/composite process forms. Logs: `report-compact-{red-unit,red-browser,green-unit,full-unit,build,green-browser,lint,budget}.log`; pre-fix browser artifacts are retained in `report-compact-red-artifacts`.
Paired input, validation and result captures were refreshed in both themes at desktop, tablet and phone:36entries, no runtime/setup errors or document overflow. Inspected desktop input, phone input pairs, dark phone validation pairs, and tablet result. The normal card geometry now matches the original; inline Cancel and shared validation focus/message treatment remain visible differences. No whole report family is approved. Canonical inventory remains97light/75dark states per size. Current preview18769 includes this correction.
Final diff review kept rich/file/code/widget editors on their existing grid rules, including configured file widths. The final export,1786units and changed-file lint pass; RPT-006 passes again in all five profiles. Final logs are `report-compact-final-{build,unit,browser,lint,budget}.log`. Both current dependency audits report zero known advisories (316production dependencies;1038in the full audit); this does not replace the remaining application security checks.
Remote full acceptance36573705464 at121468b remains active: Firefox/WebKit running; Chromium/mobile/tablet failed before tests in Corepack dependency setup with network ETIMEDOUT/ENETUNREACH. Its log is retained as `ci-121468b-chromium-mobile-tablet.log`. General CI36573705788 and image36573705430 pass. Do not push the local report correction while the full run is live; final full verification and release remain open.
## Earlier checkpoint — September 29, saved-view reload correction
Full run36564701143 is terminal: Chromium/mobile/tablet and WebKit pass; Firefox failed QRY-093. The trace showed new filtered data before asynchronous page navigation updated the URL. Query URL changes now replace the current history entry synchronously through Next-integrated native history, preserving other parameters and the hash. No query layout was intentionally changed.
A deterministic Firefox regression failed before the fix with the old filter still in the URL. The corrected export passes25 saved-view, delayed-navigation, back/forward, quick-view and column-menu cases across five profiles, plus20 pagination, sorting, quick-search and return-navigation cases. The current tree passes1784unit tests, production export/typecheck, final standalone types, changed-file lint, license and whitespace checks; bundle1033KB gzip remains within1050KB. Logs: `query-url-{red-unit,red-browser,green-browser,related-browser,build,unit,types,lint}.log`. The final full run with this correction remains pending; these are focused checks.
The current preview includes the correction. Desktop report-input pairs were re-inspected against original source: original compact card has384px maximum-width inputs and an inline Generate Report action; current shared process screen adds header and footer dividers, step count and wider fields. VIS-020 remains open. Preserve the shared dynamic form/process lifecycle while restoring the report presentation. Full visual review and all family approvals remain open.
## Earlier checkpoint — September 29, composite metadata inputs
The block INPUT_FIELD integration now reuses Next's field editors for choices, formatting and supported adornments, retaining the process form and multipart upload path. Plain scalar input layout remains the reference. The richer code/filter/upload editors add genuine capabilities absent from the original block renderer; their additional height and composition are still under visual review. This does not approve the widget or process family.
**VIS-021, corrected in source:** the dispatcher initially treated the backend's default `%s` format as custom formatting. This changed ordinary scalar controls into the shared form layout. A fixture-based browser failure and unit regression exposed it; neutral formatting now preserves the original scalar path. Hidden and read-only metadata are respected in both hosts.
**VIS-022, corrected in source:** a standalone choice editor collapsed to approximately101px, placing its Clear button at the center of the control. A native hit test confirmed that a center click cleared the value instead of opening the choices. The original scalar input is approximately200px wide; standalone metadata editors now use a14rem minimum, also accommodating Firefox's approximately217px native input. The browser assertion compares their widths and retains the center click.
A typed-input fixture existed but was not registered, so the first new capture showed Page not found. It now has a dedicated `widgetInputEditors` app. The capture script treats unexpected not-found screens as failed evidence. Paired captures also cover `prcBlockEditors`, whose original renderer lacks the rich editors, and the unchanged `prcWidgets` route. These comparisons distinguish new capability from restored composition. Final verification and refreshed captures are recorded below when complete.
The remote full run36564701143 has WebKit success, Firefox520passes/1failure(QRY-093), and Chromium/mobile/tablet still running. The Firefox trace shows a concrete reload race: B-filter data appeared before asynchronous router replacement updated the address bar, so reload used the previous Avery filter. Fix remains open; no assertion has been weakened. Artifact11033840508 and its log are retained locally.
Final input-editor checkpoint: **1,783 unit tests pass**, production export/typecheck, changed-file lint, license and whitespace checks pass, and the bundle remains within1050KB gzip. **35 real-backend cases pass across five profiles**, comprising30 widget/process checks and five unchanged QRY-093 rechecks. Those rechecks do not resolve the proven CI URL race. Final logs are `block-editors-width-{build,acceptance,visual-light,visual-dark}.log`, `block-editors-final-{unit,types,lint,license,budget}.log`. The earlier width run's failures and traces are retained, including the aborted local run after repeated diagnosed failures; no remote job was canceled.
Canonical inventory:97light states/size(194original/current entries),75dark(150entries). All36 entries for the three compared scenarios have no setup/runtime errors or page overflow. Inspected final light desktop/phone standalone controls, light phone process lower controls, dark phone standalone controls, dark tablet process lower controls, and existing Widget Lab. Plain fields retain their compact treatment; actual adorned editors are larger than the original scalar controls. Complete configuration/autofocus and visual composition review remain open in the parity ledger. The two new acceptance rows are PRC-062 and WID-074; the generated whole-suite matrix still requires final release reconciliation.
## Earlier checkpoint — September 29, report interaction review
**Release not ready; full visual review incomplete.** Recovery head `06cf1b0` is now pushed to PR #14. General CI `36564701134` and image build `36564701149` pass; full acceptance `36564701143` remains running. The preceding full run ended with tablet/WebKit Export failures and Firefox saved-report opening delay. Local report review changes are verified separately and will stay local while the new full run is active.
Expanded the review with report inputs, required validation, completed output, filter variables, pivot editing/validation, schedule creation/day selection, sharing, and keyboard tab focus. The capture script now measures dialog-body scroll positions. Query/report dark coverage is paired at all three sizes; unavailable original editors remain explicitly non-equivalent. Other families still have dark/theme/interaction gaps. No whole family is approved.
**VIS-018 (medium), corrected:** the report filter/column tabs declared tab semantics but had no arrow-key behavior or labeled panel. Added automatic activation with Left/Right/Home/End, a single tab stop, a linked panel and visible keyboard focus using the existing script-tab pattern. Filter and column drafts persist across switches; hidden Columns metadata remains respected. Unit regression failed before the correction. The first browser run exposed Firefox's native tab stop on a scrolling panel at 1280×720; a native probe proved that an explicit `tabIndex=-1` moves Tab into the column search field. The assertion was preserved.
**VIS-019 (medium), corrected:** pivot validation text on its tinted background measured 4.12:1 in light mode and 4.21:1 in dark mode. Axe independently rejected both. The alert now uses darker light-mode red text and lighter dark-mode red text, preserving its border, tint, layout and validation behavior. Final native measurements are 5.48:1 in both themes, and all ten light/dark pivot contrast cases pass across the five browser profiles.
**VIS-020 (review open):** report inputs now use the shared process screen. The original compact input card and Generate Report button have gained an Input/Step header, wider desktop field, and Cancel/Submit footer. The full process lifecycle and dynamic fields must remain, but the additional height and action placement are not visually approved. Compare `report-inputs` and `report-input-validation` on desktop/phone before deciding the final presentation. Scheduling and sharing retain their original dialog layout in the inspected pairs; scheduling adds the metadata-driven Variable Values section. Its lower controls are reachable by scrolling.
Inspected pairs: populated desktop/phone filters; dark phone quick editor, columns and export; dark tablet relative-date menu; desktop/phone report inputs; phone schedule scrolls and sharing; desktop pivot; phone pivot validation including its lower controls; dark phone variables/pivot validation/sharing; dark tablet schedule Days popover. This list names reviewed evidence, not whole-family approval. Reports still need configured variants, denied/error/loading states, populated schedule/share interactions, complete keyboard closure, and review of the added editor composition. The functional embedded-query equivalence row remains Partial.
Evidence: `query-report-dark-completion.log`, `report-interactions-{initial,light,dark}.log`, `report-tabs-{red,green,focus-probe}.log`, `pivot-contrast-{light,dark,red}.log`; final verification and corrected screenshots follow this checkpoint. Guides now document report-tab keyboard behavior and the Firefox scroll-container distinction.
Final checkpoint verification: **1,773 unit tests and 45 real-backend report cases pass across five profiles**. Export/build typecheck, final standalone TypeScript check, changed-file lint, license headers, whitespace and bundle budget pass (1032.1 KB gzip / 1050 KB). Logs: `report-review-final-{unit,build,acceptance,budget}.log`, `report-tabs-focus-green.log`, `pivot-contrast-{light,dark}-green.log`. Corrected tab-focus and pivot-validation captures were refreshed in both themes at all three sizes and inspected on phone. Canonical inventory is now **95 light states and 73 dark states per size**, with 190/146 original/current entries. Report captures have no runtime/setup errors or document horizontal overflow. This remains partial release evidence; full visual review and the complete final release gate are open.
## Earlier checkpoint — September 29, inline profile naming restored
**Release not ready; full visual review incomplete.** PR #14 remains open at `4748b6c`; local recovery is verified separately. Full real-backend run `36557085746` now has Firefox519passes/1failure (saved-report record opening) and WebKit519passes/1failure (Export). Chromium/mobile/tablet remains running. WebKit's Export failure is corrected locally; Firefox is under diagnosis. Nothing is merged or published. No whole visual family is signed off.
The light inventory contains **85 scenarios at each of three sizes**. Dark coverage remains partial (47 desktop, 45 tablet, 47 phone scenarios, with some unpaired entries). Unavailable original interactions are explicitly marked. Captures are evidence to inspect, not approval.
New, Save As and Rename now use the original Next inline naming layout inside the profile section. Compact field/button styling, Enter behavior and focus return are restored while ownership, backend errors, copying and overwrite/delete confirmations remain. Paired light/dark captures at all three sizes show the recovered editor; added reset controls still increase the surrounding section height. This is not full process-family approval.
Current verification: **1,771 unit tests and 40 bulk-load real-backend cases across five profiles pass**. The earlier menu/Preferences checkpoint passed50/50 affected cases. Production export, final typecheck/lint, license and bundle budget pass (1031.9 KB gzip / 1050 KB). The PRC046 lifecycle test now targets the portaled menu correctly and verifies duplicate-name rejection. Parity recount is728Done,22DifferentUX,5Partial,2Missing,38N/A across795existing rows; the previous summary omitted one supplemental metadata row.
## Evidence and method
- Current source: `feature/next-1.0`; restoration, report editor and guide commits are integrated. The live current preview includes the verified recovery changes described below. Both comparison exports were refreshed on September 29.
- Both builds run the same current sample JAR and acceptance fixture sources, with fresh admin browser contexts and the same Inter font bytes. Historical-reference type checking is skipped only in its isolated build; the current export completes its normal type check.
- Local comparison gallery: <http://127.0.0.1:18770>. Earlier UI: <http://127.0.0.1:18768/app>. Current UI: <http://127.0.0.1:18769/app>.
- Evidence is retained under `../qqq-frontend-next/test-results/visual-review/`: source/build provenance, capture scripts, manifests, screenshot sequences, and server/build logs. It is ignored generated output, not a published artifact.
- Review dimensions: hierarchy, typography and spacing, color and icon treatment, control grouping, responsive layout, and interaction states. Confirm findings against source before changing defaults; preserve explicitly configured metadata behavior.
## Coverage
Current inventory: **106 light and 87 dark states per viewport**, each with original/current entries. The latest checkpoint refreshes standalone widget inputs and the open choice menu against the final export. Other states include older captures and need current-build verification before approval. The table below describes the initial inventory and its remaining review scope; later checkpoints document additional coverage, not whole-family approvals.
The initial pass captured 38 scenarios per build at desktop (1440×1000), tablet (810×1080), and phone (393×851): 228 build/state combinations and 447 screenshots, including long-page scroll sequences. Every entry viewport has received a paired visual scan. Detailed review of all scroll sequences, dark/configured themes, keyboard focus, and additional interaction states remains. No whole family is signed off yet. The baseline report-filter editor is unavailable because that functionality did not exist there; its capture is not an equivalent editor state. The desktop baseline delete capture was corrected during the populated-filter follow-up.
| Family | Captured desktop scenarios | Remaining review scope |
|---|---|---|
| Shell and navigation | Dashboard, app home, nested navigation, not found | Search, command menu, notifications, user menu, help, login/session, phone navigation, branding/theme variants |
| Query | Small and wide table, filters, advanced filters, columns, views, export | Populated criteria and groups, quick-filter editing, column menus, density, cards, selection/bulk controls, saved-view states, pagination, empty/error/loading |
| Records | Compact record, person, record lab, audit, delete | Every tab/list/card variant, associations, metadata menus, configured sidebar, collapse, sharing, warnings/errors |
| Forms | Create, edit, copy, field lab | Each field editor, validation, disabled/read-only, dirty state, full copy, file/association editors, modal forms |
| Processes | Wizard, component lab, widget step, bulk load | Subsequent steps, selection, validation review, progress, results, errors, cancellation, upload mapping |
| Reports | Run inputs, saved report, edit, filter/column editor | Filter variables, columns, pivot editor, scheduling, sharing, results/download, validation |
| Widgets | Sample dashboard, gallery, blocks, parents, controls, states, tables/charts, record widgets/editors | Map all 38 dispatcher labels to evidence; inspect every scroll capture, dropdown, tooltip, menu, loading/error/empty state |
| Developer tools | Table and record developer pages | Script editor/viewer, code tabs, dialogs, ESB metadata/tools, responsive layouts |
## Findings
| ID | Severity | Observed difference | Evidence | Status |
|---|---|---|---|---|
| VIS-001 | High | Wide query tables now use very narrow numeric/date/boolean defaults. Header labels are reduced to fragments, dates wrap across lines, and monetary values clip. The reference keeps readable columns and scrolls horizontally. | `desktop/query-person`, `desktop/query-advanced`; `DataGrid.tsx` and `query-columns.ts` | Restored 150px defaults; configured/user widths preserved. Desktop dates/currency verified after rebuild. Long heading truncation remains under review. |
| VIS-002 | Medium | Switching an empty query to Advanced adds a long unsaved-view status and links to the toolbar, moving Export and view controls to another row. The filter panel also grows substantially with separate mode and sort controls. | `desktop/query-advanced` | Corrected for the reproduced desktop regression: status shares available toolbar space and compact mode/sort controls restore original panel/table positions; 44px touch targets remain. See latest checkpoint. Whole query-family review remains open. |
| VIS-003 | Medium | A record with process actions now has separate Copy and Delete buttons plus an Actions menu. The reference groups secondary actions in the menu. | `desktop/record-person`, `tablet/record-person`; `RecordActions.tsx` | Corrected and verified in desktop/tablet/phone record captures. Enabled menu actions suppress duplicate direct buttons, including nested menus. NAV-055 passes all five profiles. |
| VIS-004 | Medium | Record tabs crowd the identity header and field groups have more vertical spacing. The earlier header has a clear gap above tabs. | `desktop/record-person`, `desktop/record-small`; `RecordViewHeader.tsx` display-contents wrapper and field layout | Normal wrapper and 16px field gap restored; desktop/tablet/phone record captures reviewed. |
| VIS-005 | Medium | Widget header icons changed from colored outline glyphs to filled square tiles; the sample pie chart changed from a doughnut to a solid pie. | `desktop/widgets-sample`; `WidgetIconTile` and `QqqChartWidget` | Header glyph colors and doughnut restored; image paths, main-icon tiles, explicit colors, slice links, tooltips and legend toggles retained. Fresh desktop/phone captures reviewed; 30/30 affected five-profile widget cases pass. |
| VIS-006 | High | Phone query pages always expose mode, sort, and quick-filter controls beneath the toolbar, pushing the first Person card from approximately y=474 to y=708. Opening the Filter sheet also adds unsaved-view links behind it. | `phone/query-person`, `phone/query-small`, `phone/query-filters`; `RecordQuery.tsx` unconditional phone controls | Corrected: shared controls now live in the phone Filter sheet; opening preserves mode. Fresh phone captures confirm first card returns to approximately y=474. Affected filtering/focus/record acceptance passes 83/83 across five profiles. |
| VIS-007 | Medium | Phone app-page titles are reduced to a few characters after adding a home breadcrumb and a separate command-palette control. The earlier header preserves substantially more of the page name. | `phone/app-home`, `phone/widgets-sample`, `phone/not-found`; `Header.tsx`, `Breadcrumbs.tsx` | Corrected: compact mobile gaps, current crumb constrained to available width, and scrolling after metadata arrives or the trail resizes. Fresh phone app/widget captures reviewed; all header controls retained, navigation/focus/touch checks pass in five profiles. |
| VIS-008 | Medium | Process widget buttons changed from compact primary buttons to full-width slate bars, with extra margins pushing the footer below the first phone viewport. | `process-widgets` on all sizes; process migration to shared `QqqComposite` inherited `ButtonBlock` defaults | Partially corrected: hosted process buttons use compact primary styling and disable during work; metadata colors/formats and shared action handling preserved. Desktop/phone captures reviewed; five-profile PRC-035–037 pass. Process text/label/header spacing still exceeds the earlier Next layout and remains under review. |
Existing defects also found in the reference: Views menus run off the right edge on phone/tablet; the phone Export menu opens partly off the left edge; records with many tabs have overlapping tablet labels; sample Big Number context overflows on phone. These are production-quality issues even though they predate the parity changes. They require focused interaction checks and fixes, not preservation as design intent.
Views and Export overflow is now corrected using the existing Radix dropdown pattern: collision-aware placement, available-height scrolling, arrow-key selection and Escape focus return. Existing item actions, native disabled buttons and permissions are preserved. Fresh desktop/phone captures reviewed; 45/45 saved-view/export real-server cases pass across all five profiles, including full viewport intersection assertions. Tablet tab labels and Big Number context are corrected in the responsive checkpoint below.
Dashboard, app-home content, wizard entry, basic process components, centered Person create/edit/copy forms, blocks/parent widgets, and developer-page structure retain the earlier overall layout in the captured states. Form headings and currency adornments differ. Reports and record-widget forms gained previously missing filter, pivot, dynamic-form, and variable sections; their additional length is functional work that must survive restoration. Typed widget table cells now render values instead of raw markup/object strings. These observations cover entry states only and do not approve the full workflows.
## Historical release checkpoint — September 28
- Next UI 1.0.0 remains unreleased. PR #14 remains open at `93b7b5a`; full real-backend run `36507768282` is in progress. The local query-menu follow-up described below is verified separately.
- Remote source/typecheck/lint/unit/coverage, mocked E2E, Storybook, bundle, CircleCI and both architecture image builds pass. Full real-backend run `36501934828` completed with failures in every job. Chromium/mobile/tablet reported 143 failures and 1343 passes; Firefox reported 52 failures and 460 passes. This is not a green release gate.
- A missing middleware API dependency in the separate security fixture caused many failures. Both fixture launch paths now share classpath resolution. The local security/no-search/process-field-widget rerun passes **124/124 in Firefox and WebKit**.
- Report preview/editor verification passes **15/15 across all five profiles**. Read-only previews support temporary paging, sorting and resizing without writing to the saved report. Column widths from metadata are retained.
- Current full unit checkpoint: **1765/1765**. Production export/typecheck, license headers and bundle budget pass (1032 KB gzip / 1050 KB). The remaining CI regression set passed 140 cases across all five profiles; its five script author/date selector failures were corrected and rerun successfully (5/5). This does not replace the final full gate.
- Functional ledger remains **734 Done, 15 Done with different UX, 5 Partial, 2 Missing, 38 N/A** (794 rows). Remaining groups include base-path hosting, Google Drive picker, embedded query/report controls, complex block input adornments, unmapped icon names, and script autocomplete.
- Developer, agent and CSS-hook guides are integrated; the developer/agent report-preview descriptions now reflect the paged grid. Final browser workflow and visual approval remain open.
## Release sequence
1. Complete the screen/state inventory and paired desktop, tablet, and phone evidence, plus theme and accessibility states.
2. Review every capture and record regressions with source causes. Correct shared defaults and verify the affected families together.
3. Reconcile the developer/agent guides and functional parity ledger with the chosen Next interactions.
4. Run the final five-profile real-server acceptance, source, accessibility, security, performance, and packaging gates on the resulting source.
5. Merge and publish Next UI 1.0.0 only after the complete release evidence is green. QQQ 4.1 remains unreleased.
September 28 follow-up: phone filter controls and restored widget styling are in the current preview on port 18769. The initial capture remains archived for comparison; manifests contain refreshed captures only for the scenarios explicitly recaptured. Remaining priority regressions are VIS-002 (dirty-view toolbar), VIS-007 (phone header identity), VIS-008 (process widget button defaults), plus responsive defects and detailed visual states.
Later September 28 checkpoint: VIS-007 and process button defaults are corrected in the refreshed current preview. Full unit suite: 174 files / 1763 tests pass. Production webpack export/typecheck, source lint (0 errors, 5 existing warnings), license and bundle budget pass. NAV-026/037/038 and PRC-035/036/037 pass 30/30 across the five profiles. Header metadata-arrival and process disabled-action regressions have red-to-green unit evidence. Process spacing, VIS-002, responsive defects, and detailed states remain open; these focused checks do not replace the final full release gate.
Query-menu checkpoint: full unit suite 174 files / 1765 tests passes. Static export and standalone webpack builds/typecheck, lint (0 errors, 5 existing warnings), license and bundle checks pass. Mocked E2E passes 102 tests with two existing skips; its advanced-filter helper now explicitly selects Advanced after opening Filter, matching the restored mode-preserving behavior. This corrects the 13 stale-helper failures in CI run 36501112971 at pushed head `75044c1`. Real-backend CI run 36501113002 on that head was still running at this checkpoint. Final all-surface visual review and release verification remain incomplete.
Mocked-suite follow-up: the two shortcut-suppression checks were skipping when the lazy-loaded query input had not appeared yet. They now wait for the input, verify the typed character, and assert the global dialog stays closed. Full mocked E2E passes **104/104, no skips** (`query-menus-mocked-e2e-final.log`). Menu checkpoint `5cb2f5a` is pushed; real-backend run 36501934828 and CI run 36501934819 are in progress. The previous real-backend run was cancelled by the new push, so it is not completion evidence. The test-only follow-up stays local until that run finishes or another necessary source checkpoint is ready.
Spacing review checkpoint: redundant desktop filter heading and nested padding removed; mode/sort controls share the header, while the phone sheet retains its accessible title. Process-hosted labels, text and non-card widget headers use compact spacing. Existing explicit metadata styling and coarse-pointer targets remain. Preview refreshed on port18769; screenshot evidence and `spacing-*.log` files are in the root evidence directory. The populated query still has both a preview and editable conditions; further hierarchy/mode-state review remains open. Tablet tab labels and phone Big Number context defects were reproduced with DOM measurements, and browser-only CSS trials confirmed their causes. Source corrections and regression assertions are the next checkpoint.
Responsive checkpoint: record tabs now use content-based flex sizing, preventing labels from drawing outside their buttons; the tab bar retains horizontal scrolling for larger sets of sections. Composite rows with space-between alignment wrap when needed, and Big Number widths stay within their available container. Explicit style overrides remain effective. `responsive-red.log` proves the new bounds assertions fail for clipped phone number context and tablet tab labels before the correction; `responsive-green.log` passes all18 applicable cases across5profiles (WID023/WID040/REC004). Production export/typecheck, affected ESLint and diffcheck pass. Fresh desktop/tablet/phone record, sample-widget, block and parent captures are stored in the manifests. Desktop sample-widget arrangement is preserved; phone context now wraps within the card. Both image architecture builds are green remotely; full real-backend CI36501934828 remains in progress.
Review inventory now has41scenarios at3sizes; the historical report-filter and quick-filter editors are unavailable and are explicitly recorded as non-equivalent captures. Populated condition and nested-group states were inspected at all3sizes, plus current quick-filter editing. Phone block/parent scroll viewports1 were compared with the reference and retain their content layout. Remaining work still includes all other scroll sequences, additional interaction states, themes/focus, mode/preview hierarchy, long table headings, remaining functional gaps, and final release gates.
## Report preview and CI diagnosis checkpoint — September 28
The report view now shares the editor's paged, sortable, resizable grid. Preview state stays local; saved column widths are preserved. The report tests pass **15/15 across all five profiles**, including database before/after comparison proving that preview interactions do not change the saved report. The latest phone report capture shows contained pagination with an unbroken page indicator. Selected Basic/Advanced controls use solid theme primary/foreground tokens; measured dark-theme contrast is **6.70:1**. These changes do not close the remaining embedded-report control gaps.
The remote full Firefox/WebKit jobs failed. A shared security-fixture classpath correction resolves missing middleware API classes: **124/124** security, no-search, and process-field-widget checks now pass locally across those two browsers. Other failures include stale control-location assertions, fixture catalog changes, and a real column-filter defect that adds a condition while leaving its editor hidden in Basic mode. The latter now opens Advanced directly; the rebuilt export/typecheck passes and affected browser verification is running. General Filter opening still preserves mode. Final whole-suite release verification and the full visual review remain open.
Visual state follow-up: paired form-validation and empty-query captures were added and inspected on desktop, tablet and phone, increasing the inventory to 43 scenarios. Empty-query composition and validation-field layout retain the earlier Next design. Form heading copy differs (Creating New Person versus Create Person), and configured currency adornments remain visible. Phone Field Lab scroll captures 1–6 were compared: field spacing, rich text, password/file controls and later metadata sections retain the original layout. These captures do not approve picker/editor interactions or all form states.
CI diagnosis follow-up: the column Filter action now opens Advanced so its newly added condition is visible; general Filter and keyboard opening preserve mode. Acceptance assertions follow the restored section headings, secondary action grouping, 150px default widths and statistics menus while retaining metadata-width and permission checks. Script revision tests now select required API metadata and assert the API line separately from author/date. The phone menu test uses actual taps: Firefox with touch emulation emitted no pointerdown for a synthetic mouse click, while four diagnostic touch taps opened the menu successfully.
Checkpoint verification: `recovery-five-profile.log` records 140 passes and five failures at the outdated author/date selector; `script-api-author-green.log` passes those five corrected cases. `recovery-full-unit.log` has 1765 passes. Final TypeScript and changed-file lint pass; source lint has zero errors and five existing warnings. License headers, diff check and bundle budget pass. Current preview port 18769 was refreshed with this export and responds 200.
Final old-run diagnosis: the Chromium/mobile/tablet log contains the same corrected failures plus two mobile export assertions receiving an intersection ratio of 0.9999999403953552 instead of exactly 1, and one WebKit internal navigation error. Viewport checks now measure all four edges with a half-CSS-pixel rounding tolerance; the remaining three-profile security/export rerun passed **184/184**, including the previously failed session-switch case. Together with the Firefox/WebKit run, the security classpath fix is verified on all five profiles. Validation error text was measured at 4.58:1 in light mode and 5.01:1 in dark mode on the sample form background.
Dark validation follow-up: phone and tablet comparisons retain the baseline field hierarchy, but both builds had nearly invisible native date-picker icons because no browser color-scheme was selected. The stylesheet now assigns light/dark color-scheme with the existing theme selectors; rendered verification passes in Chromium, Firefox and WebKit for both light and dark, with the OS preference deliberately opposite. A fresh tablet capture confirms the calendar icon is visible. Saved-view/export/metadata-theme checks pass **40/40 across all five profiles** after the viewport assertion correction.
## Query controls and gallery checkpoint — September 28
The light capture inventory now has **51 scenarios at each of three sizes**. New states cover density menu/compact/comfortable, cards, Selection menu, subset dialog, selected rows, and Save View As. Selection/density and both dialogs also have paired dark captures at all three sizes. Inspected evidence includes desktop card layout, phone selected cards, phone/tablet Save View As, and light/dark phone menus. These preserve the reference composition; review of the remaining states continues. Captures alone do not approve the entire query family.
**VIS-009 (medium):** Selection extends beyond the phone viewport; Selection and Density lack arrow-key navigation, and Density ignores Escape. Browser diagnostics reproduce these failures before the change. Both now use the existing Radix dropdown primitive with the original control styling and actions, bounded placement, keyboard selection, dismissal, and focus return. The selected Density label had 2.43:1 dark-mode contrast; it now uses the same primary/foreground pair as Basic/Advanced. The subset dialog restores focus to Selection.
Verification: 1765/1765 unit tests; 30/30 real-backend density/selection/bulk/card cases across Chromium, Firefox, WebKit, phone and tablet; production export/typecheck and targeted lint; license and bundle budget (1032 KB gzip). The first acceptance run passed29 and failed the Firefox dedicated phone case because synthetic mouse input did not open the Radix trigger; the corrected test uses actual taps and asserts menu bounds. Exact diagnostics are query-menu-red.log/query-menu-green.log; final acceptance is query-controls-acceptance-green.log.
The gallery previously missed multi-profile capture manifests. They are now merged into canonical per-profile manifests, future captures update those manifests, and a theme selector exposes light/dark evidence. Browser checks verify both comparison images load at all six theme/size combinations. Save-dialog capture setup now uses the visible Save As action (the separate save-as hook only exists for an existing saved view).
**Still open:** tablet headings truncate more than the baseline because a hidden sort glyph, touch-sized column menu, and cell padding consume label space. Compare tablet query-save-dialog background and desktop query-density-menu. Preserve configured widths and touch targets when correcting this. Full query state review, all other families, remaining functional ledger gaps and final release gates remain open.
Final menu evidence: selected Density contrast is **6.70:1** in dark mode (`density-contrast-green.log`), with the final phone capture inspected. The final export/typecheck, changed-file lint, license and bundle checks pass. A further **10/10** density/selection checks pass across all five profiles on the final build, including explicit focus return after the subset dialog closes (`query-controls-final-acceptance.log`). Current preview was refreshed from this export.
## Query headings, states and WebKit checkpoint — September 28
**VIS-010 (medium), corrected:** hidden unsorted-arrow space plus menu targets and cell padding clipped First Name/Last Name on touch layouts and Annual Salary on desktop. The header now reserves an arrow only for an active sort, reclaims trailing padding next to its menu, and permits two-line touch labels within the existing header height. Default widths remain150px; explicit SIZE widths and resize remain effective; sort/menu targets stay separate and touch menus remain44px. Browser measurements confirm the four tested Person labels fit without increasing the header height (37.5px desktop,60.5px touch). The new assertion failed on all3 initial profiles before the change; **45/45** affected checks pass on all5 profiles afterward, including sorted headings, sorting, resize, column menus, statistics and explicit metadata widths.
Added paired query statistics, loading and backend-error captures at desktop/tablet/phone. Loading deliberately holds the query response; it is a layout check, not evidence of backend latency. Desktop statistics retains the original arrangement with an added Export button. Phone statistics has a more crowded title beside the added action and remains a visual follow-up. Error-state composition is retained; the corrected dirty-view toolbar grouping remains contained. Column menus and relative-date controls did not exist in the original reference, so those baseline captures are marked non-equivalent. Inventory reaches56 light scenarios with the relative-date menu follow-up.
**VIS-011 (high), corrected:** full CI and local WebKit reproduced the relative-date menu extending above the viewport, leaving7daysago unreachable. The menu now caps its height by both70dvh and Radix's available placement height, with a viewport width bound. Acceptance asserts the menu's actual bounds before choosing a date. **25/25** date, phone saved-view/export, restriction and report-sharing cases pass across all5profiles after the correction.
Full remote WebKit job109212960890 finished **509passed/4failed** at93b7b5a. Besides the reproduced relative-date issue, failures were a saved-view touch trigger that stayed closed and two document-navigation diagnostic cases (SEC010 andRPT017). Traces place those API errors39–54ms after a full navigation began while initial reads were still starting; those two tests now let initial reads settle before deliberate document replacement, with API/security assertions unchanged. Saved-view opening now has an immediate expanded-state assertion, but its intermittent CI failure is **not declared resolved** by the focused pass. Other full-CI jobs remain in progress at last check. Artifacts/logs retained under ci-webkit-36507768282 and ci-36507768282-webkit.log.
Verification: query-heading-red.log then query-heading-green.log45passes; query-heading-measurements.log; query-heading-date-unit.log1765passes; relative-date-and-ci-green.log25passes. Production export/typecheck, changed-file lint, license, bundle anddiff checks pass. Current preview contains the heading/date fixes. Final full acceptance and the complete visual review remain open.
## September 29 — report controls and statistics checkpoint
The current preview has been refreshed from the production export including report Basic/Advanced modes, saved-view selection, refresh, density, column configuration and column actions. Existing editor tabs and Cancel → OK → host Save remain. Read-only preview interactions stay local. These additions use the existing Next controls. The original report filter editor was absent, so it has no equivalent baseline state. Light inventory is now 60 scenarios at each of three sizes; captures are not full-family approval.
Paired statistics screenshots revealed that adding Export squeezed the phone title into three lines. A wrapping header now gives the title its own phone row while keeping desktop actions alongside it. The rendered check changed from 84px (three lines) to 28px (one line) at 393px width. Fresh original/current captures exist at all three sizes. The inspected dark report states include desktop Advanced, tablet Basic, and phone Columns/Density/column menu: popups fit the viewport and the footer remains visible; phone preview pagination requires scrolling the modal body. Dark blue links remain a contrast investigation. No whole visual family is signed off.
Evidence: report-controls-corrected-acceptance.log has 90/90 cases across five profiles, including draft save/cancel, saved-view import, API-versioned refresh, paging/sort/resize/pins, copy full query, nested statistics, headings and previously intermittent menu/schedule cases. The earlier expanded run used a Person fixture without QUERY_STATS and failed those five statistics cases; corrected qryItem fixtures passed in the clean run. Source build/type checking passed. Latest remote full acceptance at6439944 remains failed; local focused success does not replace that release gate. Touch-menu tests now attach native input/state diagnostics on failure, and schedule tests assert CSV selection before submission.
## September 29 — dark-mode and form-title recovery
The report link contrast defect reproduced at 2.58:1 in a browser accessibility scan. Separating primary text from branded fills raised the measured sample link contrast to 8.83:1. Broader tests with OS preference opposite the selected UI mode exposed three more defects: unbranded dark primary buttons at 3.23:1, a record badge following OS dark mode on a light page, and dark table boolean values using a dark green. Tailwind dark variants now follow ThemeProvider's class; unbranded dark fills use the readable default blue, while true boolean values use a brighter green on dark surfaces. Light styling and explicit application theme rules remain. 53 checks across five profiles passed, covering both UI modes, default/brand themes, the six main accessibility routes, grids, record layouts and widgets.
Compared dark phone record/form entry states, all seven Field Lab phone captures, tablet process widgets, and desktop widget overview against the original Next build. Form geometry remains aligned; native date/time icons and link contrast are corrected. The Create heading still said Creating New: restored the original Create wording and retained CSS hooks, heading overrides and accessible Edit/Copy record labels. The parity row now accurately records this intentional Next presentation (733Done,16DifferentUX,5Partial,2Missing,38NA).
Evidence correction: widget long-page captures revealed duplicate near-top images because document smooth scrolling had not completed. The capture script now targets the actual main content scroller, uses instant scrolling and verifies actual versus requested position before each screenshot, recording positions in manifests. Scrolling the document could move the application out of view and produce blank evidence. Earlier numbered scroll captures without verified positions must not be counted as complete scroll review; affected long-page evidence is being regenerated. No whole visual family is signed off.
## September 29 — chart restoration verified
Long-page comparison confirmed two presentation changes: stacked charts moved their value axis to the right, and small line charts gained an axis and grid. The original left axis and compact small-line presentation are restored. Integer ticks, dataset tooltips, metadata colors, legend toggles, navigation and accessible data remain. WID-069 and WID-010 pass 25/25 across Chromium, Firefox, WebKit, phone and tablet; visual-recovery unit tests pass 1768/1768, production export/typecheck succeeds and lint reports zero errors/five existing warnings. The ledger now records these intentional Next differences (731 Done, 18 Different UX, 5 Partial, 2 Missing, 38 N/A).
The current preview was refreshed from that export. Dark form-field and sample-widget captures were regenerated against the original at three sizes with verified main scroll positions. Phone lower-page comparisons confirm the compact line presentation, HTML/Stepper layout and chart card structure; current legend controls and the As of date add content/height. Remaining label-rotation differences need review. No whole family is signed off.
Remote c442613 WebKit completed with 513 passes and three failures: Annual Salary header clipping, Selection touch opening and Views touch opening. Its native input diagnostics are being retrieved. General CI and image builds pass; the other full acceptance jobs are still running. Focused local passes do not close these remote failures.
## September 29 — CI menu and header diagnosis
The completed c442613 runs report Firefox 515 passes/one heading failure and WebKit 513 passes/three failures (heading plus Selection/Views). The Chromium/phone/tablet job remains in progress. Native WebKit input attachments prove the two menus open on pointer-down and close after the same tap's compatibility click. A local probe reproduces dismissal when focus returns to the trigger; the regression unit test failed before the correction. Selection and Views now exclude their own trigger from outside-interaction dismissal, while outside clicks, Escape, selection and focus return remain supported. The regression unit passes and 30 real-backend menu/basic/saved-view checks pass across five profiles, including explicit trigger-focus retention. The full remote suite must still verify this correction.
The heading failure is font-dependent: the application renders with its system/configured font, and the original two-pixel spacing adjustment did not fit the wider Linux font. A local Verdana probe reproduced 13px of clipping at the preserved150px width; allowing two lines removed clipping. Header labels now use the existing two-line limit on desktop as well as touch. The wider-font browser regression failed before the change;20/20 heading/sorting/shared-view checks now pass across five profiles. Affected grid/menu unit tests17/17, production export/typecheck, changed-file lint and diff checks pass; the full unit suite immediately before the CSS-only header correction passed1769/1769. Bundle budget passes at1031.5KB gzip of1050KB. Logs: menu-trigger-red/green/unit/acceptance, header-font-probe/red/acceptance/build/unit/lint, visual-recovery-final-bundle.
Current preview includes theme, Create-title, chart, menu and header corrections. Dark tablet Field Lab entry and phone record-person lower viewport were compared with the original and retain field/section geometry; date icons and primary-link contrast are corrected. Desktop/tablet sample-widget lower viewports retain card layout and the compact small chart; legend controls and conditional category rotation still account for visible differences. Complete chart-label review and all other open family/state reviews remain required.
## September 29 — chart labels and single-list filter editing
**VIS-012, corrected:** the chart rotation heuristic treated every character as 6.5px wide and divided plot width by label count, turning month/fruit labels that fit horizontally in the original Next charts. SVG measurements show neighboring labels fit at desktop, tablet and phone widths. The category axis now measures actual glyph widths and tick positions, remeasuring on chart updates and font loading; long labels retain rotation and bounded axis space. The new WID-009 browser assertion failed before the correction; 40/40 chart data/legend/tooltip/rotation cases pass across all five profiles. Live measurements in chart-label-red.log/chart-label-green.log demonstrate horizontal labels with positive gaps afterward.
**VIS-013, corrected:** Advanced conditions appeared twice (preview chips plus editable rows), with two Clear actions opening the same dialog. The earlier Next single editable list is restored. Each row still deletes its own condition; one footer Clear action still confirms and retains sorting. Basic mode retains its quick filters and header Clear action. The now-unused duplicate component/test were removed; the replacement unit checks the single-list layout, and QRY-083 now compares SQL results for two conditions before deleting one and proving the second remains active. This is recorded as Different UX in the parity ledger (730 Done, 19 Different UX, 5 Partial, 2 Missing, 38 N/A). Required acceptance coverage remains enabled.
The new layout unit failed before the correction. Full unit verification passes 1768/1768 in 173 files (one duplicate-component test removed); source lint has zero errors/five existing warnings and license headers pass. The first build caught a Testing Library assertion using Playwright's exact option; that test-only typing error was corrected and the final production export/typecheck passes. Filter acceptance passes 55/55 across five profiles, covering mode switching, quick filters, saved views, per-condition deletion, confirmed clear, sort retention, field validation, dates, boolean/blob and long-text operators.
Fresh original/current captures cover populated/nested filters and sample widgets in both themes at all three sizes. Inspected light desktop/phone populated filters, tablet nested filters, dark phone populated filters, and restored desktop/phone chart labels. Single-list geometry is recovered; Basic/Advanced and sort controls remain necessary additions. Current quick-filter editing is captured; its baseline explicitly states that the control did not exist. All captured long-page positions are verified. No complete family is signed off yet.
Full c442613 acceptance 36549979322 is now terminal/failing: Chromium 515/1, phone 490/1, tablet 486/5, Firefox 515/1, WebKit 513/3. Besides heading/menu failures corrected in 5077c78, phone/tablet RPT-009 reproduced an undersized standalone report link (146x20); it now has a 44px target. The two tablet navigation traces show deliberate document replacements overlapping sidebar prefetch requests; those tests now wait for initial reads to finish. No diagnostics or assertions were waived, and their intermittent CI outcome still requires the next full run. Exact artifacts and logs remain in the root visual-review evidence directory.
Final follow-up verification: nested-and-ci-acceptance.log reproduced the report touch failure on phone and tablet (26 passes/two failures). After correction, nested-and-ci-green.log passes 28/28 across five profiles, including nested AND/OR filtering, lookups, owned/shared records and report touch targets. Affected report/filter units pass 21/21. Final production export/typecheck, changed-file lint, diff check and bundle budget pass (1031.3 KB gzip / 1050 KB). The completed old CI run remains failed; these local passes do not substitute for the next full run.
## September 29 — expanded process review and saved-profile restoration
Added paired light/dark captures at desktop, tablet and phone for expanded help, required-field errors, component review/download, wizard details/validation/confirmation, cancel dialog, running progress, user-facing error, scanner, state-machine entry, widget result, bulk file mapping, value mapping, review and completed validation. All 192 new combinations have clean setup/runtime capture status and no document-wide horizontal overflow. Scroll captures record and verify main-content positions. The first required-field selectors were too strict about labels or matched a containing section; the final captures use the actual field roles. Completed bulk validation is identified by its result text, since the original did not expose the newer status hook.
Inspected pairs include desktop component review and both bulk-mapping viewports; phone required-field error and footer, wizard confirmation, cancellation, progress, scanner, error, loop and widget result; dark desktop bulk mapping, dark tablet value mapping and wizard validation, and dark phone bulk-mapping footer and cancellation. These process compositions retain the original Next arrangement. Added controls and readable dark primary text remain. More process variants, saved-profile menu/dialog states, internal-error detail, bulk selection/results and composite modal states still need visual review; this is not full process-family approval.
**VIS-014 (medium), corrected:** the original bordered Saved Bulk Load Profiles section, heading and visible current-profile status were removed during the profile-action expansion. Restored that section in the shared component. Restored the original `showCurrent` option so file/value mapping identifies the applied profile while the final review summary avoids duplicate status. Current menu, owner permissions, reset/rename/save/delete behavior, help, typed value mapping and searchable fields remain. The extra actions can increase phone height; verified scrolling reaches the footer. Unit assertions first failed for the missing named region and for missing value-step status, then passed after correction.
**VIS-008 clarification:** browser measurements show Reload occupies the same header row as the title. It increases a compact widget header to 44px on touch devices (24px on desktop); this is the touch-target requirement, not an accidental separate row. The hosted ad hoc body is 2.5px shorter than the original in the fixture. Preserve the usable controls; further composite-specific states remain open. Evidence: `process-layout-probe.mjs` and `process-layout-before.log`.
Verification: `bulk-profile-panel-acceptance.log` passes45/45 across five profiles; after the final value-step correction, `bulk-profile-final-acceptance.log` passes25/25 including upload/value mapping/review and profile actions. Final full units1768/1768 in173files (`bulk-profile-final-unit.log`), affected units10/10, export/typecheck, changed-file lint, license headers, diff check and production bundle budget pass (1031.4KB gzip/1050KB). Capture logs: `process-states-light`, `process-states-followup`, `process-states-dark-baseline`, `process-states-dark-current`, `process-bulk-restored-light`, and `process-values-final-{light,dark}`. Canonical manifests supersede failed interim captures and orphaned images.
## September 29 — profile menus, shell overlays and Preferences
**VIS-015, corrected:** the expanded profile menu lacked arrow-key navigation and overflowed the tablet viewport with eight profiles (bottom1105px in a1080px viewport). It now uses the existing Radix dropdown primitive with collision placement and available-height scrolling. Existing menu contents, ownership restrictions, confirmations, status and backend behavior remain. Dialog focus returns to the actual opener, including WebKit clicks that activate without focusing; menu actions return to the persistent menu trigger. Native probes verify desktop/tablet/phone bounds and focus. Export now guards focus returning to its own trigger, fixing the native WebKit tap/focus dismissal reproduced by CI. Outside dismissal and download assertions remain.
The initial profile/browser follow-up produced27passes/3focus failures; actual-opener handling corrected those. A later34pass/1fail run exposed a separate Home/Enter test race. Trace shows Home at77867.082ms and Enter at77869.872ms; the resulting page had selected Review Profile8. Installed Radix roving-focus source defers focus with setTimeout. Waiting for the expected first menu item preserves and strengthens the keyboard check. `menu-home-focus-acceptance.log` passes35/35 across all profiles.
Added seven shell states in both themes at three sizes:84 clean captures. Inspected desktop Preferences/search results/command, phone navigation/user menu/Preferences/help/search results/command, tablet help, dark tablet Preferences and dark phone user menu/search results/command. Original navigation, help, user-menu and search geometry is retained; the command palette includes new table actions within its original shell. Preferences' default selection changes from25 to50 records through current metadata/default behavior. These states do not approve notifications, login/session, all branding variants, internal dialog scroll positions or the whole shell family.
**VIS-016, corrected:** Preferences used z-50 behind the phone sidebar's z-100; its controls were visually covered. It now uses `--qqq-z-overlay` and `--qqq-z-modal`. Closing returns focus to the user-menu button because the Preferences menu item unmounts on selection. The new phone hit-test assertion failed before correction; a unit focus-return assertion also failed. Both pass after correction, and fresh light/dark phone screenshots show the full dialog above the drawer without changing its internal layout.
Final verification: `preferences-menu-final-acceptance.log` passes50/50 across Chromium, Firefox, WebKit, mobile and tablet, including drawer navigation/focus, profile actions and Export. `preferences-final-unit.log` passes1771/1771 in173files. Final production export/typecheck, changed-file lint, license headers, diff check and bundle1031.7KB/1050KB pass. A test-only Playwright `exact` option was removed from Testing Library before the final build. Capture logs are `shell-states-{light,dark}` and `preferences-restored-{light,dark}`. No whole family is approved and the full remote gate remains incomplete.
## September 29 — inline profile naming recovery
**VIS-017, corrected:** Save New, Save As and Rename had replaced the earlier inline Profile Name field with a centered modal. Restored the editor inside the named, bordered profile section using the original compact input and bordered Save Profile/Cancel buttons. No nested form is introduced: Enter saves the profile and stops propagation so the enclosing bulk-load process is not submitted. Escape/Cancel close the editor; saving restores focus to the actual opener or the persistent menu trigger if the opener disappears when the current profile changes. Ownership checks, backend name limits/errors, copy semantics, update confirmation and explicit delete behavior remain.
The inline-region and keyboard regression assertions first failed2/11, then passed11/11. Full units pass1771/1771. The final success-focus assertion also passes in the affected11tests. `profile-inline-acceptance.log` passes40/40 across all5profiles (PRC031/046/058/059/060), covering required/file/value mapping, profile CRUD, duplicate-name server validation, shared ownership, menu bounds and focus. PRC046 retained stale local-panel locators after the menu moved to a portal; those now find the portaled menu while all SQL and behavioral assertions remain. Production export, final typecheck/lint, license and bundle1031.9KB/1050KB pass. The first budget-report write was denied by the sandbox; the authorized rerun completed successfully.
Fresh `profile-inline-{light,dark}` captures replace the modal snapshots at desktop/tablet/phone. All12entries have clean setup/runtime status and no body horizontal overflow. Inspected light desktop, light/dark phone and dark tablet pairs: editor input/buttons match the original arrangement within the restored section. Expanded profile actions/reset controls still add height and remain part of the broader review. No full family is approved. The ledger explicitly records inline naming as Different UX; its existing-row recount corrects795total/51supplemental rows and the stale OS-driven dark-mode description.
## September 29 — Copy alignment, selected sections and touch selection
The bounded record/form review inspected 210 existing images against original Next commit `e42ad2b2`. It found two concrete regressions: **RF-001**, Copy scope controls aligned to a wider container than the editable form; **RF-002**, a selected record section inherited Overview's half-width card and full-width field defaults. The Copy container now shares `max-w-4xl` with EntityForm. Selected tabs use a full-width fallback card and two field columns from `sm`; explicit metadata spans retain precedence. Overview and phone accordion defaults are preserved.
Both browser layout regressions failed on the old export, then passed 10/10 across Chromium, Firefox, WebKit, phone and tablet. Existing record view and create/edit/copy workflows passed 17/17 in Chromium. Full units 1,840/1,840, production export, types, changed-source lint, license headers and bundle budget 1,038 KB/1,050 KB pass. Independent code review found no additional issues.
**Touch selection:** the 44px selection label exceeded the 44px pinned selection cell because density padding was still applied. Removing horizontal selection-cell padding only for coarse pointers exposes both target edges. Browser geometry reproduced the overlap before the correction; 35 focused checks pass across all 5 profiles, covering both edges, every density and existing selection workflows. The first post-fix test attempt used coordinates from before the selection banner moved the grid; the corrected test remeasures after selection and uses native touch events for touch menus. Failed traces remain preserved.
Refreshed current captures for Copy, selected section, record lab, audit and populated query in both themes at all 3 sizes: 30 states/40 images, no capture/runtime errors or body overflow. This also replaces the stale light record lab/audit evidence. Root inspection confirms desktop light Copy/selectedsection and tablet touch query. All 34 refreshed record/form images have now been directly reviewed, closing RF-001, RF-002 and the stale-capture gap. The exact-path ledger is `record-form-review-d937507.md` in the ignored evidence directory. The fixed 210-image record/form inventory is closed; other visual families are still open. Logs/evidence: `record-default-layout-{red,green,build,units}.log`, `record-layout-workflows.log`, `query-selection-targets-final.log`, `layout-restored-{light,dark}.log`, and release-worktree `test-results/acceptance/{selection-target-evidence,record-default-layout-evidence}`.
**Publication hold:** the owner is testing immutable RC1 in real applications for a couple of weeks. These corrections are post-RC1 branch changes. Final publication requires renewed explicit owner approval; see [testing period](../releases/1.0-testing.md).
## September 29 — query and shell fixed inventories reviewed
The query inventory is fully inspected: **348 images**, covering all 28 existing states in light/dark at desktop/tablet/phone sizes, including scroll positions. The reviewer directly inspected 314 images and retained 34 explicitly attributed root inspections. No new unresolved visual regression was found. Five original controls are unavailable for equivalent comparison; the ledger distinguishes those context images. Older selection-padding appearances are historical; refreshed populated-query images and the separate 35-case interaction gate cover the correction.
The seven-state shell inventory is fully inspected: **92 images**, comprising 84 initial-position images and eight Preferences scroll captures. Root directly inspected 64 images and retained 28 prior inspections. No new unresolved shell visual regression was found. All six current Preferences scroll captures expose the lower table/record layout choices, with the footer separate. Original search/help/navigation geometry is retained; added theme choices and command actions remain inside the existing layouts. Shell source has not changed between d937507 and e87fe90. Screenshots do not establish persistence for every preference.
Exact-path ledgers are `query-review-e87fe90.md` and `shell-review-e87fe90.md` in the ignored `test-results/visual-review` evidence directory. This closes those fixed inventories, alongside the previously closed record/form inventory. Process, report and widget review remains open; notifications, login/session and branding variants are not approved by this shell review. RC2 is in preparation and final 1.0 remains held for owner testing.
## September 29 — report fixed inventory inspected
All **262 report images** in the six per-profile canonical manifests were directly inspected: 131 in each theme (desktop 40, tablet 41, phone 50). This corrects the older combined light manifest, which omitted later states. The exact-path ledger `report-review-defb3ab.md` has no missing or extra images and records no confirmed new visual regression. An initial concern about blank output-format text was withdrawn after direct focused reinspection.
This is inspection of the existing images, not a claim that all captures represent defb3ab: only 10 of 216 state entries have timestamps/export fingerprints. Fifty-four original editor-state entries lack an equivalent control, and the initial phone Advanced/Columns captures do not show the lower pager. Those comparison and provenance limits remain explicit. Embedded query equivalence and whole-product visual review remain open; final 1.0 is held.
## September 29 — remaining fixed inventories inspected; child export corrected
All **1,621 unique images** in the six canonical per-profile manifests are now accounted for by completed inspection ledgers: record/forms 210, query 348, shell 92, process 402, report 262, widgets/dashboard 269 and developer/miscellaneous 38. Process review found no additional confirmed regression; explicit full-width upload metadata explains the wide upload control. Developer/miscellaneous content layout retains the original composition, with historical header truncation already addressed by VIS-007. Widget/dashboard review found the child export issue below and no other confirmed unresolved regression. Root independently reconciled the final 269 and 38 paths against the manifests and verified every file exists. Exact ledgers are `process-review-e87fe90.md`, `widgets-dashboard-review-2221999.md` and `developer-misc-review-2221999.md` in ignored evidence.
**Child-record export, corrected:** current child lists had a generic header icon unable to read the child payload plus a working Export control in a new toolbar row. Two component regressions and the real-backend fixture reproduced the duplicate. The first correction removed the generic icon but retained the extra row; fresh screenshots showed that still differed from original Next. The final correction reuses the child export control in the existing header position. Bare hosts retain inline export. Permission-filtered join columns, shown-row CSV, empty disabled state and the partial-result tooltip remain. Widget export controls follow the same loading/error/permission/required-selection gates as the body. The existing error boundary now covers header and body; Retry requests fresh data and a replacement payload clears the error. Review regressions reproduced and verified denied exports, malformed metadata containment and successful recovery.
Verification: **1,857 unit tests**, 41 focused widget tests, 10 real-backend child-list scenarios across five profiles, production export/types/lint/license/diff checks and 1038.4KB/1050KB bundle budget pass. Final browser checks verify one header export aligned with Reload, CSV contents against independent SQL, exclusion of the undisplayed third row, record navigation and empty lists. Desktop and phone captures were directly inspected: the extra toolbar row is gone. The first geometry check compared title and button centers and failed on touch because native targets are 44px and the header uses items-start; the final assertion requires Export inside the header and aligned with Reload. Failed evidence is retained; no CSS or target size was changed to pass it. Independent review has no remaining blocker. Logs use `child-export-*`; final evidence is `child-export-final-evidence` in the ignored root directory.
Inventory completion is **not** whole-product visual approval. Fifty-two gallery/table images repeat near-top content and cannot establish lower-page coverage. Six original choice-open states are unsupported. Most historical images lack build fingerprints; dark widget coverage only includes sample/typed-input/choice states, and developer/miscellaneous has no dark captures. Previously recorded uncaptured shell variants, report pager and provenance limitations remain. The original Next reference remains `e42ad2b2`; Material supplies behavior only. The owner preview remains unchanged while the owner validates it. Final 1.0 is explicitly held.
## September 30: unpublished legacy icon compatibility follow-up
Existing Next Lucide mappings, widget overrides and image priority are preserved. Additional legacy names use the pinned local Material Icons inventory. Six formerly unmapped block icons, navigation and the raised main tile were checked in light/dark desktop/mobile views. Four workflows passed against the actual development JAR; its font and exported files were verified byte-for-byte. Saved desktop/mobile tile regions contain identical light/dark glyph pixels; the earlier suspected blank tile was an inspection error, and no repaint workaround was introduced.
The full local Chromium suite passed 589 tests and all 391 required rows, with zero failures, skips, flaky outcomes or report errors and three approved external-service exclusions. Independent code review approved the change. Evidence is retained under `test-results/visual-review/icon-compatibility-investigation/` in the original checkout. This checkpoint covers the icon change; whole-product visual approval remains open. RC5 is immutable and does not include this follow-up.
## September 30: fresh RC6 dark desktop developer and miscellaneous review
Twelve original/current captures across app home, table developer, record developer and not-found pages were directly reviewed, including lower scroll positions. The fresh manifest records capture times and export fingerprints. No new layout regression was found in those states. Evidence: `test-results/visual-review/rc6-dark-developer-review/review.md` in the original checkout.
An inherited RapiDoc dark-link contrast defect is confirmed at 2.12:1 in both original and RC6; subsequent browser checks also find light-mode contact/authentication text below 4.5:1. A separate correction is being tested. This closes the missing desktop dark capture evidence for the four entry states; phone/tablet dark coverage, expanded endpoint interactions and whole-product approval remain open.
## September 30 — phone developer view comparison
Eight fresh original/RC6 phone screenshots in light/dark were directly inspected (initial and lower scroll positions). Four capture entries had no runtime/state errors or body horizontal overflow. Evidence: `test-results/visual-review/rc6-phone-developer-review/` in the original checkout, including export fingerprints and provenance.
The main composition matches the original. RapiDoc's inner clipping is inherited: its 360px minimum exceeds the 343px phone panel, and hidden overflow clips controls. This remains open for a separate responsive correction. The added command button also reduces breadcrumb space; the trail scrolls to the current page, but header crowding remains a visual-review consideration. This is scoped evidence, not full visual approval.
## September 30 — API playground contrast follow-up
Original Next and RC6 both have an inherited dark playground link contrast defect (2.12:1). Focused browser checks also reproduced light contact/status contrast defects. An isolated follow-up applies existing theme tokens without changing layout. Four final desktop/mobile light/dark cases pass, including hovered controls and Preferences changes, with no failures, skips, flaky outcomes or report errors. All four final screenshots were directly inspected after transitions settled. Evidence is retained under `test-results/visual-review/api-playground-contrast/` in the original checkout.
This is scoped color verification, not whole-product visual approval. Narrow-screen inner clipping, expanded endpoint states and additional application palettes remain open. RC6 is unchanged.
## September 30 — API specification controls in narrow panels
Reproduced clipped specification buttons against the packaged RC7 candidate before any focus/click could conceal overflow by scrolling hidden ancestors. The follow-up lets RapiDoc fit its panel and uses a named overview container plus public CSS parts to wrap only the specification actions at narrow widths. It adds no dependency and does not mutate the shadow DOM.
Twelve real-backend desktop/mobile workflows pass: default device and 320px widths, light/dark themes, complete visibility, actual specification download, keyboard new-tab action, contrast and live Preferences changes. Final phone screenshots were directly inspected. At 1440px, the full playground screenshot is byte-identical to RC7 (SHA256 `ec6d108d5d632d1ee0b3a3ecd6c61ae9279bee705facd2a3b9e422d2e0f1ae37`), and overview/button geometry matches exactly.
All 1,904 units, types, lint/license, default production export and unchanged bundle budgets pass. Evidence, including earlier test/setup failures, is retained under original-checkout `test-results/visual-review/api-playground-width/`. This is a separate follow-up, not part of frozen RC7. Expanded endpoint states, additional application palettes and whole-product visual approval remain open.
## September 30 — desktop and tablet process comparison
Compared original Next `e42ad2b2` with the actual RC8 candidate JAR in 32 light/dark desktop/tablet screenshots: bulk file mapping at two scroll positions, running process progress, and expanded internal-error details. Both builds used fresh isolated instances of the same fixture/backend. Capture manifests include export fingerprints and report zero runtime/state errors or body/main horizontal overflow.
Progress and error panel composition are retained. Bulk mapping visibly changes the saved-profile actions and Add Fields control and introduces explanatory text. Main scroll height increases from 1330 to 1390px on desktop and 1556 to 1748px on tablet. This added density/height remains an owner-review item. Tablet breadcrumb ancestors are more crowded with the added command control; the current page remains visible. Dark mode retains the existing composition with revised readable accent colors. No screen redesign was applied during this review.
Evidence: original-checkout `test-results/visual-review/rc8-desktop-tablet-process-review/`, including four inspected contact sheets and all 32 source images. This is scoped process evidence, not whole-product approval; the existing uncaptured states and compatibility gaps remain open.
## RC8 lower widget review: desktop, tablet and phone
Original Next `e42ad2b2` and corrected RC8 `cd3d473` were compared on Widget Gallery and Widget Tables And Charts using the actual candidate JAR, matching fixtures and backend `e0d57dd31`. Evidence: `rc8-widget-lower-review/` in the visual-review archive. Desktop supplied 28 images; tablet/phone supplied 86 more, for 114 images across 24 entries in both themes. Twelve contact sheets were directly inspected. Every lower capture records and verifies its actual scroll position. All entries had zero runtime/state errors and no body/main horizontal overflow. This closes the earlier repeated-near-top capture gap for these two entry states across all three viewport sizes.
The card structure is retained. Recorded changes include map geography, formatted table cells, per-table export/footers, paging controls, metadata chart colors, readable long category labels, legends, connected line data and As Of timestamps. These additions increase page height, including an extra phone viewport on Tables And Charts. Long phone header text now truncates alongside the added command control. Dark map/plot contrast remains a review concern; this comparison did not measure contrast ratios or verify horizontally scrolled table columns. Expanded interaction states, other widget surfaces and owner visual approval remain open. No product code changed during this review.
+88
View File
@@ -0,0 +1,88 @@
# Performance budgets (QRun-IO/qqq#710)
Two automated checks. A breach fails CI. To fix one, make the change smaller or faster. If a
budget really has to rise, put the new measurement and the reason in the commit.
| Check | What it measures | Budget file | Runs in |
|---|---|---|---|
| Bundle budget | First-load JS per route of the static export, total JS, largest chunk, CSS | `perf-budget.json` | `ci.yml` job "Bundle budget (static export)": `pnpm build:export && pnpm perf:budget` |
| Runtime budgets | Large table, heavy dashboard, long process in the production export against the real sample backend | `tests/acceptance/specs/performance/budgets.ts` | `acceptance.yml`: matrix rows PRF-001 to PRF-005 in every browser |
```bash
pnpm build:export && pnpm perf:budget # bundle; report in test-results/performance/bundle.json
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit node scripts/acceptance.mjs --skip-build specs/performance
```
## Bundle
First-load JS for a route is every script and stylesheet that the route's exported HTML loads
before hydration, gzip level 9. Next 16 no longer prints these sizes, so the script measures
them from `out/`. `_` stands for a dynamic segment: `/app/_` is `/app/[slug]` (app home, record
query, process, report) and `/app/_/_` is the record view.
| Route | Before (KB gzip) | 1.0 (KB gzip) | Budget |
|---|---|---|---|
| `/`, `/login`, `/callback`, `/token`, `/404` | 218–219 | 218–219 | 230 |
| `/app`, `/app/developer`, `/app/search`, `/app/_/key`, `/app/_/dev`, `/app/_/_/dev` | 309–311 | 309–311 | 330 |
| `/app/_` (app home, record query, process, report) | **562.9** | **448.2** | 470 |
| `/app/_/_` (record view) | 431.6 | 431.5 | 455 |
| `/app/_/create`, `/app/_/_/edit`, `/app/_/_/copy` | 351–356 | 351–356 | 370–375 |
| `/app/_/savedView/_` | 371.2 | 371.1 | 390 |
| Total JS (all chunks) | 689.2 in 54 chunks | 675.0 in 51 chunks | 710 |
| Largest chunk (Recharts + d3, loaded only by chart widgets) | 93.8 | 93.8 | 100 |
| CSS | 16 | 16 | 18 |
Regression fixed: `/app/[slug]` imported `AppHome` from the widgets barrel, and that barrel
re-exports the chart widgets. Recharts was therefore in the first load of every table and
process page, even though `WidgetRenderer` loads charts lazily. The page now imports its
components by module path.
## Runtime
Fixture: `tests/acceptance/fixture/PerformanceFixtures.java`, all synthetic data.
- `prfWide`: 10,000 rows, 40 columns of mixed types, read-only.
- `prfDashboard`: 24 widgets: 6 statistics, 6 bar, 4 line (60 points) and 4 pie charts, and
4 tables of 100 rows.
- `prfLongRun`: a job that walks all 10,000 rows in 40 pages with a 250 ms pause per page.
Each test measures wall time from the user action until the exact expected content is on
screen, and records it as a `timing` annotation in `test-results/acceptance/report.json`.
| Row | Measurement | Budget (ms) | chromium | firefox | webkit |
|---|---|---|---|---|---|
| PRF-001 | Open the 10,000-row table until the first 25 rows × 40 columns show | 5,000 | 535 | 353 | 602 |
| PRF-002 | Page size 25 → 250 (10,000 cells) | 10,000 | 462 | 665 | 738 |
| PRF-002 | Next page at 250 rows | 10,000 | 636 | 969 | 1,188 |
| PRF-002 | Last page (page 40) at 250 rows | 10,000 | 612 | 1,158 | 1,064 |
| PRF-003 | Sort by Name over 10,000 rows | 3,000 | 155 | 234 | 299 |
| PRF-003 | Filter Count 04 = 0 (40 matches) | 3,000 | 173 | 157 | 150 |
| PRF-004 | Open the 24-widget dashboard until every widget has loaded | 14,000 | 1,086 | 1,567 | 1,760 |
| PRF-005 | Job finished until its result screen shows (10.15 s job, 5 status polls) | 5,000 | 415 | 838 | 521 |
These numbers come from an otherwise idle run of the performance area on an Apple M3 Max
(macOS, Darwin 27), Playwright 1.58.2. Each budget is about 8× the slowest browser's time.
In a full four-project run with other acceptance suites on the same machine, the same steps
took up to about 4× longer: Firefox next page 4,191 ms, dashboard 8,841 ms. Retries are 0,
so a tighter budget would flake. The budgets catch order-of-magnitude regressions. The
structural checks below catch smaller ones, and don't depend on machine load.
The rows also check behavior besides time:
- Only one page of rows is in the DOM: 25 or 250 rows × 40 cells, not the whole table.
- The backend is asked for `skip`/`limit` 250 on each page.
- Each dashboard widget's data is requested exactly once.
- Status polls never overlap, and each poll waits at least 1.5 s (`POLL_INITIAL_MILLIS`)
after the previous answer. The job never gets more polls than its length allows.
Grid rendering fixed for large pages: body rows are memoized. The grid used to re-render all
10,000 cells before the page request was sent, and again for the URL sync. Date cells no
longer build an `Intl.DateTimeFormat` per cell. A 250-row page turn in chromium, from click to
rendered rows: 659–669 ms → 518–561 ms.
Observed, not changed here:
- Grid columns follow the field order of the metadata map, not the section order, and not
Material's alphabetical-by-label order. For a 40-column table this looks unordered.
- The pagination range is not locale-formatted, while the total is ("Showing 9751–10000 of
10,000").
+239
View File
@@ -0,0 +1,239 @@
# Next UI agent guide
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the latest published prerelease is [1.0.0-RC.9](../releases/1.0.0-RC.9.md) at `c029ad2`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
## Operating rule: metadata is the product boundary
QQQ's Java backend owns tables, fields, processes, apps, navigation, widgets, permissions, and human labels. The browser adapts to the metadata of the current user. Never make a QQQ application work by hardcoding its table names, field names, routes, or widget data in a React component. Add general rendering behavior and a real backend fixture, or change the backend metadata producer where the contract originates. Do not invent JSON keys because a frontend type accepts `Record<string, unknown>`; inspect the actual producer, versioned API response, and acceptance fixture.
The UI supports a **versioned** API at `/qqq/v1`. The acceptance diagnostics reject browser calls to legacy unversioned paths. All browser requests should use typed functions in [`src/lib/api`](../../src/lib/api); TanStack Query keys come from [`src/lib/query-client.ts`](../../src/lib/query-client.ts). Treat permission flags as display controls only; the backend must refuse the same forbidden operation.
## Find the right source before editing
| Need | Start here | Why |
|---|---|
| Instance, app, table, field, process, report, widget shapes | [`src/types/metadata.ts`](../../src/types/metadata.ts), [`src/types/enums.ts`](../../src/types/enums.ts) | Frontend contract and enumerated types. |
| Backend HTTP contract | [`src/lib/api`](../../src/lib/api), [`src/lib/api/client.ts`](../../src/lib/api/client.ts) | Versioned routes, typed request/response handling, 401 behavior. |
| Route and sidebar dispatch | [`src/app/(dashboard)/app/[slug]/page.tsx`](../../src/app/%28dashboard%29/app/%5Bslug%5D/page.tsx), [`use-routes.ts`](../../src/lib/hooks/use-routes.ts), [`use-route-params.ts`](../../src/lib/hooks/use-route-params.ts), [`legacy-paths.ts`](../../src/lib/utils/legacy-paths.ts) | Slug precedence, hidden entries, nested nav, static-export and Material deep links. |
| Query and saved views | [`src/components/query`](../../src/components/query), [`use-record-query.ts`](../../src/lib/hooks/use-record-query.ts), [`use-saved-views.ts`](../../src/lib/hooks/use-saved-views.ts) | URL state, filters, variant, selection, backend saved views. |
| Record display and forms | [`src/components/records`](../../src/components/records), [`src/components/forms`](../../src/components/forms), [`FormWidgetSection.tsx`](../../src/components/forms/FormWidgetSection.tsx), [`zod-from-metadata.ts`](../../src/lib/utils/zod-from-metadata.ts) | Sections, fields, form widget context, validation, adornments. |
| Processes and reports | [`src/components/process`](../../src/components/process), [`ReportRun.tsx`](../../src/components/reports/ReportRun.tsx), [`src/lib/api/processes.ts`](../../src/lib/api/processes.ts) | Step components, state transitions, polling, files. |
| Widget entry and dispatch | [`ConnectedWidget.tsx`](../../src/components/widgets/ConnectedWidget.tsx), [`WidgetBlock.tsx`](../../src/components/widgets/WidgetBlock.tsx), [`WidgetRenderer.tsx`](../../src/components/widgets/WidgetRenderer.tsx) | Fetch/params, chrome, data renderer. |
| Widget payload details | [`src/components/widgets`](../../src/components/widgets), [`widget-types.ts`](../../src/components/widgets/widget-types.ts), [`src/mocks/fixtures/widgets`](../../src/mocks/fixtures/widgets) | Concrete canonical and demo shapes; demo fixtures are not production guarantees. |
| Branding, analytics and CSS | [`material-theme.ts`](../../src/lib/theme/material-theme.ts), [`src/lib/theme`](../../src/lib/theme), [`src/lib/analytics`](../../src/lib/analytics), [`src/styles`](../../src/styles) | Supplemental Material theme, implemented tokens, analytics privacy and `data-qqq-id` customization hooks. |
| Owned backend acceptance | [`tests/acceptance`](../../tests/acceptance), [`tests/acceptance/README.md`](../../tests/acceptance/README.md) | QQQ sample fixtures, browser projects, SQL assertions, diagnostics gate. |
| Current gaps and risk | [Material parity ledger](../acceptance/material-parity.md), [security review](../security/next-ui-review.md), [browser matrix](../acceptance/browser-matrix.md) | What has actually been verified and what remains open. |
The repo's [implementation plans](../implementation-plans) explain intended architecture, but some examples predate the current code. Prefer source and executable acceptance evidence. In particular, current `package.json` uses Next.js 16 even though older `CLAUDE.md` and plans mention 15.
## Preserve the Next visual baseline
Use the September 25 Next design (`e42ad2b2`) as the visual reference. Use Material source to establish functional behavior and backend contracts. Changes to parity behavior must preserve the Next layout unless the user requests a visual change.
- Desktop query mode controls, sort and chips belong inside the Filter panel. On phones, Filter opens a sheet containing those same controls and preserves the mode; Advanced shows the nested builder inside it. Check [`RecordQuery.tsx`](../../src/components/query/RecordQuery.tsx).
- Query column widths default to 150 px. Preserve `SIZE` adornment widths and saved/user-resized widths.
- The record section sidebar requires literal `showRecordSidebar: true` in `supplementalMetaData.materialDashboard` (legacy fallback: `supplementalTableMetaData.materialDashboard`). An absent setting keeps it off.
- T1 identity fields omit the primary key and values already contained in `recordLabel`, checking display values before raw values. Preserve the compact header and card field spacing in [`RecordViewHeader.tsx`](../../src/components/records/RecordViewHeader.tsx) and [`RecordView.tsx`](../../src/components/records/RecordView.tsx).
- Desktop Copy/Delete remain direct actions unless the Actions menu offers that enabled action, including in a nested menu. Preserve accessibility when a custom menu omits or disables the action; see [`RecordActions.tsx`](../../src/components/records/RecordActions.tsx).
- Preserve manual activation in `RecordViewTabs`: Left/Right/Home/End move focus, Enter/Space activates through the existing URL callback. A focused but inactive tab must not mount widgets. Keep one selected tab stop, unique tab/panel links and a focusable named panel. Phone accordion assertions should follow the trigger’s `aria-controls`; the nested section can legitimately have the same accessible name.
- Create, edit and copy share the centered `max-w-4xl` container in [`EntityForm.tsx`](../../src/components/forms/EntityForm.tsx).
- A fresh profile starts in light mode. A saved dark preference applies only without an application theme; an application theme keeps light mode active. Check [`theme-provider.tsx`](../../src/lib/theme/theme-provider.tsx).
- Preserve inline naming in `SavedBulkLoadProfiles`: New, Save As and Rename belong inside the original profile section. Prevent Enter from submitting the enclosing process form; retain ownership rules, backend validation and overwrite/delete confirmations.
- Dialogs launched from the phone sidebar must use the existing `--qqq-z-overlay` and `--qqq-z-modal` layers. A hardcoded `z-50` puts them behind the drawer. Return focus to a persistent control when the opening menu item unmounts.
- For Radix menus, check keyboard navigation, collision placement, available-height scrolling and native touch focus. Returning focus to the same trigger must not dismiss its newly opened menu; outside interactions must still dismiss it. Record a dialog's actual event opener because WebKit can activate buttons without focusing them.
- Search-dialog checks must exercise empty and populated Tab cycles, Clear removing its own button, Escape and backdrop dismissal, keyboard reopening, pointer openers that browsers do not automatically focus, and close-before-autofocus cleanup. Build `pnpm build:export` before Javalin acceptance; `pnpm build` produces a separate Node artifact.
- Browser keyboard checks must await the resulting focus before sending the next dependent key; Radix defers roving focus. Testing Library `getByRole` uses an exact string name by default and does not accept Playwright's `exact` option.
- Standard report input-then-download flows use `compactReportInputs` in `ProcessStepScreen`: preserve the compact card, inline Generate Report action, and accessible form name. Keep the shared form and cancellation; do not replace metadata-driven fields with primitive inputs. Custom components, section labels, process fields and longer flows retain normal step presentation. `DynamicForm.compactInputs` changes ordinary flat-list defaults while preserving explicit grid widths and rich editors.
- Test report editor tabs at a viewport short enough to make their panel scroll. Firefox can add a native focus stop on an overflowing container unless it has an explicit `tabIndex`; the report panel uses `-1` so Tab enters the first control. Verify draft preservation and metadata that hides Columns. Check validation contrast on the actual tinted alert background in both themes.
- A saved-view reset must discard both the applied report filter and any unapplied quick-filter input. `FilterAndColumnsSetupEditor` changes the quick-filter session key on view selection/reset; do not reset it on ordinary typing. Report view selection/reset must not call saved-view store/delete APIs. Verify Cancel against persisted report and saved-view rows.
- Report preview column indicators derive from complete criteria at every nesting level. Opening an indicator reveals the existing filter without appending an empty condition. Preserve read-only preview isolation and editor Cancel behavior; `report-filter-indicators.spec.ts` checks preview rows against SQL and stored report JSON.
- Treat table variants as part of the request and cache identity. Report previews reuse `VariantPicker` and the shared per-table storage key; do not query before selection or display rows from the preceding variant while loading. Scope copy and statistics too. Remounting a grid does not cancel an awaiting copy function: guard it against unmount before further requests and clipboard writes. Preserve the delayed-response regression in `report-variants.spec.ts`.
- Error text must be checked against its rendered background, including Retry hover and visible notifications. The error palette follows configured `errorColor`; Sonner currently uses a light notification surface in both page modes. Request the backend fixture before navigating in theme tests so instance metadata is reset. Assert the actual root theme as well as setting its preference.
Verify affected screens with paired desktop and phone captures, including a fresh preference state and explicit metadata overrides. Source inspection and passing behavioral tests do not establish full visual review.
Query URL synchronization in `use-record-query.ts` uses Next-integrated `window.history.replaceState(null, '', url)`. Keep updates synchronous with displayed query state, preserve unrelated parameters/hash, and replace the current entry. Do not reintroduce asynchronous page navigation for filter changes: QRY-093 holds an RSC navigation while checking immediate reload; QRY-017 covers back/forward and legacy JSON links.
## Workflow for a metadata-driven feature
1. **Find the Material behavior and acceptance row.** Search the parity ledger by screen or widget type and identify the owning issue and matrix ID. Read the current Material implementation when the behavior is ambiguous. Preserve negative cases and permissions.
2. **Trace the QQQ producer.** Locate the Java metadata or renderer that emits the relevant response. Record the exact versioned route and response, including whether data is omitted for unauthorized users. Check the real sample fixture. Do not assume the TypeScript interface is a complete backend specification.
For field choices, inspect `inlinePossibleValueSource` in the actual `/qqq/v1/metaData/table/{name}` response. The core Java frontend model can have a property that the v1 response wrapper omits; `REC-062` caught and closed this gap for inline enums.
3. **Pick the existing frontend seam.** Add an API function for a new endpoint, a query key for server state, a renderer case for a new widget type, or a metadata-driven component for a new field behavior. Keep application identifiers in metadata/fixtures only.
4. **Define behavior for loading, empty, invalid, denied, and failed states.** A malformed widget must not crash neighboring widgets. A denied action must be hidden or disabled and refused by the server. Preserve record identity, selected variant, return path, and unsaved form changes across interactions where applicable.
5. **Add focused unit tests for logic and an acceptance scenario for user behavior.** Acceptance tests use the owned QQQ server and check values or SQL state, not only visibility. Give every acceptance test a matrix ID, and use the `diagnostics` fixture. New rows live in the owning `tests/acceptance/matrix/*.json`; the Markdown matrix is generated from those files.
6. **Run the affected quality gates, then the full release gate when warranted.** Record commands, build/backend revisions, browsers, and results. Do not write “parity complete” while open Partial/Missing rows remain.
7. **Update documentation with verified behavior.** Explain the metadata producer, request/response, UI behavior, and any remaining limitation. Revise this guide's catalog if a widget type changes.
For code changes, follow import order and file naming in [CLAUDE.md](../../CLAUDE.md): React/Next, external packages, types, library, components; PascalCase component filenames, kebab-case utilities, co-located `*.test.ts(x)`. Client components with interactivity use `'use client'`. User text comes from `label` rather than internal `name`. Interactive elements need a stable `data-qqq-id`, an accessible name, keyboard operation, and appropriate focus management. Inspect existing IDs and [`material-theme.ts`](../../src/lib/theme/material-theme.ts) before promising Material selector or token compatibility.
## Configuration decision map
| Desired behavior | Correct source | Verify |
|---|---|---|
| Add a sidebar entry or nested app | Backend `QInstance.appTree` with `QAppTreeNode.type`; corresponding object in `apps`, `tables`, `processes`, or `reports` | Visible route and `useAppTreeRoutes`; hidden objects omitted from nav but authorized deep link still works; denied user's metadata. |
| Add an app home section | `QAppMetaData.sections` (`tables`, `processes`, `reports` by name) | Labels, order, links and permissions in app home. |
| Place dashboard widgets | `QAppMetaData.widgets` ordered names; matching `QInstance.widgets` metadata | `WidgetGrid`, widget POST, grid span, denied cases. |
| Place a record widget | `QTableSection.widgetName` and compatible record context; [`FormWidgetSection.tsx`](../../src/components/forms/FormWidgetSection.tsx) for editing | Two records render their own values; form widget changes join host validation/save; no cross-record cache leak. |
| Configure record layout | `QTableSection.alternatives.RECORD_VIEW`, field/section/widget `gridColumns`, section `collapsible`; Material supplemental table `showRecordSidebar` and `recordViewActionsPlacement`; instance `recordViewActionsPlacement` override | Use [`record-layout-utils.ts`](../../src/lib/utils/record-layout-utils.ts); sidebar defaults off and requires explicit `true`. Preserve the original Next defaults for unset widths: Overview half-width cards, selected section full-width card and two field columns (one on phones); explicit metadata spans win. Verify scroll, saved collapse state, and action location in desktop and phone layouts. |
| Configure record action menus | `QTableMetaData.menus` slots `VIEW_SCREEN_ACTIONS` and `VIEW_SCREEN_ADDITIONAL`; [`record-menu-utils.ts`](../../src/lib/utils/record-menu-utils.ts) | Permissions, nested menus, process deduplication, file downloads, and direct Copy/Delete availability. |
| Choose basic filter fields | Table dashboard `defaultQuickFilterFieldNames`, otherwise T1 section fields | Read [`quick-filter-utils.ts`](../../src/lib/utils/quick-filter-utils.ts); confirm resolved names, deduplication, basic/advanced conversion and phone sheet. |
| Add virtual query columns | Backend `virtualFields` with `isQuerySelectable`; filtering/sorting also requires `isQueryCriteria` | Query payload selects the virtual field; display-only virtual fields cannot become filter or sort criteria. |
| Add fields or editors | `QTableMetaData.fields`, sections, adornments, possible-value source | Form, view, validation, server save, keyboard and phone. |
| Show a joined field in a record section | Put `joinTable.field` in `QTableSection.fieldNames` and declare a readable `exposedJoins` path | The record GET includes that join; the qualified value renders with joined field metadata. REC-064 covers the real sample in five profiles; permission-denied paths stay absent. |
| Read a variant table record | `QTableMetaData.usesVariants` and the query screen's `qqq.tableVariant.<table>` choice | Pass [`storedRecordVariantJson`](../../src/lib/utils/table-variant.ts) to each view/edit/copy record GET; verify the same ID resolves to the selected variant's values ([REC-063](../acceptance/feature-matrix.md)). |
| Add a process screen | `QProcessMetaData.frontendSteps` and a supported `QFrontendComponent.type` | Init, submit, async status, cancel/retry, output and server mutations. |
| Add report | `QInstance.reports`, optional `processName` | CSV/XLSX/JSON and required inputs; actual downloaded bytes. |
| Customize identity | `QInstance.branding` for app/company/logo/banners; `supplementalInstanceMetaData.materialDashboardTheme` for the active application theme | Login subset, footer, header, colors, density, icons, contrast, CSP. `QInstance.theme` is not the active theme input. |
| Customize widget chrome | `QWidgetMetaData` fields such as `gridColumns`, `isCard`, `icon`, `collapsible`, `dropdowns`, `showReloadButton`, `showExportButton`, `helpContent`, `icons`, `footerHTML` | Widget response's labels, lists/CSV and interaction; remembered collapse and dropdown state. |
| Customize application CSS | `supplementalInstanceMetaData.materialDashboardTheme.customCss` against observed `data-qqq-id`/implemented CSS tokens | Browser rendering, contrast, and actual Material selector/token mapping. |
| Configure analytics | `QInstance.environmentValues` with provider settings and privacy opt-ins | No provider initializes without settings; IDs/search/labels withheld by default; CSP for scripts. |
| Switch host mode | `QQQ_NEXT_OUTPUT=export` for Javalin jar, or standalone build with `QQQ_BACKEND_URL` | Root deep link, `/qqq/v1` calls, asset paths, browser refresh. |
Table dashboard settings use `supplementalMetaData.materialDashboard` in v1; `supplementalTableMetaData.materialDashboard` is the legacy fallback. When both objects exist, the v1 object wins. Use [`materialDashboardTableMetaData`](../../src/lib/utils/goto-utils.ts) rather than reading only one spelling.
The exact type names and optional fields are in [`metadata.ts`](../../src/types/metadata.ts). `supplementalInstanceMetaData`, `supplementalTableMetaData`, and widget `defaultValues` are extension maps, not licenses to invent arbitrary frontend behavior. Inspect each consumer and QQQ backend producer first.
### A safe widget change, end to end
For an existing `statistics` widget, the backend can declare metadata equivalent to this TypeScript value:
```ts
import type { QWidgetMetaData } from '@/types'
const metadata = {
name: 'openOrders',
label: 'Open orders',
type: 'statistics',
hasPermission: true,
gridColumns: 4,
showReloadButton: true,
} satisfies QWidgetMetaData
```
The canonical QQQ renderer then returns a payload such as `{ "type": "statistics", "count": 7, "countContext": "orders awaiting review" }`. `ConnectedWidget` calls the typed widget API, `WidgetBlock` renders the frame, and `WidgetRenderer` selects `QqqStatisticsWidget` because `count` is present. The local demo statistic instead uses `value`; do not copy that shape into a QQQ Java renderer without verifying it. To add a new renderer type, add the dispatcher branch, a focused component, a malformed/empty case, real backend fixture, and a WID acceptance row. For existing types, do not add a duplicate dispatcher path.
For a dropdown, metadata alone is insufficient. The backend payload supplies parallel `dropdownNameList`, `dropdownLabelList`, `dropdownDataList`, and `dropdownDefaultValueList`. The selected option ID is sent back as a widget request parameter. A PVS dropdown uses `possibleValueSourceName` as that parameter name; a `DATE_PICKER` uses its `name`. `storeDropdownSelections` writes a local preference only. Test a stale stored choice and a required selection ([WID-047–WID-050](../acceptance/feature-matrix.md)).
For `customComponent`, metadata `defaultValues.componentName` and `componentSourceUrl` select a trusted external bundle. The browser loads `window[componentName][componentName]`, exposes `window.React`/`window.ReactDOM`, and passes `qfmdBridge` and `qContext` to the component ([`QqqContainerWidgets.tsx`](../../src/components/widgets/QqqContainerWidgets.tsx), [`qfmd-bridge.tsx`](../../src/components/widgets/qfmd-bridge.tsx)). Verify an existing application's real bundle and CSP before claiming compatibility; the in-repo bridge test alone does not prove it.
### Widget editors and host forms
[`WidgetRenderer.tsx`](../../src/components/widgets/WidgetRenderer.tsx) dispatches all 38 renderer labels cataloged in the [developer guide](./next-ui-developer-guide.md#7-widgets-placement-configuration-and-data). Pass the host's `formContext`, `dataVersion`, and `onWidgetData` through the existing connected boundary when the widget edits data. A record form's [`FormWidgetSection.tsx`](../../src/components/forms/FormWidgetSection.tsx) selects editable widgets; a process `WIDGET` step has its own host ([`WidgetComponent.tsx`](../../src/components/process/WidgetComponent.tsx)). Do not make a display widget write directly to the backend or silently mutate a neighboring form field.
For a row builder, inspect [`row-builder-model.ts`](../../src/components/widgets/row-builder-model.ts) and the backend producer before setting `defaultValues`. It reads `frontendFields` (else `fields`), `associationName` or `outputFieldName`, `orderByFieldName`, `mayReorderRows`, `isForRecordViewAndEditScreen`, `isEditable`, `useModalEditor`, `inlineHeading`, `modalTitle`, and `defaultValuesForNewRowsFromParentRecord`. A record form uses association values when `associationName` is set; a process step uses the named output field. Verify add/delete/reorder, validation, save/cancel and reload with real data. The metadata flags accept booleans or the strings `"true"`/`"false"` where the model explicitly parses them.
For `pivotTableSetup`, the record view reads `pivotTableJson`; a form supplies the editor context. The editor needs `tableName` and `columnsJson` in the host form and writes serialized `pivotTableJson`. Verify switching the pivot on/off through the actual host form and database readback: a widget-only callback assertion does not prove that clearing survives form validation. Also check modal cancel/save, invalid or missing columns and a persisted reload ([`PivotTableSetupWidget.tsx`](../../src/components/widgets/PivotTableSetupWidget.tsx)). For `dynamicForm`, inspect the payload's `fieldList`/`recordOfFieldValues` and `defaultValues.isEditable`; its editable fields join the process or record form's validation and submission. For a record widget that merges to JSON, verify the named table field exists in a hidden section (rather than marking the field `isHidden`), the renderer returns stored values in `recordOfFieldValues`, required fields block Save, and only the JSON field reaches the record API. [REC-061](../acceptance/feature-matrix.md) proves the full round trip across five browser profiles. `childRecordList` can manage an association when `defaultValues.manageAssociationName` is set. `dataBagViewer` can edit through `storeDataBagVersion` when that process exists.
`filterAndColumnsSetup` renders a summary and paged, sortable, resizable preview on record view, and an editor when a form context is present, unless the payload sets `overrideIsEditable: false`. [`FilterAndColumnsSetupWidget.tsx`](../../src/components/widgets/FilterAndColumnsSetupWidget.tsx) hands the form to [`FilterAndColumnsSetupEditor.tsx`](../../src/components/widgets/FilterAndColumnsSetupEditor.tsx). The editor loads the selected table's metadata, uses `FilterBuilder` and `ColumnConfig`, offers ordered sort levels and a draft preview inside the modal, and writes `queryFilterJson`/`columnsJson` (or payload-selected field names) to host values on `OK`. Edit opens the filter tab; + Add Filters and + Add Columns appear when the respective saved content is absent and open the matching tab. `Cancel` and closing the dialog discard the draft; persistence still requires saving the host record or advancing a process. A `WIDGET` field adornment with `values.widgetName` uses [`WidgetAdornmentField.tsx`](../../src/components/forms/WidgetAdornmentField.tsx): it sends the host form values and `__formFieldAsWidget_FieldName` to the widget request, overrides `filterFieldName` with the actual field name (including a row index), and writes only that field back. Material supports `filterAndColumnsSetup` in this field path; other widget types show a contained unsupported message. Inspect [`filter-and-columns-utils.ts`](../../src/components/widgets/filter-and-columns-utils.ts) and the backend producer for `hideColumns`, `hideSortBy`, `hidePreview`, `allowVariables`, API-version settings, default filter fields, omitted joins, and custom labels. A missing table disables editing; missing required default-field values produce an alert when an editor control is pressed. A variable saves as a `FilterVariableExpression`; unresolved variables suppress preview and a query opened in a new window until a value is supplied. WID-030, WID-070, WID-071 and WID-072 cover the summary, preview, variable assignment, editor save, field-adornment process path and API-versioned metadata/query/count across five browser profiles. WID-072 supplies version flags to the widget response while the metadata and data requests go to real QQQ API-aware routes. The editor now shares Basic/Advanced controls, saved-view selection, density, refresh, column menus, and statistics with the query components. Saved-view selection is intentionally read-only in this host: it imports the view into the report draft. Read-only preview changes remain local; edited widths/pins serialize into columnsJson on OK and host Save. Starting in RC4, `omitExposedJoins` limits new filter/column choices in both the read-only preview and editor. Existing saved joined columns, criteria and sorts remain usable, and reordering available columns preserves omitted columns in place. Opening the editor and pressing OK preserves those saved fields; Basic/Advanced changes and host Save/reopen retain their values and widths. This is a choice restriction, not a backend permission boundary; existing joined data still uses the normal query permissions. WID-073 checks the configured and unconfigured cases against SQL, including local edits and reload. WID-073 verifies pin/width persistence, full-query copying, and nested statistics. Check the remaining embedded-query parity ledger before claiming complete equivalence; variable assignment currently uses Advanced mode.
### Composite input editors
For complete record submissions, call `wireValuesFromForm(table, values, originalValues)`: EntityForm passes `record.values`, association editors pass the existing row values, and process child editors pass the original dialog defaults. Ordinary updates still omit unchanged DATE_TIME fields. Preserve this distinction for copies and child-row edits; omitting the original can shift a repeated DST hour and truncate fractional seconds. REC-065 verifies the copy wire value and SQL source/copy; both child-editor paths have component regressions.
Use `toLocalDateTimeInput` for seeded process/standalone DATE_TIME values and `fromLocalDateTimeInput(value, originalValue)` at submission. Supplying the original preserves an unchanged instant in the repeated DST hour and its hidden subsecond precision. Normalize the final process payload after contributors have added values, so bulk-edit fields use the same contract. Do not slice UTC text into datetime-local controls or send local text as an instant. PRC-064 and WID-075 exercise America/New_York dates and seconds. Published RC7 sets a one-second step on plain TIME blocks, matching regular/shared fields. Keep TIME values as local clock strings; do not apply instant/timezone conversion. PRC-064 checks unchanged and edited seconds with SQL readback, and WID-074 checks standalone native stepping. This correction is published in RC7 at `f65cf6b`; RC6 remains unchanged.
`ProcessRun` preserves focus already held inside a configured `[data-qqq-autofocus="true"]` editor; other later screens still focus their heading. Preserve this precedence when changing screen lifecycle. On scalar Enter, normalize text in the hosting form before validation/submission; do not trim unrelated fields or convert typed values to display strings. PRC-063 covers later-screen focus, placeholders, prior values, first blur, whitespace validation and backend-stored text.
Keep plain block controls visually stable. Backend field metadata supplies the neutral `displayFormat: "%s"`; do not treat that as a request for a different editor. Reuse `BlockInputEditor` and `DynamicFormField` for choices, formatting, behaviors and supported field adornments. Process blocks must bind to the existing screen form and send file contents as multipart files. Never create nested forms or serialize a file as its name. Surrounding process values belong in a field widget's request context, but widget changes must update only its own field.
Verify hidden/read-only flags, typed choice IDs, numeric conversion, multiline Enter, required uploads and uploaded bytes. For uppercase/lowercase behaviors, replace part of mixed-case text with an uppercase character and check the caret plus stored values. Include Unicode expansion (`ß` to `SS`, `İ` to `i` plus combining dot), then type another character to verify the insertion point; retain backward selection direction. Cover both `STRING` and `TEXT`: textarea Enter must insert a newline without submitting, including with `submitOnEnter: true` (PRC-066/WID-074). For explicitly configured `PASSWORD` fields, verify masked editing, Unicode expansion, show/hide value preservation, Enter/Submit, and independent database readback. Also prove that passwords without a case behavior retain mixed case. The standalone choice field must not collapse below the neighboring plain input's width: a narrow control can place its Clear button under an ordinary center click. Check system and wider native fonts, plus a 320px viewport. The metadata-editor minimum is capped by available width. Sample-dashboard acceptance must initialize the owned backend session and diagnostics before its page-opening hook. PRC-062 exercises the backend round trip, and WID-074 checks the standalone controls. Screenshot comparisons must include the richer editors' height and scroll states; functional success does not approve a changed layout.
## Navigation and route rules
`/app/{slug}` resolves an app, table, process, or report from loaded instance metadata, in that order. Do not create a static page for each QQQ object. `isHidden` removes a table/process/report from navigation, while an authorized direct route still works. New dynamic route shapes must work in both standalone and Javalin static export. The export prerenders `_` placeholder paths; [`useRouteParams`](../../src/lib/hooks/use-route-params.ts) translates them from the browser path. Preserve `trailingSlash` handling and avoid constructing deep links that a static host cannot serve. Use [`material-links.ts`](../../src/lib/utils/material-links.ts) for generated links. The not-found route's [`LegacyPathRedirect`](../../src/components/layout/LegacyPathRedirect.tsx) recognizes nested Material paths and `QInstance.redirects` exact or trailing `/*` rules while preserving query/hash. Test deny/not-found loops, a child create URL (`/app/{table}/{id}/createChild/{childTable}`), and refresh of each route shape. Root-path hosting is the documented target; the parity ledger lists base-path support as Missing.
The [developer route catalog](./next-ui-developer-guide.md#4-navigation-and-deep-links) includes authentication callbacks, table/process developer views, record actions and child creation. `/app/{table}/{id}/{process}` resolves registered processes (table exact name, table suffix, then instance exact name) and redirects with record and return context; the runner enforces permission; preserve that route alongside the reserved edit/copy/dev pages.
The query URL owns `page`, `pageSize`, `filter`, and `q`; do not move shareable filter state into local storage. Saved views have `/app/{table}/savedView/{id}` and backend persistence. Local storage is for preferences, such as density, column configuration, and widget selections. See [state management](../STATE-MANAGEMENT.md). Put app shell labels, icons, and nested nav in metadata and verify breadcrumb titles against the same tree.
### Keep configuration coverage executable
The developer guide's [widget configuration recipes](./next-ui-developer-guide.md#widget-configuration-recipes), [link parameters](./next-ui-developer-guide.md#link-parameters-and-launch-examples), [theme properties](./next-ui-developer-guide.md#theme-property-reference), and [analytics setup](./next-ui-developer-guide.md#analytics-configuration-example) cover the supported keys and copyable response fragments. Update the matching reference whenever a consumer changes; a catalog row with only a widget name is insufficient documentation for new configuration.
- Compare all `WidgetRenderer` cases with the catalog, including canonical/demo payload branches. Keep `QqqComposite` leaf types and layouts covered separately; they are not top-level widget dispatch labels.
- Compare theme properties with `THEME_PROPERTY_KINDS`, including generated typography and radius keys. Keep browser-public `environmentValues` separate from build variables and backend secrets.
- Trace dropdown behavior through `ConnectedWidget`: lists pair by position, payload names are request keys, response defaults must be offered options, and `dropdownNeedsSelectedText` gates the body. The interface's `isRequired` and `defaultValue` fields do not by themselves implement those behaviors.
- Preserve `gridCols:sizeClass:*` overrides in widget metadata defaults. Their Material breakpoint widths differ from ordinary Tailwind defaults; explicit overrides can keep widgets side by side on smaller screens.
- Document `table` cell helper values and `subRows`, chart dataset/URL arrays, and host-specific composite behavior when changing these families. Modal control codes work locally; process `conditional` and interpolation depend on the composite host. RC6 icon compatibility preserves existing Lucide mappings, then lazily resolves valid legacy names through `LegacyMaterialIcon` and the pinned local font. Unknown names retain their fallback. Keep the codepoint inventory, font and license together; verify real JAR font delivery, both themes, existing mapped icons, image precedence, and bundle budgets before integration. Do not attribute this behavior to RC5 or claim support for every Material Symbols name/style.
- Check both table-scoped aliases (`/app/{table}/{process}` and `/app/{table}/{report}`) before record GET behavior. Process aliases take precedence over reports; matching names can shadow record IDs. Preserve form hash presets, record tab/view/return parameters, process selection/default inputs, and safe return-path validation.
- Preserve the distinction between a displayed preview, a host-form draft, and persisted data. A payload example demonstrates the contract shape; it does not establish backend acceptance for every combination of options.
## Security and accessibility review points
- Use the typed API client and same-origin cookie session. Do not put access tokens, passwords, or server secrets in browser storage, public environment variables, widget metadata, or tests.
- Session resume now calls `manageSession` with an empty request so browser code can use HttpOnly session cookies; do not restore a `document.cookie` dependency. Test sign-in, refresh, expiry and sign-out against cookie attributes and the real server. Authentication metadata's `?clearAuthenticationMetaDataLocalStorage` switch is a cache reset, not a permission bypass.
- Preserve existing identity-provider callback registrations: OAUTH2 uses `/token`, AUTH_0 uses `/`. Provider descriptions may reach login only through the state-matched callback and a one-time browser session value; never render free text from `?error_description=` on a direct link.
- Check permission at the UI and the backend. Exercise a denied persona in acceptance; a hidden button alone is insufficient. Do not call widget, process, or record endpoints for an omitted or disabled object.
- For exposed query joins, check every target and intermediate table named in `joinPath`. The query screen filters its metadata before rendering columns or filters, and the request hook applies the same rule before sending a join. Keep both paths aligned when changing join UI.
- Sanitize HTML. Review CSS, external links, iframes, custom bundle URLs, and CSP when adding a new embed. The Javalin host and standalone server supply security headers; test the deployed response. The [security review](../security/next-ui-review.md) predates some integrated fixes, so reconcile each follow-up with source and current acceptance evidence.
- Give forms real labels, `aria-required`/`aria-invalid` where relevant, keyboard operation, error text, and a sensible focus target. Dialogs and phone sheets must trap focus and restore it. Check both desktop keyboard and phone/tablet touch interactions.
- The nonmodal Columns popup focuses its named container on open and returns focus to its trigger on explicit close/Escape. Outside clicks keep focus on the clicked control. Embedded report column editors must not take focus when mounted. Verify keyboard search and native browser Tab behavior with QRY-092.
- Keep errors local when possible: widget failures show a contained retry and leave neighboring widgets usable; route errors and missing records have dedicated states. Never swallow an API failure into a blank page.
## Quality gates and evidence
| Command | Evidence it supplies |
|---|---|
| `pnpm typecheck` | TypeScript contract check. |
| `pnpm lint` | ESLint and license headers. |
| `pnpm test` | Vitest unit/regression behavior. |
| `pnpm test:e2e` | Mocked Playwright UI paths, useful but not real backend compatibility. |
| `pnpm build:export` | Javalin static-export build and dynamic placeholder output. |
| `pnpm perf:budget` | Static-export JS/CSS budget after `build:export`; see [performance guide](../acceptance/performance.md). |
| `QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet pnpm test:acceptance` | Full real-sample browser and backend gate when `QQQ_SAMPLE_JAR` points to a compatible sample jar. |
For RC1, prioritize workflow depth in Chromium: normal entry/defaults, validation, edit/cancel, save/reopen, dependent selections, permissions/recovery and persisted records or downloads. Preserve broader tests and report their status; do not treat scenario counts as proof that everything works.
Read [acceptance setup](../../tests/acceptance/README.md) and [browser matrix](../acceptance/browser-matrix.md) before running the final gate. Default acceptance runs only Chromium; the five-project command is necessary to claim the stated browser scope. Phone and tablet run tagged tests, with matrix coverage enforced by the gate. Every test title needs its matrix ID; use fixtures with a reset database, verify persisted effects through SQL, and include `diagnostics` for console, network, and CSP failures. Do not loosen a matrix row, skip a failing browser, or declare an external service excluded without the documented approval path.
## Common mistakes to avoid
- **Inventing config from a plan or TypeScript escape hatch.** Read the actual QQQ producer and renderer; `Record<string, unknown>` does not prove a key is honored.
- **Treating mocked widgets as a canonical backend contract.** `statistics.value` and several demo aliases are fixtures; canonical QQQ statistics uses `count`.
- **Editing a route for one table.** The slug route and app tree are generic; change backend metadata or general dispatch behavior.
- **Fetching inside a presentational metadata component.** Keep metadata as a prop where the component pattern expects it; use hooks/API modules at the connected boundary.
- **Calling raw `fetch` for QQQ endpoints.** It bypasses versioned base URL, credentials, and centralized error/session behavior.
- **Using `name` as display text.** It is an internal identifier; use `label` and a deliberate fallback only when metadata is genuinely absent.
- **Claiming Material theme, custom bundle, URL, or workflow parity from a happy path.** Check current code, the parity ledger, negative/permission rows, and actual browser/backend acceptance evidence. The filter/column editor has real-server coverage in WID-070–WID-072, while the embedded full query screen remains parity work.
- **Testing a static deep link on a generic file server.** QQQ Javalin serves placeholder pages for dynamic paths; standalone Next has its own routing path.
## Completion checklist for an agent change
1. The backend metadata or widget/process producer and the frontend consumer agree on names, types, and payload fields.
2. An authorized user can complete the workflow, while an unauthorized user cannot perform it through the API.
3. Loading, empty, malformed, missing, failed, and retry states are addressed where they apply.
4. Keyboard, focus, labels, responsive phone/tablet behavior, and contrast have been checked for changed UI.
5. Focused tests and the applicable real-server matrix row pass. The final browser claim names the projects actually run.
6. Static export or standalone build, as applicable, works on refresh and direct deep link.
7. Docs and the parity ledger reflect the observed behavior; any open Partial/Missing rows remain visible until proven closed.
### Appearance preferences
Use the real ThemeProvider when rendering UserPreferencesDialog or a sidebar with its user footer. The saved `darkModePreference` is distinct from effective `isDarkMode`: an application theme overrides dark mode without erasing the choice. Preferences exposes Light/Dark, explains a theme lock, and resets both saved appearance and layout defaults. Preserve `qqq-dark-mode` storage compatibility. Verify changes through the actual Preferences flow and reload (NAV-056), including phone drawer layering/focus (NAV-038); injecting localStorage alone does not exercise the user control. There is currently no system appearance choice or account/device synchronization.
### Google Drive integration boundary
Google Drive picker changes span the frontend component/SDK loader and Javalin's CSP
and v1 public-environment allow-list (#704; backend PR #908 merged to develop as `bac663044`). The frontend needs those backend changes to receive its public configuration and load under the configured CSP. PRC-065 tests controlled SDK responses with
real process execution and SQL; PRC-039 is the approved real-account exclusion. Preserve
the shared-drive view, folder-only validation, cancellation and token expiry handling.
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin conditional CSP covers its static export only; standalone Node/container deployments also require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
### Script editor suggestions
`CodeEditor` autocomplete is opt-in from `ScriptEditorDialog`; keep other metadata code fields unchanged unless their contract calls for it. QQQ helpers, language keywords and file identifiers are local suggestions. Preserve native undo/redo, composition input, read-only behavior, caret/selection validation, and Escape-then-Tab focus escape. `records/script-completions.spec.ts` verifies loaded-revision undo/redo, prefix and pointer/touch insertion, both-theme accessibility, and SQL-backed save/reload. Native browsers group consecutive edits differently; do not claim every insertion creates a separate undo transaction.
### RC8 implementation notes
Public JAR checksum/signature/fresh-consumer checks and 28 affected desktop/mobile workflows passed. Full tag Chromium passed 602 and mobile 563; Firefox, WebKit and tablet retain failures, including tablet Clear-search focus. Consult the release notes before claiming cross-browser completion.
- Search must contain the entire Tab/Shift+Tab cycle, handle Escape from every dialog control, restore the opener after dismissal, and move focus to the input before Clear removes its own button. Explicitly focus a phone pointer opener because WebKit does not necessarily focus clicked buttons. Cancel delayed autofocus when closing. `navigation/search-focus.spec.ts` covers light/dark and pointer/keyboard openings; preserve the existing layout.
- Keep RapiDoc width handling on its public CSS parts. The named `qqq-api-overview` and `qqq-api-overview-wide` containers restrict narrow wrapping and wide content sizing to overview specification actions; do not apply it to authentication or endpoint controls. Test full visibility before focus can scroll a clipped control into view, real JSON downloads, keyboard new-tab behavior, both themes, and label fit with wider application fonts at desktop and phone widths.
- LONG range validation must run only after integer syntax succeeds. Preserve integer-string bounds as `BigInt` values; converting through `Number` rounds large bounds. Keep the mixed numeric comparison for fractional numeric bounds. Test `safeParse` error results without exceptions, exact positive/negative boundaries, optional blanks, and correction/submission through block and shared process forms. These changes do not introduce a backend range-metadata contract.
+500
View File
@@ -0,0 +1,500 @@
# Next UI developer guide
> **RC9:** application code is unchanged from RC8. See [RC9 notes](../releases/1.0.0-RC.9.md) and [published releases](https://github.com/QRun-IO/qqq-frontend-next/releases) for versioned verification and public delivery status. Earlier RC8 receipts below remain historical evidence.
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The latest published prerelease is [1.0.0-RC.9](../releases/1.0.0-RC.9.md) (commit `c029ad2`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
The visual baseline is the September 25 Next UI design (`e42ad2b2`). Material remains the reference for backend contracts and functional behavior. The layout defaults below describe current source; they do not certify a completed visual review.
## 1. What an application supplies
QQQ supplies an instance metadata document with `apps`, ordered `appTree`, `tables`, `processes`, `reports`, `widgets`, and `branding`; help content, environment values, supplemental metadata, and redirect rules may also be present. The UI renders those objects for the signed-in user's permissions. A table, widget, or route name is an internal identifier; its `label` is the text users see. Define names and behavior on the backend, then verify the versioned metadata response. Do not edit the frontend to add an application-specific table or menu item.
The source of truth for the frontend shape is [`src/types/metadata.ts`](../../src/types/metadata.ts). A small, **partial response fragment** illustrates the relationships; it is not a complete `QInstance` response:
```json
{
"appTree": [{ "name": "operations", "label": "Operations", "type": "APP", "children": [{ "name": "orders", "label": "Orders", "type": "TABLE" }] }],
"apps": { "operations": { "name": "operations", "label": "Operations", "widgets": ["ordersToday"], "sections": [{ "name": "work", "label": "Work", "tables": ["orders"] }] } },
"widgets": { "ordersToday": { "name": "ordersToday", "label": "Orders today", "type": "statistics", "hasPermission": true, "gridColumns": 4 } },
"branding": { "appName": "Operations", "accentColor": "#1d4ed8" }
}
```
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
## 2. Install and run
The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-next` Maven jar. A QQQ 4.1+ application's `QApplicationJavalinServer` serves its static export from `next-dashboard/` at `/` on the same origin as `/qqq/v1`. The [repository README](../../README.md) and [jar README](../maven-artifact/README.md) describe this packaging. The QQQ [sample quickstart](https://github.com/QRun-IO/qqq/blob/main/qqq-sample-project/README.md#quickstart-with-next) exercises the packaged path. Material can be selected with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`; do not assume Material and Next use the same URL layout.
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.5</version>
</dependency>
```
For frontend development, use a compatible Node.js version (the README specifies 20.19+ or 22.12+) and pnpm 9.15.9:
```bash
pnpm install
NEXT_PUBLIC_MOCK_API=true pnpm dev
pnpm build:export
pnpm build:jar
```
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). Use version `1.0.0-RC.8` for the published prerelease, overriding an older BOM-managed frontend. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
| Setting | Behavior in this checkout |
|---|---|
| `NEXT_PUBLIC_API_BASE_URL` | Defaults to `/qqq/v1`; baked into browser requests. Keep the UI and API on the same origin where possible. |
| `NEXT_PUBLIC_MOCK_API` | `true` enables MSW development fixtures; `build:export` forces `false`. |
| `QQQ_NEXT_OUTPUT` | `export` selects the Javalin-hosted static export; otherwise standalone build. |
| `QQQ_BACKEND_URL` | Standalone build only: backend origin for Next rewrites; fixed at build time. |
| `NEXT_PUBLIC_APP_VERSION` | Optional version reported to the backend; defaults to `package.json` version. |
| `QQQ_DASHBOARD_CSP_SOURCES` | Standalone server runtime setting: additional CSP directives/sources for external services. See Google Drive hosting below. |
The static export is built for `/`, with `trailingSlash: true`. QQQ serves placeholder HTML for dynamic route shapes; [`useRouteParams`](../../src/lib/hooks/use-route-params.ts) reads real path segments from the browser. Keep that behavior intact when adding routes. The [parity ledger](../acceptance/material-parity.md) currently lists sub-path hosting as unsupported. A plain file server without the QQQ route fallback is not a valid deep-link test.
## 3. Authentication and security
`GET /qqq/v1/metaData/authentication` selects `AUTH_0`, `OAUTH2`, `FULLY_ANONYMOUS`, `MOCK`, or `TABLE_BASED` (see [`QAuthenticationMetaData`](../../src/types/metadata.ts) and [`auth-provider.tsx`](../../src/lib/auth/auth-provider.tsx)). OAuth flows use authorization code and PKCE; table-based sign-in sends credentials to the QQQ session endpoint. The server issues `sessionUUID`/`sessionId` cookies; current Next code resumes an existing session with an empty `manageSession` request, allowing HttpOnly cookies that browser script cannot read. Browser API calls use credentials. A 401 clears session state and returns to sign-in. The pre-login response exposes only limited login branding, not banners or custom CSS. Authentication metadata is cached briefly; `?clearAuthenticationMetaDataLocalStorage` clears that cache.
Existing provider registrations remain usable: OAUTH2 redirects to `{origin}/token`, while AUTH_0 redirects to `{origin}/`. Both use the same state-checked PKCE callback. A state-matched provider denial displays its description once on the login page; a forged callback or direct login link shows only a safe generic error.
Permissions apply to metadata and data: check table capabilities and the corresponding read/insert/edit/delete flags, `process.hasPermission`, `report.hasPermission`, and `widget.hasPermission`. A disabled widget can show a permission message without fetching; omitted metadata cannot be guessed from the UI. Use the backend to prove a forbidden operation is rejected. HTML from metadata or widget payloads is sanitized before display. Treat theme `customCss` and custom component bundles as trusted application configuration with a stricter review boundary. The Javalin dashboard and standalone Next server configure security headers and CSP; embedding an external provider needs an allowed source, not a disabled policy. The [security review](../security/next-ui-review.md) is an earlier audit; compare its follow-ups with current code and the latest acceptance gate before treating them as resolved.
## 4. Navigation and deep links
[`useAppTreeRoutes`](../../src/lib/hooks/use-routes.ts) recursively walks `appTree` to build sidebar links, labels, ancestor breadcrumbs, and the first accessible app. `APP` nodes nest; visible `TABLE`, `PROCESS`, and `REPORT` nodes navigate to `/app/{name}`. Hidden objects are omitted from navigation but can still have direct routes when authorized. An app home uses its `widgets` and `sections`; a table slug opens its query page; a process slug opens its wizard; a report slug opens its runner. The unified route resolves name collisions in that order: app, table, process, report. Use unique names across these maps.
| URL | Screen |
|---|---|
| `/` | Opens `/app`, or handles an Auth0 authorization callback. |
| `/login`, `/callback`, `/token` | Sign-in and supported authorization callback routes. |
| `/app` | Instance dashboard / app entry point. |
| `/app/{appName}` | App home and its ordered widgets/sections. |
| `/app/{tableName}` | Query or key lookup, according to table capability. |
| `/app/{tableName}/create` | Create record. |
| `/app/{tableOrProcessName}/dev` | Table or process developer view; table takes precedence. |
| `/app/{tableName}/{recordId}` | Record view; `/edit`, `/copy`, and `/dev` are subroutes. |
| `/app/{tableName}/{processName}` | Table-scoped process alias; redirects to the process runner with query parameters and a return path to the table. |
| `/app/{tableName}/{reportName}` | Alias for a report whose `tableName` matches; redirects to its runner. |
| `/app/{tableName}/{recordId}/createChild/{childTable}` | Child create form with parent association values. |
| `/app/{tableName}/{recordId}/{processName}` | Resolves a registered process and redirects to its runner with record and return context; execution still checks permission. |
| `/app/{tableName}/key?...` | Unique-key lookup. |
| `/app/{tableName}/savedView/{viewId}` | Server-backed saved query view. |
| `/app/{processName}` | Process run. |
| `/app/{reportName}` | Report run and download. |
| `/app/search?q=...` | Navigation, recent-record, and available record search results. |
| `/app/developer` | Developer information available to permitted users. |
The header search and `/` dialog locate navigation targets and recent records; record search is offered when the backend supports it. RC8 includes the search-focus correction: Tab/Shift+Tab remain within the search dialog, Clear returns focus to the search input, and Escape or backdrop dismissal restores focus to the opener. A pointer-opened phone search restores focus to its actual button in verified Chromium/mobile cases. RC8 tablet WebKit has two open Clear-search focus failures; see the release notes for remaining browser issues. RC7 does not include this correction. The command palette opens with `.` or Cmd/Ctrl+K and has a header button; `?` opens keyboard help. Keyboard shortcuts on query and record pages are listed in the [feature matrix](../acceptance/feature-matrix.md). The sidebar has a phone drawer, nested app groups, banners, and a user menu. Branding can add a company footer and a themed bar above the shell. `?helpHelp` reveals help-slot keys for content authors.
Material's nested `/{app}/{table}/{id}` URLs are not the native Next route. A not-found page now checks the signed-in instance metadata, applies exact or `/*` redirect rules, and resolves nested Material app paths to `/app/...` while preserving query/hash ([`legacy-paths.ts`](../../src/lib/utils/legacy-paths.ts), [`LegacyPathRedirect.tsx`](../../src/components/layout/LegacyPathRedirect.tsx)). Verify a specific old bookmark rather than assuming arbitrary legacy URLs match. For links generated by widgets use [`material-links.ts`](../../src/lib/utils/material-links.ts), which normalizes supported legacy links and preserves a trailing slash for static-export routes.
### Link parameters and launch examples
The table-scoped process/report aliases are resolved before a record GET, so avoid record IDs that collide with those names. Process resolution checks an exact table process, then an instance process; the record-scoped route also accepts a table process name suffix. A matching process takes precedence over a report. Use full process names for unambiguous new links. See [`material-links.ts`](../../src/lib/utils/material-links.ts).
| Link context | Supported parameters and behavior |
|---|---|
| Record view query | `tab` selects a section; `view=tabs` or `view=list` selects the layout. `from` is a safe local return path and `fromLabel` labels it. The default view comes from Preferences. |
| Record view hash | `#audit` opens audits when available; `#/launchProcess=<name>` launches a process; `#<sectionName>` selects a section. `#/createChild=<table>` starts child creation and accepts the presets below. |
| Create/child-create hash | `#/defaultValues=<encoded JSON object>/disabledFields=<encoded JSON array>` seeds and locks fields. `disabledFields` also accepts a JSON object whose truthy entries name locked fields. These are UI presets; backend validation and authorization still apply. |
| Process query | `recordsParam=recordIds&recordIds=1,2` selects IDs; `recordsParam=filterJSON&filterJSON=<encoded QQueryFilter>` selects by filter. The legacy selector `queryFilter` uses the same `filterJSON` value. |
| Process context | `defaultProcessValues=<encoded JSON object>` presets inputs; `returnTo=<encoded local path>` supplies the return destination. `tableName` supplies launch context only for a process without its own table and only when that table exists in metadata. |
| Dashboard hash | `#<widgetName>` targets a widget anchor. An embedded `process` widget also reads the page's `recordIds` query parameter. |
Build encoded links instead of concatenating raw JSON. For example, where metadata declares the supplied process/table names:
```ts
const params = new URLSearchParams({
recordsParam: 'recordIds',
recordIds: selectedIds.join(','),
defaultProcessValues: JSON.stringify({ sendNotification: true }),
returnTo: `/app/${encodeURIComponent(tableName)}`,
})
const processHref = `/app/${encodeURIComponent(processName)}/?${params}`
const createHref = `/app/${encodeURIComponent(tableName)}/create/#/defaultValues=${encodeURIComponent(JSON.stringify(defaultValues))}/disabledFields=${encodeURIComponent(JSON.stringify(disabledFieldNames))}`
```
Only preset fields actually declared by the application's metadata. Record/form hash decoding is implemented by [`formPresetsFromHash`](../../src/lib/utils/material-links.ts); process query decoding lives in the [slug page](../../src/app/%28dashboard%29/app/%5Bslug%5D/page.tsx). Test a generated link on the static-export host and after refresh.
## 5. Tables, queries, records, and forms
A failed query displays its backend error with Retry and Dismiss controls. Retry repeats the request; Dismiss closes the panel without changing the filter. Correct or reset an invalid filter to load records again. Query and pivot error text use theme-derived shades for readability; the top error notification also uses the configured error palette.
Tables are described by [`QTableMetaData`](../../src/types/metadata.ts): `primaryKeyField`, `fields`, `sections`, `associations`, `exposedJoins`, capabilities, and permission flags. A field supplies `name`, `label`, `type`, `isRequired`, `isEditable`, `isHeavy`, `isHidden`, and `adornments`; possible values, bounds, formats, and help are optional. The supported primitive field types are `STRING`, `INTEGER`, `LONG`, `DECIMAL`, `BOOLEAN`, `DATE`, `TIME`, `DATE_TIME`, `TEXT`, `HTML`, `PASSWORD`, and `BLOB`. [`DynamicFormField`](../../src/components/forms/DynamicFormField.tsx) chooses editors, while [`zod-from-metadata.ts`](../../src/lib/utils/zod-from-metadata.ts) builds validation. Configure fields in QQQ metadata, not in a per-table React component.
Table dashboard settings are read from `supplementalMetaData.materialDashboard` in the v1 response, with `supplementalTableMetaData.materialDashboard` accepted as a legacy fallback. The v1 object takes precedence when both are present. These settings include `defaultQuickFilterFieldNames`, `gotoFieldNames`, `showRecordSidebar`, and `recordViewActionsPlacement`. Quick-filter defaults use the configured nonempty field list, otherwise T1 section fields; unresolved names and duplicates are removed. Query-selectable `virtualFields` also become columns, but only virtual fields with `isQueryCriteria` can be filtered or sorted.
For a fixed choice list, put `inlinePossibleValueSource` on the QQQ field with `type: ENUM`, an `idType` matching the field, and ordered `enumValues` containing `id` and `label`. Do not also set `possibleValueSourceName`: QQQ rejects both sources on one field. Next filters inline labels by prefix without a possible-values request and stores the selected `id`. A `CHIP` field adornment with keys such as `color.HIGH` and `icon.HIGH` styles both record values and form options. The v1 table metadata response must include the inline source; [REC-062](../acceptance/feature-matrix.md) verifies its rendering, persistence, and reload against the sample server.
The query page supports basic quick-filter chips and an advanced nested filter builder, quick search, sort, column visibility/order/width/pinning, density, pagination, CSV export, row selection, bulk process launch, column statistics, and table variants where declared. On desktop, Filter opens a panel containing the Basic/Advanced switch, sort controls, quick-filter chips, and advanced builder. On phones, Filter opens a sheet containing the same mode, sort, and quick-filter controls; Advanced shows the nested builder inside that sheet. Opening Filter preserves the selected mode. Advanced mode uses a single editable condition list with per-row removal and one confirmed Clear action in the footer. Complex criteria remain in Advanced mode; the basic chips expose metadata-selected fields and let the user add more. The Columns popup supports keyboard entry, search, reordering and focus return on close; embedded report column editors keep the surrounding editor focus. Query columns start at 150 px unless a `SIZE` adornment or a saved/resized width supplies another value. `TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`, `TABLE_EXPORT`, and `QUERY_STATS` gate operations alongside permissions. For tables with `usesVariants`, choose a backend-provided variant before querying; it is stored under `qqq.tableVariant.<table>` and sent to query, process and record view/edit/copy GET requests. Backend field-download URLs carry the same variant; inline BLOB data comes from the variant-specific record response. [REC-063](../acceptance/feature-matrix.md) verifies view/edit/copy against duplicate record IDs in two variants. URL parameters `page`, `pageSize`, `filter`, and `q` carry shareable query state ([`use-record-query.ts`](../../src/lib/hooks/use-record-query.ts)). Saved views have backend persistence, quick-view shortcuts and counts, modified-state comparison, and their own routes when the backend capabilities exist ([`use-saved-views.ts`](../../src/lib/hooks/use-saved-views.ts)). Browser local storage holds personal display preferences and last view state, not authoritative records or permissions.
Record pages render sections, field values, associated records, widgets, metadata-defined actions, help, and audit history. `QTableSection.alternatives.RECORD_VIEW` replaces the base section for this screen. Visible sections appear in metadata order; fields, sections and widget cards use `gridColumns` as twelfths on large screens and stack on narrow screens. When widths are not configured, Overview uses half-width cards on large screens; a selected section uses a full-width card with two field columns from the small breakpoint, stacking fields on phones. Explicit field, section, or widget widths take precedence over those presentation defaults. A collapsible section uses `collapsible.isCollapsible` and `initiallyOpen`, then remembers the choice under `qqq.recordView.collapsibleSectionOpenStates.<table>.<section>`; a section's rule overrides its widget's rule. The sticky desktop section sidebar is off by default; only `showRecordSidebar: true` in the table dashboard settings enables it. `recordViewActionsPlacement` can be set on the table or `supplementalInstanceMetaData.materialDashboard`, with the instance winning; `INLINE_WITH_PAGE_TITLE` places desktop actions beside the local record title, while the default `IN_IDENTITY_SECTION` keeps them in the identity controls. The identity card omits the primary-key T1 field and T1 values already contained in the record label; display values take precedence over raw values for that comparison. Desktop Copy and Delete buttons are omitted when the Actions menu already contains the corresponding enabled action, including in a submenu. An absent or disabled menu action preserves its available direct button. The Audit action reads through `GetAuditsForRecord` when permitted, otherwise through the `audit` table; its dialog groups entries by the viewer's local date, supports oldest/newest sort saved under `audit.sortDirection`, and reports when the 1,000-detail cap truncates results. Create/edit/copy forms use a centered container capped at `max-w-4xl` and follow field metadata, section layout, adjusters, possible-value choices, and server validation; `FILE_UPLOAD` width `full` spans the form grid, while `half` uses the default half width. Copy defaults to Base Copy, which duplicates editable fields; choose Full Copy to edit and submit loaded named associations with the new record. A widget section can edit association rows or form values through `WidgetFormContext` when its type and defaults allow it. Process launch actions can receive selected record IDs or a filter. `gotoFieldNames` in Material supplemental table metadata enables a Go To dialog for the primary key or configured unique keys. Developer views are capability and permission dependent. Consult [record and query acceptance rows](../acceptance/feature-matrix.md) for exact supported cases; do not infer coverage from the existence of a component alone.
Query changes replace the current URL immediately, preserving unrelated parameters and the hash. Reload and browser back/forward restore the visible filter, sort, search and pagination state; changing those controls does not add a separate history entry.
Record section tabs support Left/Right, Home and End to move focus. Enter or Space opens the focused section; Tab moves into the named content panel. Moving focus alone does not load section widgets. Phone sections use accordion buttons with Enter/Space activation. The selected tab remains reflected in the URL.
Query header labels can wrap to two lines when their text and sort indicator do not fit, including with wider system or configured fonts. Configured column widths and 44px touch menu targets remain effective. Active sorts display their direction arrow; unsorted headings remain labeled sort buttons. Relative-date preset menus scroll within the space available beside their trigger.
Selection and display-density menus support arrow-key navigation, Enter to choose, and Escape to close and return focus to the trigger. They stay within the viewport on phone and tablet. Density choices expose radio-menu semantics; the selected option uses the theme primary/foreground color pair.
Record action menus come from `QTableMetaData.menus`. The first `VIEW_SCREEN_ACTIONS` menu replaces the default Actions menu; `VIEW_SCREEN_ADDITIONAL` menus add ordered menu buttons. Item types are `BUILT_IN`, `RUN_PROCESS`, `DOWNLOAD_FILE`, `SUB_MENU`, `SUB_LIST`, and `DIVIDER`. Their `values` supply `option`, `processName`, `fieldName`, or nested `items`, respectively. Built-in options are `NEW`, `COPY`, `EDIT`, `DELETE`, `DEVELOPER_MODE`, `AUDIT`, `THIS_TABLE_PROCESS_LIST`, and `ALL_TABLES_PROCESS_LIST`; permissions filter available operations. See [`record-menu-utils.ts`](../../src/lib/utils/record-menu-utils.ts) for resolution and process deduplication.
For a joined field in a record section, list its qualified name (`person.firstName`) in `QTableSection.fieldNames` and expose a readable join through `QTableMetaData.exposedJoins`. Next asks for that join on the record GET and uses the joined table's field metadata to render the qualified response value. Join paths through tables without read permission are omitted. [REC-064](../acceptance/feature-matrix.md) checks the real sample server in five browser profiles.
## 6. Processes and reports
`QProcessMetaData` declares the process name, label, table association, permission, limits on input records, and ordered `frontendSteps`. The wizard initializes a run, submits each step, polls asynchronous jobs, handles validation results, and cancels or retries as appropriate ([`ProcessRun.tsx`](../../src/components/process/ProcessRun.tsx), [`processes.ts`](../../src/lib/api/processes.ts)). It preserves return navigation to its caller. `QFrontendComponent.type` selects a process component. The currently registered types are:
| Type | Purpose |
|---|---|
| `HELP_TEXT`, `HTML` | Guidance and sanitized markup. |
| `EDIT_FORM`, `VIEW_FORM` | Metadata-driven writable and read-only fields. |
| `RECORD_LIST`, `PROCESS_SUMMARY_RESULTS` | Input/output records and completion summary. |
| `BULK_EDIT_FORM` | Changes applied to selected records. |
| `BULK_LOAD_FILE_MAPPING_FORM`, `BULK_LOAD_VALUE_MAPPING_FORM`, `BULK_LOAD_PROFILE_FORM` | File preview; flat/tall/wide layout and header-row choices; field-to-column/default mappings; per-value translations; saved load/edit profiles. |
| `VALIDATION_REVIEW_SCREEN` | Review validation findings before commit. |
| `DOWNLOAD_FORM` | Download parameters and result. |
| `GOOGLE_DRIVE_SELECT_FOLDER` | Selects one folder from a shared drive using Google OAuth. Requires `GOOGLE_APP_CLIENT_ID` and `GOOGLE_APP_API_KEY` in instance environment values. Submits `googleDriveAccessToken`, `googleDriveFolderId`, and `googleDriveFolderName`; cancellation clears them. |
| `WIDGET` | Embedded connected widget. |
Do not invent `values` keys for these components: their shapes vary by backend process implementation. Start with the QQQ producer and a [real fixture](../../tests/acceptance/fixture), then verify the versioned response and end-to-end step submission. A `WIDGET` step uses the named widget's full dashboard controls; an editable `childRecordList` contributes `frontendRecords`, while an editable `rowBuilder` writes its configured output field. Ad hoc composite blocks share the dashboard block renderer, including modal control codes, typed inputs and layouts. A dashboard widget of type `process` embeds the wizard without its stepper or Cancel action; Return starts a fresh run in place. For bulk load, test duplicate headers, required fields, saved profiles, value mappings, validation review, and the committed records. Reports appear in `QInstance.reports`. [`ReportRun.tsx`](../../src/components/reports/ReportRun.tsx) offers CSV, XLSX, and JSON, handles required inputs and asynchronous process-backed reports, and provides a download. A report without a backing process uses the versioned streaming report route. Saved and scheduled report behavior is covered by the report rows of the acceptance matrix.
For bulk load, the mapping screen derives required fields, child associations, layout and suggested mappings from the backend's table structure and process values. Its Add Fields menu searches the main and child-table groups; WIDE layouts may repeat a child field. A mapped field can read a file column or a typed default, including possible values, dates and booleans. Header-row changes remap by name and warn about duplicates. File and value mapping show the current profile in a bordered Saved Bulk Load Profiles section; the final review shows that status in its profile summary. Saved bulk-load profiles are grouped by owner; owners can save, rename and delete, while shared profiles can be selected but not modified. Open the profile menu with Enter or Arrow Down; use arrow keys or Home/End to move, Enter to choose, and Escape to close. Long lists scroll within the viewport. New, Save As and Rename open an inline Profile Name field inside the profile section. Save Profile or Enter saves the name without advancing the process; Cancel or Escape closes it. Duplicate-name errors stay beside the editor. Closing returns focus to the opening control, or the profile-menu button if that control was replaced after saving. Updates and deletion retain their confirmations. New, Empty Mapping, Suggested Mapping and Reset All Changes affect only the draft until the process step is submitted. The validation preview can show the associated child grids declared by `previewRecordAssociated*` values.
Report output formats come from the backend's `reportFormat` possible values unless a process screen declares its own `reportFormat` field. Report input screens use the same dynamic form renderer as other processes, so declared possible values, Booleans, dates and validation rules apply. Intermediate report process screens render before the download; the completed file can be a server path or storage reference.
Standard report processes with an input form followed by a download use the original compact input card and **Generate Report** action. Cancel remains available beside it. Ordinary fields stack at a 384px maximum width; explicitly configured grid widths and rich editors keep their layout. These inputs retain the shared dynamic form, required validation, possible values and typed submission. Longer or custom report processes retain their step headings and navigation.
## 7. Widgets: placement, configuration, and data
Each app dashboard lists widget **names** in order. The matching `QWidgetMetaData` supplies `name`, `label`, optional `type`, and `hasPermission`. The UI posts to `/qqq/v1/widget/{name}` with current dropdown and record parameters ([`ConnectedWidget.tsx`](../../src/components/widgets/ConnectedWidget.tsx), [`widgets.ts`](../../src/lib/api/widgets.ts)). The widget response contains its data and sometimes its type. [`WidgetRenderer.tsx`](../../src/components/widgets/WidgetRenderer.tsx) uses `metadata.type` first, falling back to `payload.type`; it contains errors and unknown types within the widget.
A valid TypeScript metadata example, suitable for an instance metadata fixture (the backend should produce the equivalent JSON), is:
```ts
import type { QWidgetMetaData } from '@/types'
const ordersToday = {
name: 'ordersToday',
label: 'Orders today',
type: 'statistics',
hasPermission: true,
gridColumns: 4,
isCard: true,
showReloadButton: true,
showExportButton: true,
tooltip: 'Orders created since midnight',
icon: 'inventory_2',
collapsible: { isCollapsible: true, initiallyOpen: true },
dropdowns: [{ name: 'region', label: 'Region', possibleValueSourceName: 'regions', type: 'POSSIBLE_VALUE_SOURCE' }],
storeDropdownSelections: true,
} satisfies QWidgetMetaData
```
`gridColumns` spans 1–12 twelfths on large screens (default 12); smaller screens stack widgets unless explicit breakpoint overrides are supplied in `defaultValues` (see the configuration recipes below). `isCard: false` removes card chrome. `icon` supplies the main header glyph using the Next outline presentation. `collapsible.isCollapsible` enables a header toggle, starts according to `initiallyOpen`, and remembers state under `qqq.widget.collapsibleOpenState.<name>`. `minHeight`, `footerHTML`, help content, `icons.topLeftInsideCard`/`topRightInsideCard`, tooltip, reload, and export customize the frame. Payload `label`, `sublabel`, and `footerHTML` can override or add frame text; a parent payload's `isLabelPageTitle` promotes the label to a page title. HTML is sanitized. For export, provide `csvData` as rows of cells; a table payload may also export `columns` and `rows`. The dropdown controls are declared in metadata **and populated by parallel payload lists** (`dropdownNameList`, `dropdownLabelList`, `dropdownDataList`, `dropdownDefaultValueList`). Controls offer searchable choices or a native date input; metadata can set `width`, `startIconName`, `allowBackAndForth`, `backAndForthInverted`, and `disableClearable`. The selected ID becomes a request parameter; a possible-value source uses its source name. Required selection can delay content, and `storeDropdownSelections` persists choices at `qqq.widgets.dropdownData.<widget>.<param>`. See [WID-040–WID-050](../acceptance/feature-matrix.md) for the tested contract.
Choice dropdowns support search and keyboard selection. A `DATE_PICKER` uses the platform calendar and a Today action; set `allowBackAndForth` for previous/next day buttons. If the `timeframe` dropdown offers `custom`, selecting it opens start and end inputs; after both are entered, the UI sends `custom,<UTC start>,<UTC end>`.
For example, a canonical QQQ statistics response uses `count`, not the demo fixture's `value`:
```json
{ "type": "statistics", "count": 42, "countContext": "open orders", "percentageAmount": 12.5, "percentageLabel": "vs previous week", "increaseIsGood": true }
```
The catalog below lists **every explicit dispatch type** in `WidgetRenderer.tsx`. Payload fields are from the corresponding renderer; they are examples of what the backend emits, not a replacement for the QQQ Java renderer API. When `type` is `chart` or a chart variant, canonical `chartData.labels` and `chartData.datasets` select the QQQ chart path. A payload with `chartType`, `seriesData`, or demo `data` instead uses the legacy/demo chart path.
### Canonical QQQ display widgets
| `type` | Use and important response fields |
|---|---|
| `alert` | Status message: `html`, `alertType`, optional `bulletList` and `hideWidget`. A demo `message` payload uses the demo alert renderer. |
| `divider` | Horizontal separator, optionally `label`; rendered without normal card chrome. |
| `fieldValueList` | Labeled record fields: `fields`, `record.values`, optional `record.displayValues`, prefix icon maps, indentation map. Display values win. |
| `generic` | Common widget frame fields, especially `sublabel` and sanitized `footerHTML`; optional `html` payload. |
| `html` | Sanitized `html` string. A demo payload with `blocks` uses the demo block renderer. |
| `location` | `imageUrl`, `title`, `description`, `location`, `footerText`. |
| `quickSightChart` | Provider embed `url` in an iframe; supply a server-authorized URL and appropriate CSP. Real AWS account coverage is an approved external exclusion. |
| `stepper` | `steps` with labels and optional links/icons, plus zero-based `activeStep` and optional `title`. |
| `usaMap` | `mapMarkerList` of names and latitude/longitude; optional CSS `height`. |
### Canonical QQQ charts, statistics, and tables
| `type` | Use and important response fields |
|---|---|
| `barChart` | Vertical bars from `chartData.labels` and `chartData.datasets`; dataset `data`, colors, and URLs. |
| `horizontalBarChart` | Horizontal bars; supports negative values relative to zero. |
| `stackedBarChart` | Multiple datasets stacked for each label. |
| `lineChart` | Line points from labels and datasets. |
| `smallLineChart` | Compact line presentation with `title` and optional sanitized `description`. |
| `pieChart` | Colored slices and legend; optional `chartSubheaderData`. |
| `chart` | Generic canonical chart, displayed as a bar variant when `chartData` is used; can be inferred from payload type. |
| `statistics` | Headline `count`, optional `countContext`, `percentageAmount`, `increaseIsGood`, and count/percentage links. Demo `value` payload uses the demo statistic renderer. |
| `multiStatistics` | `statisticsGroupData`: groups with `header`, `subheader`, icon, and statistic list. |
| `table` | `columns` (`header`, `accessor`, alignment, optional help/width) and `rows`; typed number, HTML, tooltip, image, composite/block cells; hidden helper columns, expandable subrows, optional `noRowsFoundHTML`, entries-per-page, sticky header and last-row behavior. |
| `multiTable` | `tableDataList` of table payloads, each with its own label, rows and export action. |
### Composite and interactive widgets
| `type` | Use and important response fields |
|---|---|
| `composite` | QQQ block tree: `blocks`/`blockTypeName`, layout, values, links, tooltips and styles. Without those fields, demo `childWidgets` uses `CompositeWidget`. |
| `parentWidget` | Server response `childWidgetNameList`; renders permitted registered children in a 12-column grid or `layoutType: "TABS"`. Children fetch their own data. |
| `process` | Inline wizard using `processMetaData`, optional `defaultValues` and selected record IDs. Server permission still applies. |
| `childRecordList` | Joined child records from `queryOutput.records` and `childFrontendTableMetaData`/`childTableMetaData`; `includeExposedJoinTables` adds readable join columns, and `defaultValuesForNewChildRecords` hides parent-key columns on screen. Optional `viewAllLink`, add-child defaults, row limits, `disableRowClick`, and CSV export via `showExportButton`. On a record form, `defaultValues.manageAssociationName` enables association editing. |
| `customComponent` | Loads a trusted bundle from metadata `defaultValues.componentSourceUrl` and `componentName`, then resolves `window[componentName][componentName]`. The component receives widget metadata/data/record, `qContext`, and `qfmdBridge`; `window.React`/`window.ReactDOM` are exposed. Check CSP and test the actual older bundle before claiming compatibility. |
| `cronUI` | Record view displays the expression, time zone and backend `cronDescription`. Put the widget in a table section and set `defaultValues.includeOnRecordEditScreen: true`, `cronExpressionFieldName`, and `timeZoneFieldName` to edit the named record fields on create/edit forms. The expression editor offers Basic days/hours/minutes, Advanced Quartz input, Clear, live description and validation. Give the time-zone field a possible-value source to render a select. |
| `dynamicForm` | Labeled `fieldList` and `recordOfFieldValues`, including merged JSON field values. Editable in a form/process host when `defaultValues.isEditable` allows it; fields join host validation and submission. In a record form, `mergedDynamicFormValuesIntoFieldName` names the declared JSON table field that stores the values. Put that field in a hidden section, so QQQ still returns it to the widget renderer; a field marked `isHidden` is omitted from normal record reads. The renderer should return current values in `recordOfFieldValues` for reloads. |
| `dataBagViewer` | Data bag/version browser with raw/preview tabs and version editor when `storeDataBagVersion` is available; uses `queryParams.id` or record ID and the built-in `dataBag` and `dataBagVersion` tables. |
| `pivotTableSetup` | Record view summarizes saved `pivotTableJson` (`rows`, `columns`, `values`). In a form, a switch and modal edit its definition after table/`columnsJson` fields are available. OK changes the form draft; Save persists it. Turning the switch off clears the stored definition when the host form is saved. |
| `filterAndColumnsSetup` | Record view summarizes saved filter, sort and columns JSON and shows a paged, sortable, resizable preview unless `hidePreview` is set. A host form offers Edit, + Add Filters and + Add Columns controls, then opens an editor for nested filters, multiple sort levels, visible/ordered columns and a draft preview; `OK` writes JSON into the host form, `Cancel` discards the draft. When payload `allowVariables` is true, a criterion value can become a `FilterVariableExpression`; its `${VARIABLE}` chip is saved in `queryFilterJson`, and preview and the opened query page wait for a value. Saving the host record or advancing a process persists the JSON. A `WIDGET` field adornment can host the setup in a process or row-builder field; its `widgetName` names the widget and only that field receives its output. `overrideIsEditable: false` keeps it read-only. |
| `rowBuilder` | Payload `records`, with columns from `defaultValues.frontendFields` or `fields`. Record view/dashboard is read-only; a form/process host can add, edit, delete and reorder rows when the metadata's edit flags allow it. |
| `scriptViewer` | Script revisions, files, logs, test runner, docs and editor; uses `queryParams.id` or record ID and the built-in script tables. |
| `ESB_OVERVIEW` | ESB destination/trigger overview; fetches its own ESB API data and does not use the ordinary widget payload for content. Requires the ESB backend module. |
For `scriptViewer`, pass a script ID through `queryParams.id` or host it on that script's record. The built-in script and script-type tables supply revisions, schema-ordered files, docs and logs. Register `storeScriptRevision` to expose editing and `testScript` to expose the test panel. The editor supports per-file syntax coloring, split panes, API name/version selection when those fields exist, and a guard for unsaved changes. Local suggestions include QQQ API/logger helpers, language keywords and identifiers in the current file. Type a prefix or press Ctrl+Space, use arrows to choose and Enter/Tab to insert, or click/tap a suggestion. Escape dismisses the list; Escape followed by Tab leaves the editor. Completion supports native undo/redo; the browser controls how consecutive edits are grouped. Suggestions do not validate or execute code.
QQQ composite block types rendered by [`QqqBlocks.tsx`](../../src/components/widgets/blocks/QqqBlocks.tsx) include `TEXT`, `BIG_NUMBER`, `UP_OR_DOWN_NUMBER`, `NUMBER_ICON_BADGE`, `ICON`, `TABLE_SUB_ROW_DETAIL_ROW`, `PROGRESS_BAR`, `DIVIDER`, `IMAGE`, `AUDIO`, `INPUT_FIELD`, and `BUTTON`. Supported layouts include `FLEX_COLUMN`, `FLEX_ROW`, `FLEX_ROW_WRAPPED`, `FLEX_ROW_SPACE_BETWEEN`, `FLEX_ROW_CENTER`, `TABLE_SUB_ROW_DETAILS`, and `BADGES_WRAPPER`. Buttons that submit process actions and standalone input submissions need a host callback. Modal control codes also work locally on dashboard composites. Unknown block types show a contained warning. See [WID-057–WID-059](../acceptance/feature-matrix.md).
`INPUT_FIELD` blocks read `values.fieldMetaData`, optional `value`, `placeholder`, `autoFocus`, and `submitOnEnter`. Ordinary scalar inputs retain the compact block controls; the backend's neutral `%s` display format does not change their presentation. Possible-value sources, explicit formatting/behaviors, and `CODE_EDITOR`, `FILE_UPLOAD`, or `WIDGET` adornments use the shared Next field editors. Hidden fields are omitted and read-only fields cannot be changed. Standalone metadata editors retain a 14rem minimum width so short choice labels do not collapse the control. For `STRING`, `TEXT`, and explicitly configured `PASSWORD` blocks, `fieldMetaData.behaviors` accepts `TO_UPPER_CASE` or `TO_LOWER_CASE`: text changes case while typing and the caret remains at the edit position, including conversions that expand characters such as `ß` to `SS`. Selection boundaries and direction are preserved. For single-line inputs, process Enter and Submit both send the transformed value; standalone Enter requires `submitOnEnter` and a host callback. Multiline `TEXT` fields preserve newlines: Enter edits the text even when `submitOnEnter` is true, and the process Submit button sends the transformed value. Password inputs remain masked by default; showing or hiding the value does not change it. Passwords without a case behavior retain their entered case. The behavior names come from backend metadata, so do not apply a second conversion in an action callback.
Numeric block controls in RC4 blur on mouse-wheel scrolling to preserve the entered value, matching shared numeric form fields. Clicking back into the control allows normal typing and arrow-key edits. Text controls keep focus when scrolling. This correction is included in published RC4 (`3c409f9`); RC3 does not contain it.
In a process, block editors use the screen's form, validation and submission. A BLOB or FILE_UPLOAD field sends the selected `File` as multipart content, and required files block submission until chosen. Named choices on block-only fields search their standalone possible-value source; declared process fields use the process source. WIDGET fields receive surrounding process values (for example `tableName`) merged with current form values; editing writes only the adorned field. Scalar Enter submits only when `submitOnEnter` is true; `->code` submits an action code. Multiline code, choice searches and file controls own their keyboard interactions. Standalone blocks keep their draft locally and send scalar Enter values through their action callback. PRC-062 and WID-074 cover the process and standalone fixture paths. Enter trims text before form validation and submission; required whitespace cannot advance. Process initial values come from the process state, while standalone values come from the block. Configured autofocus takes precedence over heading focus on subsequent process screens, and the first blur does not display a premature required error. PRC-063 verifies this configuration across screen transitions and checks the actual stored text.
Ordinary record edits omit unchanged timestamps. Base Copy and both child-row editor hosts resubmit complete values, so they retain each unchanged timestamp's source instant and fractional seconds. REC-065 verifies copy persistence during the repeated daylight-saving hour; component regressions cover association and in-process child rows.
`DATE_TIME` process fields and input blocks display instants in the browser's local timezone, retain seconds, and send edited times as zoned UTC values. Unchanged values retain their original instant and subsecond precision, including a repeated daylight-saving hour. `DATE` and `TIME` remain calendar and wall-clock values. PRC-064 checks seeded and edited values across plain blocks, metadata editors and regular process forms against backend SQL; WID-075 checks standalone display.
The filter/column editor reads the payload's `tableName` or the form's `tableName`, then loads that table's metadata. It writes to `queryFilterJson` and `columnsJson` by default; `filterFieldName` and `columnFieldName`/`columnsFieldName` override those keys. `hidePreview` controls the read-only summary preview. Starting in RC5, the editing dialog retains its query grid even when that summary is hidden, matching Material behavior; RC4 incorrectly hides both. Payload flags `hideColumns`, `hideSortBy`, `allowVariables`, `isApiVersioned`, `apiName`/`apiPath`/`apiVersion`, `filterDefaultFieldNames`, `filterDefaultFieldNameSourceFieldNames`, `omitExposedJoins`, `editButtonLabel`, and `modalHeader` affect the editor. It waits for a selected table and required default-field values. The preview supports server-side paging, sorting, column resizing, Refresh, column configuration, and column-menu actions (filter, hide, pin, copy page/full-query values, and statistics when permitted). The editor also offers density, Basic quick filters, Advanced conditions, and selection of an existing saved view. Selecting a view imports its filter and columns into the draft; it does not modify that saved view. Column widths and pins survive host Save. Unresolved variables suppress the preview; assign variables in Advanced mode. Interactions in a read-only record preview are temporary and never update the saved report. Starting in RC4, `omitExposedJoins` limits new filter/column choices in both the read-only preview and editor. Existing saved joined columns, criteria and sorts remain usable, and reordering available columns preserves omitted columns in place. Opening the editor and pressing OK preserves those saved fields; Basic/Advanced changes and host Save/reopen retain their values and widths. This is a choice restriction, not a backend permission boundary; existing joined data still uses the normal query permissions. WID-073 checks the configured and unconfigured cases against SQL, including local edits and reload. `OK` updates the host form's values, so the record must still be saved to persist. [WID-030 and WID-070](../acceptance/feature-matrix.md) cover the summary, preview, Cancel, OK, and persisted host save across five browser profiles.
In the report setup dialog, Left/Right Arrow switches between Filters and sort and Columns; Home/End selects the first/last available tab. Tab enters the selected panel's controls. Switching tabs preserves the draft, and metadata that hides Columns leaves only the filter tab. The tab controls expose `filter-editor-tab-<widget>-filters` and `filter-editor-tab-<widget>-columns` CSS hooks.
Selecting a saved view imports it into the report draft. **Reset Changes** reapplies that view; **Reset to New View** clears filters and custom quick fields, restores default columns, and sorts by the primary key descending when present. Both discard an open quick-filter edit. These actions leave the saved view unchanged. OK applies the draft to the host form; saving the form persists it. Cancel discards the dialog draft.
Complete conditions, including nested groups, mark the corresponding preview column header. Use that indicator to open the existing filter. It adds no condition; editing a read-only preview stays local, and editor changes remain a draft until OK and host Save. Very narrow columns retain the shared grid’s compact header treatment.
When a preview table declares `usesVariants`, select its backend variant before querying. The choice uses the same per-table browser storage as Record Query (`qqq.tableVariant.<table>`). Switching variants starts a fresh preview page; query/count, full-column copy and statistics use the selection. It does not alter the saved report definition. Application-API query/count require the backend fix in [QQQ PR #913](https://github.com/QRun-IO/qqq/pull/913), merged to QQQ develop as `cba758507dcc54cf282a6638ab50a7d3a1904c7c`; verification used the middleware JAR built from its reviewed source. This does not require or announce a QQQ 4.1 release.
### Demo and compatibility dispatch labels
The remaining dispatch labels mainly support local mock fixtures. They are implemented UI paths, but are not a promise that the production QQQ backend emits those shapes. Check [`src/mocks/fixtures/widgets`](../../src/mocks/fixtures/widgets) for demo payloads and the real widget fixtures and matrix for QQQ-backed examples.
| `type` | Use and important response fields |
|---|---|
| `recordGrid` | Demo record grid with `tableName`, `records`, and `columns` or full `fields`; optionally `totalCount`. |
| `quickLinks` | Demo list of labeled navigation links; inspect the fixture's `links` payload before using it. |
| `processSummary` | Demo process status/summary cards; inspect the fixture's status and counts before using it. |
| `block` | Chooses QQQ composite blocks when the payload has `blockTypeName` or `blocks`; otherwise falls back to `BlockWidget`. A demo `blocks` list therefore enters the QQQ composite branch. |
| `parent` | Demo `CompositeWidget` alias using `childWidgets`; canonical QQQ parent widgets use `parentWidget` and `childWidgetNameList`. |
Canonical chart types can also fall through to the demo chart components when the response is not a QQQ `chartData` payload.
### Widget configuration recipes
These examples are payload fragments emitted by an application's backend renderer, with illustrative names and values. Pair them with matching `QWidgetMetaData`; they are not standalone Java producer definitions. Every dispatch label is listed above; the details below cover the shared and specialized configuration that is easy to miss when copying a basic example.
**Responsive placement.** Metadata `defaultValues` accepts `gridCols:sizeClass:xs`, `sm`, `md`, `lg`, `xl`, and `xxl`. Their minimum viewport widths are 0, 576, 768, 992, 1200, and 1400 px. Values are column counts from 1 to 12; numeric strings are accepted. Once any override is set, `xs` defaults to 12 and `xxl` defaults to `gridColumns` or 12. Other declared spans apply until the next declared breakpoint. This example gives a half-width widget from 768 px upward, including 1400 px:
```json
{"gridColumns":6,"defaultValues":{"gridCols:sizeClass:xs":12,"gridCols:sizeClass:md":6}}
```
See [`widgetColumnClasses`](../../src/components/widgets/widget-utils.ts). Parent grids use the same sizing. A `parentWidget` payload such as `{"childWidgetNameList":["totals","trend"],"layoutType":"TABS"}` resolves those names in the instance widget registry. Children inherit parent request parameters and dropdown selections and fetch their own data; tab selection is remembered locally. A child omitted from permitted metadata cannot be configured into existence by listing its name.
**Dropdown data.** Metadata and response lists are paired by position; keep their lengths and order aligned. For the `region` dropdown example above, a response fragment is:
```json
{"dropdownNameList":["regions"],"dropdownLabelList":["Region"],"dropdownDataList":[[{"id":"east","label":"East"},{"id":"west","label":"West"}]],"dropdownDefaultValueList":["east"]}
```
The request parameter is the payload's `dropdownNameList` entry (`regions` here). The backend convention uses the possible-value source name for choice controls and the declared name for date controls. A valid stored choice wins over the response default; unavailable stored choices are cleared. `labelForNullValue` adds an explicit empty option only when the payload has no default. `disableClearable` removes the clear button. To gate the body, return `dropdownNeedsSelectedText` until the required choice is supplied. Metadata `isRequired` and `defaultValue` alone are not consumed as client-side gating/default rules by [`ConnectedWidget`](../../src/components/widgets/ConnectedWidget.tsx). A date control sends local `YYYY-MM-DD` text; the special custom timeframe sends the UTC range described above.
**Charts and statistics.** All seven chart dispatch labels use the canonical shape below. `backgroundColors` and `urls` are arrays aligned with data points; `color` supplies the series color and `backgroundColor` is the stacked-series fallback. Top-level `chartData.urls` supplies category links. `height` is numeric; `isCurrency` or `isYAxisCurrency` enables the renderer's currency formatting.
```json
{"type":"barChart","height":280,"isCurrency":true,"chartData":{"labels":["Open","Closed"],"datasets":[{"label":"Value","data":[1200,800],"backgroundColors":["info","success"],"urls":["/app/orders/","/app/archive/"]}]}}
```
`chartSubheaderData` accepts `mainNumber`, `vsPreviousPercent`, `vsPreviousNumber`, `isUpVsPrevious`, `isGoodVsPrevious`, `vsDescription`, `mainNumberUrl`, and `previousNumberUrl`; use it for the optional comparison summary. See [`QqqChartPayload`](../../src/components/widgets/widget-types.ts). Statistics adds `countFontSize`, `countURL`, `percentageURL`, and `isCurrency` to the basic example; numeric currency counts format as USD, while string counts pass through. A zero or absent percentage hides the change row. Multi-statistics uses this nested shape:
```json
{"type":"multiStatistics","statisticsGroupData":[{"header":"Orders","subheader":"Today","icon":"inventory_2","iconColor":"#0062ff","statisticList":[{"label":"Open","value":7,"url":"/app/orders/"}]}]}
```
**Tables.** Each `table` payload (and each member of `multiTable.tableDataList`) uses the same configuration. `rowsPerPage` defaults to 10; `hidePaginationDropdown` hides its size selector, `fixedHeight` constrains the scroll area, and `fixedStickyLastRow` fixes the final row. `linkText`/`linkURL` add the table link. Columns accept `type`, `header`, `accessor`, `width`, `align`, and `verticalAlign`; widths can be fixed CSS lengths or `fr` shares.
```json
{"type":"table","rowsPerPage":5,"columns":[{"header":"Name","accessor":"name","width":"2fr"},{"header":"Count","accessor":"count","width":"1fr"},{"accessor":"tooltip","type":"hidden"}],"rows":[{"name":"Open","count":7,"tooltip":"Current backlog","subRows":[{"name":"Priority","count":2}]}]}
```
Cell `type` is `default`, `html`, `htmlAndTooltip`, `composite`, `block`, `image`, or `hidden`. An `htmlAndTooltip` cell reads the row's `tooltip`; `image` reads `imageUrl`, `imageLabel`, `imageTotal`, and `imageTotalType`. Composite/block cells read a block payload at the column accessor. Hidden helper columns do not display but remain available to cells and export. `subRows` provides expandable rows. See [`QqqTablePayload`](../../src/components/widgets/QqqTableWidget.tsx) and [`TableWidgetColumn`](../../src/components/widgets/table-widget-utils.ts).
**Display and editor details.** These keys supplement each catalog row; they are read by the linked implementations.
| Widget | Additional configuration |
|---|---|
| `stepper` | Each step accepts `label`, `linkText`, `linkURL`, `iconOverride`, and `colorOverride`; only the active step shows its action link. |
| `fieldValueList` | Use `fieldLabelPrefixIconNames`, `fieldLabelPrefixIconColors`, and `fieldIndentLevels`, keyed by field name. Fields use the shared record formatter, including declared formats/adornments and possible-value record links. |
| `alert` | `alertType` supports ERROR, WARNING, SUCCESS and INFO; absent/unknown values use INFO. `bulletList` contains sanitized HTML strings. Empty `html` renders no alert body. |
| `usaMap` | `mapMarkerList` entries use `name`, `latitude`, and `longitude`; the UI includes the US basemap and an accessible location list. |
| `html`, `generic`, `divider`, `location`, `quickSightChart` | Use the fields in the catalog and shared chrome; these types do not expose host-form editing. QuickSight's optional payload `label` names the iframe. |
| `childRecordList` | `omitFieldNames` and `onlyIncludeFieldNames` control columns. `canAddChildRecord` enables Add; `disabledFieldsForNewChildRecords` locks named defaults, and `defaultValuesForNewChildRecordsFromParentFields` maps child field names to parent field names. `totalRows` supplies the total. A process host uses `isInProcess`, `allowRecordEdit`, and `allowRecordDelete` to control in-memory rows. |
| `rowBuilder` | Metadata defaults include `frontendFields` (fallback `fields`), `associationName`/`outputFieldName`, `orderByFieldName`, `mayReorderRows`, `isForRecordViewAndEditScreen`, `isEditable`, `useModalEditor`, `inlineHeading`, `modalTitle`, and `defaultValuesForNewRowsFromParentRecord`; see the [agent editor guide](./next-ui-agent-guide.md#widget-editors-and-host-forms). |
| `process` | `processMetaData.name` selects the process; payload `defaultValues` presets its inputs. Reloading the widget reinitializes the embedded run. |
| `cronUI`, `dynamicForm`, `pivotTableSetup`, `filterAndColumnsSetup` | These edit host fields through their documented bindings above; changing the widget draft alone does not save a record. Use the shared form context and inspect each editor's validation before introducing a new field binding. |
| `dataBagViewer`, `scriptViewer`, `ESB_OVERVIEW`, `customComponent` | These require their respective backend module/process or trusted bundle described above; ordinary display payloads cannot replace those dependencies. |
| `recordGrid`, `quickLinks`, `processSummary`, `parent`, demo `block` | Use the [demo fixtures](../../src/mocks/fixtures/widgets) and matching renderer for the demo contract. These labels do not imply an equivalent canonical Java renderer. |
**Composite blocks.** `link` and `tooltip` apply to the block; `linkMap` and `tooltipMap` apply to named slots, such as `NUMBER`. Links carry `href` and optional `target`; tooltips carry `title`, `placement`, or nested `blockData`. Help slots can provide fallback tooltip text. `styles` configures the leaf block, while composites also accept `styleOverrides`, sanitized `overlayHtml`, and `overlayStyleOverrides`.
| `blockTypeName` | Values and styles to supply |
|---|---|
| `TEXT` | `values.text`, optional `startIcon`/`endIcon`; `styles.color`, `format`, `size`, `weight`. |
| `NUMBER_ICON_BADGE` | `values.number`, `iconName`. |
| `UP_OR_DOWN_NUMBER` | `values.number`, `context`, `isUp`, `isGood`; `styles.colorOverride`, `isStacked`. |
| `TABLE_SUB_ROW_DETAIL_ROW` | `values.label`, `value`; `styles.labelColor`, `valueColor`. |
| `PROGRESS_BAR` | `values.percent`, `heading`, `value`; `styles.barColor`. |
| `BIG_NUMBER` | `values.heading`, `number`, `context`; `styles.width`, `numberColor`. |
| `DIVIDER` | No values required. |
| `IMAGE` | `values.path`, `alt`; `styles.width`, `height`, `bordered`. |
| `AUDIO` | `values.path`, `showControls`, `autoPlay`; playback still follows browser policy. |
| `ICON` | `values.name`; `styles.color`, `fontSize`. |
| `BUTTON` | `values.label`, `actionCode` or `controlCode`, optional `startIcon`/`endIcon`; `styles.format` is filled, outlined or text. |
| `INPUT_FIELD` | `values.fieldMetaData`, `value`, `placeholder`, `autoFocus`, `submitOnEnter`; host behavior is detailed above. |
| `COMPOSITE` | Nested `blocks`, one of the documented `layout` values, and optional `blockId`/`modalMode`. |
A host evaluates `conditional` as the truthiness of the named host value and interpolates `${name}` in text; it is not a JavaScript expression. Dashboard composites without a process host do not evaluate those conditions. Modal buttons use `showModal:<blockId>`, `hideModal:<blockId>`, or `toggleModal:<blockId>` control codes. A nonempty `modalMode` marks the target composite; a host value with that block ID set to literal `true` opens it initially. This local dashboard example needs no process submission callback:
```json
{"type":"composite","blocks":[{"blockTypeName":"BUTTON","values":{"label":"Details","controlCode":"showModal:details"}},{"blockTypeName":"COMPOSITE","blockId":"details","modalMode":"dialog","blocks":[{"blockTypeName":"TEXT","values":{"text":"Details appear here."}}]}]}
```
See [`QqqComposite`](../../src/components/widgets/blocks/QqqComposite.tsx), [`QqqBlocks`](../../src/components/widgets/blocks/QqqBlocks.tsx), and [`BlockSlot`](../../src/components/widgets/blocks/BlockSlot.tsx). RC6 icon compatibility preserves existing Lucide mappings and adds a locally served glyph for valid unmapped names from the pinned 2,234-name legacy Material Icons inventory. Examples include `3d_rotation`, `account_balance_wallet`, `battery_6_bar`, and `60fps`. Unknown names retain their fallback; this does not include every Material Symbols name or distinct outlined/rounded font variants. Metadata image paths still take precedence. The font and Apache license ship with the static export/JAR; no Google Fonts request is needed at runtime. See the [font provenance](../../public/fonts/material-icons/README.md). This behavior first ships in RC6. The parity ledger also retains open INPUT_FIELD configuration and visual review work.
## 8. Theme, branding, and customization
`QInstance.branding` supplies app/company name, logo/icon, accent colors, banners and optional `gravatarDefault`. A company footer appears when both `companyName` and a safe HTTP(S) `companyUrl` are present. The active application theme is `QInstance.supplementalInstanceMetaData.materialDashboardTheme`, read by [`material-theme.ts`](../../src/lib/theme/material-theme.ts); do not configure `QInstance.theme` and expect these styles to apply. Its optional fields cover palette, fonts, typography, radii, density, logo/icon/favicon, icon style, branded header, app bar, sidebar, table colors, and `customCss`. Invalid theme values are ignored. For example:
```json
{"supplementalInstanceMetaData":{"materialDashboardTheme":{"primaryColor":"#245ca6","fontFamily":"Inter, sans-serif","borderRadiusCard":"12px","brandedHeaderEnabled":true,"brandedHeaderTagline":"Operations","customCss":"[data-qqq-id='sidenav-root'] { border-right: 1px solid #ccc; }"}}}
```
[`apply-branding.ts`](../../src/lib/theme/apply-branding.ts), [`material-theme.ts`](../../src/lib/theme/material-theme.ts), [`tokens.ts`](../../src/lib/theme/tokens.ts), and [`qqq-theme.css`](../../src/styles/qqq-theme.css) define the CSS mapping, including supported Material `--qqq-*` variables. Scope custom CSS to observed `data-qqq-id` attributes, and inspect actual rendering and contrast before migrating a Material stylesheet. A declared application theme sets the app look and keeps light mode active. Without an application theme, the local display-mode preference applies; a fresh profile starts in light mode regardless of the operating system preference. Density is available through theme metadata and local preferences. Open the sidebar user menu → Preferences → Appearance to choose Light or Dark. Changes apply immediately and persist in `localStorage` as `qqq-dark-mode` for this browser profile and origin (scheme, host and port), not the signed-in account; there is no cross-device sync or operating-system mode. Clearing site data removes the preference. When an application theme is active, the choices are disabled with an explanation and the saved preference is retained. Reset to Defaults resets appearance to light along with table and record defaults.
See the [Material CSS hook guide](../CSS-HOOKS.md) for the ID sanitizer, selector patterns, named hooks, and phone/tablet visibility rules.
Analytics is configured by the backend's `QInstance.environmentValues` ([`src/lib/analytics`](../../src/lib/analytics)). It stays off when no analytics values are published. `ANALYTICS_PROVIDERS` can name Google, PostHog, or registered plugins; provider-specific IDs and enable flags are also required. The default privacy behavior omits record IDs, labels and query strings and does not identify users. Only publish `ANALYTICS_INCLUDE_RECORD_DATA=true` or `ANALYTICS_IDENTIFY_USERS=true` after deciding those disclosures are appropriate for the application.
### Theme property reference
All keys below belong to `supplementalInstanceMetaData.materialDashboardTheme`. Unset properties retain the Next defaults. The executable allow-list and value validation are [`THEME_PROPERTY_KINDS`](../../src/lib/theme/material-theme.ts); [`QThemeMetaData`](../../src/types/metadata.ts) defines their types.
| Area | Keys or key pattern |
|---|---|
| Palette | `primaryColor`, `secondaryColor`, `backgroundColor`, `surfaceColor`, `textPrimary`, `textSecondary`, `errorColor`, `warningColor`, `successColor`, `infoColor`. |
| Action color | `preferInfoColorToPrimaryColor` chooses the info color for the controls that honor that option. |
| Fonts | `fontFamily`, `headerFontFamily`, `monoFontFamily`, `fontSizeBase`, `fontWeightLight`, `fontWeightRegular`, `fontWeightMedium`, `fontWeightBold`. |
| Typography | `typography{variant}{property}`, where variant is `H1`–`H6`, `Body1`, `Body2`, `Button`, or `Caption`, and property is `FontSize`, `FontWeight`, `LineHeight`, `LetterSpacing`, or `TextTransform`. Example: `typographyH2FontSize: "1.5rem"`. |
| Radii | `borderRadiusGlobal`, `borderRadiusScale`, and `borderRadius{component}` for `Button`, `Card`, `Chip`, `Dialog`, `OutlinedInput`, `LinearProgress`, `MenuPaper`, `PaperRounded`, `PopoverPaper`, and `Tooltip`. A component radius overrides the global radius; the scale applies when no global radius is set. |
| Density and icons | `density`: `compact`, `normal`, `comfortable`; `iconStyle`: `filled`, `outlined`, `rounded`, `sharp`, `two-tone`. Unsupported icon names can still fall back to the default glyph. |
| Images and CSS | `logoPath`, `iconPath`, `faviconPath`, `customCss`. |
| Branded header | `brandedHeaderEnabled`, `brandedHeaderBackgroundColor`, `brandedHeaderTextColor`, `brandedHeaderLogoPath`, `brandedHeaderLogoAltText`, `brandedHeaderHeight`, `brandedHeaderTagline`. |
| App bar | `appBarBackgroundColor`, `appBarTextColor`. |
| Sidebar | `sidebarBackgroundColor`, `sidebarTextColor`, `sidebarIconColor`, `sidebarSelectedBackgroundColor`, `sidebarSelectedTextColor`, `sidebarHoverBackgroundColor`, `sidebarDividerColor`. |
| Tables | `tableHeaderBackgroundColor`, `tableHeaderTextColor`, `tableRowHoverColor`, `tableRowSelectedColor`, `tableBorderColor`. |
| Borders | `dividerColor`, `borderColor`, `cardBorderColor`. |
Lengths use CSS length strings; colors use supported named, hex, RGB or HSL values. Font weights must be integers from 1 through 1000; radius scale is 0 through 10. Text transforms are `none`, `uppercase`, `lowercase`, or `capitalize`. Invalid values are ignored rather than inserted into CSS. Application CSS and external assets remain trusted configuration, and their actual appearance/CSP must be checked on the host.
### Help content configuration
Fields and sections expose `helpContents`; widgets expose a slot map in `helpContent`. Entries supply `content`, `format` (`TEXT`, `HTML`, or `MARKDOWN`), and optional `roles`. The shared selector prefers the first entry matching the most specific screen role, then an entry without roles. View roles are `VIEW_SCREEN`, `READ_SCREENS`, `ALL_SCREENS`; query substitutes `QUERY_SCREEN`; edit uses `EDIT_SCREEN`, `WRITE_SCREENS`, `ALL_SCREENS`; create/copy substitutes `INSERT_SCREEN`; processes use `PROCESS_SCREEN`, `ALL_SCREENS`.
For example, widget help can use `{"helpContent":{"label":[{"content":"Current totals","format":"TEXT"}],"columnHeader=count":[{"content":"Matching records","format":"TEXT"}]}}`. Table widget headers use `columnHeader=<accessor>` slots; composite slots use `<blockId>,<slot>` keys. Turn on `?helpHelp` to inspect the exact key at the target control before defining content. See [`help-utils.ts`](../../src/lib/utils/help-utils.ts) and [`widgetSlotHelp`](../../src/components/widgets/widget-utils.ts).
### Analytics configuration example
Publish these values through `QInstance.environmentValues`, not frontend build variables. Google uses `GOOGLE_ANALYTICS_ENABLED="true"` and `GOOGLE_ANALYTICS_TRACKING_ID` (a valid GA4 tracking ID). PostHog uses `POSTHOG_ENABLED="true"`, `POSTHOG_API_KEY` (alias `POSTHOG_PROJECT_API_KEY`), and optional `POSTHOG_HOST`, which defaults to `https://us.i.posthog.com`.
```json
{"environmentValues":{"ANALYTICS_PROVIDERS":"google,posthog","GOOGLE_ANALYTICS_ENABLED":"true","GOOGLE_ANALYTICS_TRACKING_ID":"G-EXAMPLE123","POSTHOG_ENABLED":"true","POSTHOG_PROJECT_API_KEY":"<public-project-key>","ANALYTICS_INCLUDE_RECORD_DATA":"false","ANALYTICS_IDENTIFY_USERS":"false"}}
```
Replace the illustrative IDs with the application's public provider identifiers. `ANALYTICS_PROVIDERS` accepts comma-, semicolon-, or newline-separated names; names are lowercased. If omitted, Google and PostHog are considered, but each still requires its own enable/configuration values. `ANALYTICS_PLUGIN_SCRIPTS` (alias `ANALYTICS_PLUGIN_SCRIPT_URLS`) loads configured provider scripts; those register through `window.QQQAnalytics`. Use the [provider registry](../../src/lib/analytics/registry.ts) and [provider interface](../../src/lib/analytics/types.ts), and configure host CSP for each script/endpoint. Privacy defaults and opt-ins are described above; never place server-side provider secrets in environment values exposed to browsers.
## 9. Accessibility, testing, and known boundaries
Components use labeled fields, keyboard navigation, focus-managed dialogs and drawers, inline errors, contained widget failures, and touch layouts. Below 768 px, queries become record cards and filters/actions use sheets; tablet testing uses a touch WebKit project. Test with keyboard and assistive technology in addition to axe. See [browser matrix](../acceptance/browser-matrix.md) and [accessibility rows](../acceptance/feature-matrix.md).
Run `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm test:e2e`, `pnpm build:export`, and `pnpm perf:budget` for the relevant change. For RC1, prioritize complete Chromium workflows and the full Chromium gate before expanding browser/viewport coverage; verify defaults, validation, save/reopen and persisted results. The final release acceptance gate is `QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet pnpm test:acceptance` with `QQQ_SAMPLE_JAR` pointing at a compatible real sample build. It verifies SQL/backend behavior as well as browser behavior; mocked tests are not a substitute. See [test setup](../../tests/acceptance/README.md), [performance budget](../acceptance/performance.md), and [API error handling](../API-ERROR-HANDLING.md).
Current limits to account for: the Material parity ledger has open Partial/Missing rows beyond the narrower feature matrix; the report filter/column editor supports variables and API-versioned metadata but still needs complete embedded-query equivalence review; a built-in QFMD bridge does not prove every third-party Material bundle works; root-path static hosting is the documented deployment; and Google Picker requires a backend with the #704 metadata/CSP changes (backend PR #908 merged to develop as `bac663044`). Real Auth0 tenant, Google account, and AWS QuickSight service tests have approved external-service exclusions in the feature matrix. Validate any additional feature against the current branch, backend contract, and matrix before describing it as production-compatible.
### Google Drive process configuration
This implementation is part of work after the immutable RC1 release. RC1 itself retains the disabled picker placeholder; the final 1.0 implementation requires the matching backend changes below.
Set the public browser OAuth client ID and browser API key as `GOOGLE_APP_CLIENT_ID`
and `GOOGLE_APP_API_KEY` in the QInstance environment (normally `QQQ_ENV_` environment
variables). Enable the Google Picker and Drive APIs and configure the application's
JavaScript origin in Google Cloud. Restrict the API key to the required APIs and website
origins, including `https://docs.google.com/*` as required by the
[Google Picker setup guide](https://developers.google.com/workspace/drive/picker/guides/web-picker).
Client secrets are not browser configuration and are not published in v1 metadata.
The Next UI middleware must include the #704 changes (backend PR #908, merged to develop as `bac663044`) that publish these two settings
and permit Google's SDK and picker origins in CSP only when both are configured.
Those conditional headers apply to the Javalin-hosted static export. The standalone
Node/container host must receive `QQQ_DASHBOARD_CSP_SOURCES` at runtime; it does not derive
Google allowances from instance metadata. Merge these sources with any other deployment
additions and start through [`standalone/qqq-server.mjs`](../../standalone/qqq-server.mjs):
```bash
export QQQ_DASHBOARD_CSP_SOURCES='script-src https://accounts.google.com/gsi/client https://apis.google.com; connect-src https://accounts.google.com/gsi/; frame-src https://accounts.google.com/gsi/ https://docs.google.com; style-src https://accounts.google.com/gsi/style'
```
This matches the #704 backend directive additions. The standalone parser adds these to
its base policy; custom reverse proxies/hosts must supply equivalent allowances. See
[`security-headers.mjs`](../../standalone/security-headers.mjs). Keep the metadata client
ID/API key configured too: CSP permissions alone do not enable the picker.
The component preserves Material's
shared-drive folder view and `drive` OAuth scope. It loads the official SDKs before the
button becomes ready, supports retry after loading or authorization errors, rejects files,
and requires selection again if authorization expires before submission. Tokens remain
in screen memory and are submitted to the backend process; they are not persisted in
localStorage or sessionStorage. Without configuration the picker stays disabled while
other inputs remain usable. PRC-065 verifies the browser/backend contract with controlled
Google SDK responses; PRC-039 retains the approved real-account testing exclusion.
### Post-RC4 table appearance correction
RC5 restores the original Next uniform row background; RC4 has alternating row shading. Selection highlighting and opaque pinned cells are retained. Applications can target `data-row-parity="odd"` or `"even"` through their configured CSS if they explicitly want alternating colors.
### API playground theme integration (RC7)
The embedded RapiDoc viewer uses its own shadow DOM. Next applies existing dashboard color tokens to its dark accent/inverse pair and light contact/status colors, preserving the surrounding layout. Preferences theme changes apply without reloading the page. Focused Chromium desktop/mobile checks cover the fixture's visible text, hovered controls and live theme changes; expanded endpoint states, arbitrary custom palettes and narrow-screen inner layout still require review. This correction is not included in RC6.
### TIME block correction (RC7)
Plain process and standalone TIME blocks now use a one-second native input step, matching regular/shared form fields. This permits second-level clock values without a minute-step validation error and makes native stepping advance one second. TIME values remain local clock strings, with no timezone conversion. This correction is published in RC7 at `f65cf6b`; earlier RCs remain unchanged.
### Narrow API controls and LONG validation (RC8)
The API specification Download and View actions fit narrow panels, including 320px phones, and wrap their labels when necessary. Their download/new-tab behavior is preserved. On wide panels, buttons grow to fit the rendered font instead of clipping labels at a fixed width. This correction is not included in RC7.
When the frontend receives LONG range bounds, invalid integer text produces a field error before range comparisons run. Integer-string bounds retain exact precision beyond JavaScript's safe-number range; optional blanks and required-field messages retain their existing behavior. This describes the frontend schema's supported inputs, not a new backend metadata contract. RC8 includes this correction; RC7 does not.
+6
View File
@@ -0,0 +1,6 @@
# qqq-frontend-next
This Maven artifact contains the static export of the QQQ Next dashboard under
`next-dashboard/`. It has no Java sources or API documentation; the source code is at
https://github.com/QRun-IO/qqq-frontend-next. Add the jar to a QQQ application and
`QApplicationJavalinServer` serves the dashboard at `/`.
@@ -0,0 +1,26 @@
# API playground controls in narrow panels — GH-724
## Requirement
Keep the original Next developer view and make its API specification actions fully visible and usable in narrow panels, including 320px phones. Preserve the wide desktop layout, real spec download/new-tab actions, light/dark themes and keyboard access.
## Evidence and implementation
- The packaged RC7 JAR fails the new real-backend visibility test: the Download button is partially clipped before any focus/click can horizontally scroll hidden ancestors. The View button extends outside the panel too.
- RapiDoc 9.3.8 has a 360px host minimum, a negative overview info margin and two fixed-width, non-wrapping buttons.
- Allow the host to fit its panel. Use the public overview/button CSS parts under a panel-width container query to provide safe inset and share available button width with wrapped labels. A named overview container limits button adjustments to specification actions, excluding authentication and endpoint controls. No dependency changes or shadow DOM mutation.
## Verification plan
1. Retain the failing Chromium phone trace and screenshot.
2. Run light/dark, normal-width and 320px workflows with desktop and touch input. Check complete visibility before interaction, download the real OpenAPI JSON, and open the same spec using keyboard navigation.
3. Inspect screenshots and compare wide desktop before/after layout.
4. Run existing API contrast/theme workflows, unit suite, types, lint, export and bundle budgets. Obtain independent review before integration.
Evidence: original checkout `test-results/visual-review/api-playground-width/`. This follow-up is separate from frozen RC7 candidate `f65cf6b`.
## Results
The final production export passed 12 focused desktop/mobile workflows, with zero failures, skips, flaky outcomes or report errors. All 1,904 unit tests, types, lint/license checks and the default Turbopack export passed. The unchanged bundle budget passed at 963.3KB/1050KB. The 1440px overview/button geometry and entire RapiDoc screenshot are identical to RC7; final phone screenshots were inspected.
Earlier evidence is retained: the first green attempt exposed a relative-versus-absolute URL assertion error; a later run used a running Java server with a replaced export, invalidating its startup asset/CSP registry. The server was restarted against the finished export before the passing run. A Webpack build exceeded the size budget; the release/CI Turbopack pipeline passed without changing budgets. The incompatible old font-response mock was removed from the build command; the final build uses the normal font pipeline.
@@ -0,0 +1,17 @@
# Bounded LONG validation — GH-728
## Defect and correction
Configured LONG ranges exposed two defects in the shared metadata schema: chained Zod refinements called `BigInt` after format validation had already failed, and exact integer-string bounds were rounded through `Number`. Seven new schema cases reproduced exceptions or wrongly accepted values before the fix.
The schema now pipes successfully validated integer text into its range checks. Integer-string bounds remain BigInt values; numeric fractional bounds use native mixed numeric comparisons. Optional blanks, required-field errors, signs, and existing field messages are preserved. No rendering changes or dependencies are introduced.
## Verification
- Seven schema regressions passed after failing on the previous source.
- Block and shared process-form component tests verify invalid fractional text, a below-minimum error, blocked submission, and correction to exact digits beyond the safe-number range.
- An unrelated intermittent unit failure expected a globally unique “Green” label although both view and edit fields display it. Its separate correction targets the read-only value being tested.
- All 1,913 units across 180 files passed; the 95 affected tests, types, lint/license and diff checks passed. Independent review approved both corrections.
- Evidence: original checkout `test-results/visual-review/long-range-validation/`. The initial full-run failure remains retained in `units.log`; the final full run is `units-final.log`.
This follow-up is not included in immutable RC7 `f65cf6b`. Production export and real-backend verification of the next integrated candidate remain required; these results establish schema/component behavior, not a new backend range-metadata contract.
+409
View File
@@ -0,0 +1,409 @@
# Prompt: Add `node_app_*` jobs to kingsrook/qqq-orb
## Context
You are working in the `kingsrook/qqq-orb` CircleCI orb repo at:
`/Users/james.maes/Git.Local/QRun-IO/qqq-orb`
The orb currently supports:
- `mvn_*` — Maven Java libraries
- `mvn_frontend_*` — Maven + bundled frontend
- `node_*` — npm-published TypeScript libraries
You are adding a new project type: **`node_app_*`** — pnpm-based Node.js
applications (Next.js, Vite, etc.) that are _deployed_, not published to npm.
---
## Step 1: Read these files before writing anything
### qqq-orb existing patterns (understand before touching)
```
src/@orb.yml ← orb-level imports (browser-tools, node orbs already imported)
src/executors/default.yml ← Docker executor (cimg/node)
src/jobs/node_test_only.yml ← existing node job structure
src/jobs/node_publish.yml ← existing node publish job
src/commands/node_install_dependencies.yml
src/commands/node_run_tests.yml
src/commands/node_build_package.yml
src/scripts/node_npm_auth.sh ← existing script style/conventions
src/scripts/node_version_commit.sh ← existing script style/conventions
CLAUDE.md ← orb architecture, make commands, linting rules
```
### Munitor reference orb (copy engineering patterns from here)
These files live at `/Users/james.maes/Git.Local/Kof22/Munitor/`. Read them
to understand the patterns you must replicate — **not to copy content**, but
to adopt the same engineering discipline.
```
src/scripts/munitor_helpers.sh ← shared helper library pattern
src/scripts/install_node.sh ← nvm-based node install with BASH_ENV export
src/scripts/npm_install.sh ← lockfile validation before install
src/scripts/npm_test.sh ← JSON test_commands array override pattern
src/scripts/npm_lint.sh ← lint with JSON artifact output
src/scripts/run_e2e_playwright.sh ← Playwright install + JUnit reporter
src/commands/restore_npm_cache.yml ← cache key pattern
src/commands/save_npm_cache.yml
src/jobs/npm_build_and_test.yml ← two-job pipeline with workspace handoff
src/jobs/npm_e2e_test.yml ← E2E job that attaches workspace
```
---
## Step 2: Create a shared helper script
### `src/scripts/qqq_helpers.sh`
Model this on Munitor's `munitor_helpers.sh`. It must provide:
- `qqq_header <step_name>` — prints a banner with step name, date (UTC),
hostname, and current directory. Format to match qqq style (not Munitor branding).
- `qqq_check_tool <command> [version_flag]` — verifies tool is in PATH,
prints its version. Exits 1 with clear error if missing.
- Guard against double-sourcing (`[[ -n "${_QQQ_HELPERS_LOADED:-}" ]] && return 0`)
Sourcing pattern every script must use at the top:
```bash
QQQ_HELPERS="${QQQ_HELPERS:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/qqq_helpers.sh}"
[[ -f "${QQQ_HELPERS}" ]] && source "${QQQ_HELPERS}"
```
With a fallback stub if helpers are missing (same pattern as Munitor):
```bash
elif ! type qqq_header &>/dev/null; then
qqq_header() { echo "=== QQQ: ${1:-unknown} ==="; }
qqq_check_tool() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found"; exit 1; }; }
fi
```
---
## Step 3: Create shell scripts
All scripts: `#!/usr/bin/env bash`, `set -euo pipefail`, source `qqq_helpers.sh`
at top. Clear section headers. Descriptive error messages. Print summaries.
### `src/scripts/node_app_install.sh`
Purpose: Enable pnpm via corepack, validate lockfile exists, install deps.
Environment variables consumed:
- `NODE_PKG_MANAGER` — `pnpm` (default), `npm`, or `yarn`
Logic:
1. Call `qqq_header "node_app_install"`
2. Check tool: node, npm (for corepack)
3. If `NODE_PKG_MANAGER=pnpm`:
- Run `corepack enable`
- Run `corepack prepare pnpm@latest --activate`
- Verify `pnpm --version`
- Validate `pnpm-lock.yaml` exists; exit 1 with clear message if not
- Run `pnpm install --frozen-lockfile`
4. If `NODE_PKG_MANAGER=npm`:
- Validate `package-lock.json` exists
- Print lockfile version + npm version compatibility warning (copy logic from
Munitor's `npm_install.sh`)
- Run `npm ci`
5. If `NODE_PKG_MANAGER=yarn`:
- Validate `yarn.lock` exists
- Run `yarn install --frozen-lockfile`
6. Print count of installed packages
### `src/scripts/node_app_test.sh`
Purpose: Run type check, lint, and unit tests. Each step is individually
skippable. Supports custom test commands via JSON array (replaces default test
runner when provided).
Environment variables consumed:
- `NODE_PKG_MANAGER` — pnpm | npm | yarn (default: pnpm)
- `TYPE_CHECK_SCRIPT` — npm script name, or empty to skip (default: `tsc`)
- `TYPE_CHECK_ARGS` — extra args (default: `--noEmit`)
- `LINT_SCRIPT` — npm script name, or empty to skip (default: `lint`)
- `TEST_SCRIPT` — npm script name (default: `test`)
- `QQQ_TEST_COMMANDS_JSON` — JSON array of shell commands; if set and non-empty,
runs these instead of the default test command (same pattern as Munitor's
`MUNITOR_TEST_COMMANDS_JSON`)
Logic:
1. `qqq_header "node_app_test"`
2. If `TYPE_CHECK_SCRIPT` is non-empty: run `<pkg_manager> run <TYPE_CHECK_SCRIPT> <TYPE_CHECK_ARGS>`
3. If `LINT_SCRIPT` is non-empty: run `<pkg_manager> run <LINT_SCRIPT>`
4. If `QQQ_TEST_COMMANDS_JSON` is set and not `[]`/`null`:
- Parse JSON array using `jq` and run each command in sequence
5. Else: run `<pkg_manager> run <TEST_SCRIPT>`
6. Print pass/fail summary
### `src/scripts/node_app_build.sh`
Purpose: Run the production build. For Next.js this is `pnpm build`. Exits
non-zero on failure with clear output.
Environment variables consumed:
- `NODE_PKG_MANAGER` — default: pnpm
- `BUILD_SCRIPT` — npm script name (default: `build`)
Logic:
1. `qqq_header "node_app_build"`
2. Check pkg manager is available
3. Run `<pkg_manager> run <BUILD_SCRIPT>`
4. Print success with timing
### `src/scripts/node_app_e2e.sh`
Purpose: Install Playwright browsers and run E2E tests. Reports results in
JUnit format for CircleCI test analytics.
Environment variables consumed:
- `NODE_PKG_MANAGER` — default: pnpm
- `E2E_TEST_DIR` — default: `e2e`
- `PLAYWRIGHT_BROWSERS` — comma-separated browsers (default: `chromium`)
Logic:
1. `qqq_header "node_app_e2e"`
2. Install Playwright browsers: `npx playwright install --with-deps <browsers>`
3. Run: `npx playwright test --reporter=junit --output=e2e-results`
- If `E2E_TEST_DIR/playwright.config.ts` exists, pass `--config=<path>`
4. On failure: print "E2E tests failed. See playwright-report artifact." and
re-exit with the original exit code
5. Print pass summary
---
## Step 4: Create commands
### `src/commands/node_app_install.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"` — package manager (pnpm | npm | yarn)
Steps:
1. Restore cache with keys:
- `node-app-v1-{{ checksum "pnpm-lock.yaml" }}-{{ checksum "package-lock.json" }}-{{ checksum "yarn.lock" }}`
- `node-app-v1-`
2. Run `node_app_install.sh` with environment `NODE_PKG_MANAGER: << parameters.pkg_manager >>`
3. Save cache key (same composite key as restore), paths: `node_modules`
### `src/commands/node_app_test.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"`
- `type_check_script` — string, default: `"tsc"` — set empty to skip
- `type_check_args` — string, default: `"--noEmit"`
- `lint_script` — string, default: `"lint"` — set empty to skip
- `test_script` — string, default: `"test"`
- `test_commands` — string, default: `""` — JSON array of custom test commands
Steps:
1. Run `node_app_test.sh` with environment:
```
NODE_PKG_MANAGER: << parameters.pkg_manager >>
TYPE_CHECK_SCRIPT: << parameters.type_check_script >>
TYPE_CHECK_ARGS: << parameters.type_check_args >>
LINT_SCRIPT: << parameters.lint_script >>
TEST_SCRIPT: << parameters.test_script >>
QQQ_TEST_COMMANDS_JSON: << parameters.test_commands >>
```
2. store_artifacts: path: `test-results`, destination: `test-results` (if exists)
### `src/commands/node_app_build_step.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"`
- `build_script` — string, default: `"build"`
Steps:
1. Run `node_app_build.sh` with environment:
```
NODE_PKG_MANAGER: << parameters.pkg_manager >>
BUILD_SCRIPT: << parameters.build_script >>
```
### `src/commands/node_app_e2e.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"`
- `test_dir` — string, default: `"e2e"`
- `browsers` — string, default: `"chromium"`
Steps:
1. Run `node_app_e2e.sh` with environment:
```
NODE_PKG_MANAGER: << parameters.pkg_manager >>
E2E_TEST_DIR: << parameters.test_dir >>
PLAYWRIGHT_BROWSERS: << parameters.browsers >>
```
2. store_test_results: path: `e2e-results`
3. store_artifacts: path: `e2e-results`, destination: `e2e-results`
4. store_artifacts: path: `test-results/traces`, destination: `playwright-traces`
(use `when: always` so traces are captured even on failure)
---
## Step 5: Create jobs
### `src/jobs/node_app_test_only.yml`
For feature/non-protected branches. Run install + test only (no build).
Executor: `default` (existing `cimg/node` Docker executor)
- Use `default` executor, tag parameter: `<< parameters.node_version >>`
Parameters:
- `node_version` — string, default: `"lts"`
- `pkg_manager` — string, default: `"pnpm"`
- `type_check_script` — string, default: `"tsc"`
- `type_check_args` — string, default: `"--noEmit"`
- `lint_script` — string, default: `"lint"`
- `test_script` — string, default: `"test"`
- `test_commands` — string, default: `""`
Steps:
1. `git_full_checkout`
2. `node_app_install` (pass `pkg_manager`)
3. `node_app_test` (pass all test params)
### `src/jobs/node_app_build.yml`
For protected branches (develop, release/*, main, hotfix/*). Run install +
test + production build. Persists workspace for a downstream E2E job.
Executor: `default` (cimg/node Docker)
Parameters: (same as node_app_test_only PLUS:)
- `branch_type` — string, default: `"snapshot"` — reserved for future deploy hooks
- `build_script` — string, default: `"build"`
Steps:
1. `git_full_checkout`
2. `node_app_install` (pass `pkg_manager`)
3. `node_app_test` (pass all test params)
4. `node_app_build_step` (pass `pkg_manager`, `build_script`)
5. `persist_to_workspace` — root: `.`, paths: `["."]`
(full workspace so E2E job gets built artifacts)
### `src/jobs/node_app_e2e.yml`
Separate E2E job. Attaches workspace from `node_app_build` and runs Playwright.
Executor: `default` (cimg/node Docker) — browsers are installed by the script
using `npx playwright install --with-deps`, which works in Docker with root.
Parameters:
- `node_version` — string, default: `"lts"`
- `pkg_manager` — string, default: `"pnpm"`
- `test_dir` — string, default: `"e2e"`
- `browsers` — string, default: `"chromium"`
Steps:
1. `attach_workspace` — at: `.`
2. `node_app_e2e` (pass all E2E params)
---
## Step 6: Do NOT modify any existing files
These must be completely unchanged:
- All `mvn_*` jobs, commands, and scripts
- All `mvn_frontend_*` jobs, commands, and scripts
- All `node_*` jobs (node_test_only, node_publish), commands, and scripts
- `src/@orb.yml` — do NOT add new orb imports; existing `browser-tools`,
`node`, and `slack` orbs are already available
- `src/executors/default.yml`
---
## Step 7: Validate your work
After creating all files, run these commands from the orb root:
```bash
make lint # yamllint + shellcheck + circleci validate
make pack # pack to target/qqq-orb-packed.yml
make validate # pack + validate
```
Fix any yamllint, shellcheck, or orb validation errors before finishing.
---
## Target usage (what consuming repos will write)
After your changes, a pnpm Next.js app at `QRun-IO/qqq-frontend-next` writes:
```yaml
version: 2.1
orbs:
qqq-orb: qrun-io/qqq-orb@<new-version>
workflows:
test_only:
jobs:
- qqq-orb/node_app_test_only:
context: [ qqq-maven-registry-credentials ]
filters:
branches:
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/node_app_build:
branch_type: snapshot
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ develop ]
publish_release_candidate:
jobs:
- qqq-orb/node_app_build:
branch_type: release_candidate
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ /release\/.*/ ]
publish_release:
jobs:
- qqq-orb/node_app_build:
branch_type: release
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ main ]
tags:
only: [ /v.*/ ]
publish_hotfix_release:
jobs:
- qqq-orb/node_app_build:
branch_type: hotfix
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ /hotfix\/.*/ ]
```
All parameters use defaults that work for any standard pnpm Next.js project
with `tsc`, `lint`, `test`, and `build` scripts — zero configuration needed
unless the project deviates from those conventions.
---
## Engineering quality bar
Every script must meet the standard set by Munitor's scripts:
- `#!/usr/bin/env bash` + `set -euo pipefail`
- `qqq_helpers.sh` sourced at top with fallback stub
- `qqq_header` called as first real step
- `qqq_check_tool` used to validate required tools before using them
- Clear, actionable error messages with fix instructions
- Summary output at the end (what ran, pass/fail, counts)
- shellcheck-clean (no SC warnings)
+73
View File
@@ -0,0 +1,73 @@
# RecordQuery Feature Gap Analysis
_Generated: 2026-02-28_
## Summary
The new RecordQuery implementation covers the core query-page surface area well: quick search, advanced filter builder with nested AND/OR groups and possible-value comboboxes, server-side pagination, single-column sort, column show/hide/reorder with drag-and-drop, column resize, saved views, CSV export, bulk process launch, row selection, density picker, grid/card view toggle, variant picker, and mobile filter bottom-sheet. The foundation is solid and the hook architecture (`useRecordQuery`) is clean.
The primary gaps fall into three categories: (1) **multi-column sort** — the DataGrid sort handler replaces the entire sort array on every click instead of appending to it, so only one column can be sorted at a time; (2) **join-aware filtering** — the FilterBuilder only lists columns from the primary table; joined-table fields exposed via `exposedJoins` cannot be added as filter criteria even though the backend type (`QFilterCriteria`) has no `joinName` field and uses dot-notation `fieldName` instead; (3) **select-all-across-pages / bulk delete** — the selection affordance is page-scoped (only rows visible on the current page can be checked) and the BulkActionBar's delete button is wired up to an `onDeleteSelected` prop that `RecordQuery` never passes, so bulk delete is dead code.
Several lower-impact features from the legacy implementation are also absent: per-row action buttons (edit/delete inline on each row), inline cell editing, and distinct-count display.
---
## Feature Inventory
| Feature | Status | Notes |
|---------|--------|-------|
| Multi-column sort | ⚠️ Partial | State and API types support multiple `orderBys`, but `handleSortColumn` in `DataGrid.tsx` calls `onSortChange([{ fieldName, isAscending: true }])` — a single-element array — on every click, replacing any prior sort. Shift-click to append a secondary sort is not implemented. |
| Join filters | ⚠️ Partial | `use-record-query.ts` derives `QueryJoin[]` from `exposedJoins` and sends them to the API. However `FilterBuilder.tsx` builds its field list solely from `tableMetaData.fields` and has no awareness of joined-table fields. Users cannot add filter criteria on joined columns. The `QFilterCriteria` type uses dot-notation `fieldName` (e.g. `"joinedTable.fieldName"`) rather than a separate `joinName` property, so the backend contract is compatible — the UI just never produces such criteria. |
| Bulk select across pages | ❌ Not implemented | The select-all checkbox (`grid-select-all`) selects only rows on the current page (TanStack Table `getToggleAllRowsSelectedHandler`). There is no "Select all N matching records" affordance analogous to the legacy "select all" banner. The `BulkActionBar` shows `"{selected} of {total} selected"` but has no way to extend selection beyond the page. |
| Export | ✅ Implemented | `ExportButton.tsx` supports three scopes: all records (up to 10,000), current page, and selected records. CSV is built client-side from visible/ordered fields and downloaded via a temporary `<a>` element. No server-side export endpoint is called; this may diverge from the legacy approach for very large datasets or Excel format support. |
| Inline edit | ❌ Not implemented | `DataCell.tsx` is read-only. There is no double-click-to-edit or pencil-icon affordance on any cell. The legacy implementation offered inline editing on editable fields. |
| Saved views | ✅ Implemented | `SavedViewsMenu.tsx` and the `views` namespace in `use-record-query.ts` fully implement save / load / delete of named views persisted to `localStorage`. Views capture filter, column visibility, column order, and sort order. |
| Quick search | ✅ Implemented | Debounced input in `RecordQueryToolbar.tsx` drives an OR-joined `CONTAINS` search across all visible `STRING`/`TEXT` columns. Mutually exclusive with the advanced filter panel. |
| Column resizing | ✅ Implemented | `DataGrid.tsx` implements mousedown/mousemove/mouseup column resize with a minimum width of 60 px. Widths are persisted to `localStorage` via `useRecordQuery`. Resize listeners are cleaned up on unmount. |
| Column reordering | ✅ Implemented | `ColumnConfig.tsx` supports HTML5 drag-and-drop reordering as well as keyboard Up/Down arrow keys on the grip button. Order is persisted to `localStorage`. Drag-reorder directly in the grid header is not implemented (only via the side panel). |
| Pinned columns | ❌ Not implemented | No sticky/pinned column support. TanStack Table supports column pinning but it is not wired up. |
| Bulk actions | ⚠️ Partial | Process launch from the bulk bar works (`ProcessLauncherMenu` inside `BulkActionBar`). Bulk delete is dead code — `BulkActionBar` renders a Delete button only when `onDeleteSelected` is provided, but `RecordQueryBulkBar.tsx` never passes that prop. There is no "delete selected" handler anywhere in `RecordQuery.tsx`. |
| Row-level actions | ❌ Not implemented | Each row navigates to the record detail view on click but has no per-row action buttons (edit pencil, delete trash, custom process). The legacy implementation rendered per-row action icon buttons in a trailing "actions" column. |
| Pagination | ✅ Implemented | Standard offset-based pagination with first/prev/next/last buttons, a page-size selector (10/25/50/100/250), and a "Go to page" input when `totalPages > 5`. Controlled by `use-record-query.ts`. |
| Distinct count | ⚠️ Partial | `countRecords` in `tables.ts` accepts `includeDistinct = false` as a third parameter and the `CountRecordsResponse` type includes `distinctCount?`. However `use-record-query.ts` always calls `countRecords(tableName, { filter: countFilter, joins }, false)` (or omits the flag, defaulting to `false`) and the UI never reads or displays `distinctCount`. The infrastructure is there; it is just never invoked or surfaced. |
---
## Priority Gaps (implement these next)
Ranked by user-visible impact on day-to-day admin usage:
1. **Multi-column sort** — High impact, low effort. Change `handleSortColumn` in `DataGrid.tsx` to detect a Shift-click and append/toggle the clicked column in the existing `sortOrder` array rather than replacing it. Update the column header button to accept a `shiftKey` event attribute. The `QFilterOrderBy[]` structure and API calls already support multiple entries.
2. **Join filters** — High impact, medium effort. Extend `FilterBuilder.tsx` to accept a `joinedFields` prop (or derive it from `tableMetaData.exposedJoins`). Prefix each joined field's display label with the join table label (e.g. "Order → Customer Name"). Use dot-notation fieldName (e.g. `"customer.name"`) in the emitted `QFilterCriteria`. The backend already expects this format.
3. **Bulk select across pages** — Medium impact, medium effort. Add a "Select all {N} records" banner that appears below the bulk-action bar when the page-scoped selection equals `pageSize` and `totalCount > pageSize`. Store a `selectAllMode: boolean` flag in state. When `true`, submit the current filter as the selection scope to process launches rather than the explicit ID list.
4. **Bulk delete** — Medium impact, low effort. Wire up `onDeleteSelected` in `RecordQuery.tsx` (and pass it through `RecordQueryBulkBar`). Implement a confirmation dialog, call `deleteRecord` in a loop (or a future bulk-delete endpoint), then invalidate the records query.
5. **Row-level actions** — Medium impact, medium effort. Add an "actions" column to `DataGrid.tsx` as the last column. Render icon buttons for Edit (navigate to `/{tableName}/{id}/edit`), Delete (single-record confirmation modal), and optionally a per-row process dropdown if the table's `processes` array contains single-record processes. Guard each button on the respective permission flag from `tableMetaData`.
6. **Pinned columns** — Low-to-medium impact, medium effort. Enable TanStack Table's `columnPinning` feature. Add a "Pin left" option to the `ColumnConfig` panel or a column header context menu. Apply `position: sticky; left: 0` styling to pinned headers and cells. Persist the pinned set to `localStorage` alongside visibility and order.
7. **Distinct count display** — Low impact, low effort. Pass `includeDistinct = true` in `countRecords` when `tableMetaData.usesVariants` is true or when the query includes a join with `type: 'LEFT'`. Read `countQuery.data?.distinctCount` and display it alongside or instead of the plain count in the `Pagination` component.
8. **Inline edit** — Low impact (admin use case), high effort. On double-click of an editable cell, swap `DataCell` for a `DataCellEditor` component that renders an appropriate input (text, number, date, possible-value combobox). On blur or Enter, call `updateRecord` and invalidate the query cache. Requires a new `DataCellEditor` component, field-type-aware input selection, and optimistic update handling.
---
## Reference Implementation Notes
The following patterns from the legacy `RecordQuery.tsx` (MUI + React) are worth preserving or adapting as the new implementation matures:
- **Multi-column sort via Shift-click:** The legacy grid accumulates `orderBys` entries when the user holds Shift while clicking a column header. This is the industry-standard UX (matching MUI DataGrid Pro) and should be replicated exactly.
- **Join filter field grouping:** The legacy FilterBuilder groups fields under their parent table heading in the field selector dropdown (e.g. a separator "— Order fields —" followed by "— Customer fields —"). This makes joined fields discoverable rather than mixed into a flat list.
- **Select-all banner:** When the user selects all rows on the current page, a blue banner appears: "All 25 records on this page are selected. Select all 1,247 records matching your filter." Clicking the link sets a `filterOnlyMode` flag that replaces the ID list with the filter in process invocations.
- **Per-row action icons:** The legacy grid renders three small icon buttons at the end of every row (view, edit, delete) that are hidden until hover. They are absolutely positioned so they do not affect column widths. The new DataGrid's trailing-column approach is acceptable but should use `opacity-0 group-hover:opacity-100` CSS to avoid visual noise.
- **Export via server endpoint (Excel):** The legacy implementation calls a dedicated server-side export endpoint (`POST /table/{name}/export`) that returns an Excel file stream. The current implementation builds a CSV client-side from the raw query results. This has a hard limit of 10,000 rows and produces only CSV. For parity, a server-side export endpoint call should be added; the client-side fallback can remain for smaller datasets.
- **Distinct count in count query:** The legacy implementation passes `includeDistinctCount=true` for tables that use joins, and displays "N (M distinct)" in the pagination footer when `distinctCount < count`. This helps users understand that a LEFT join is inflating the row count.
- **Filter variable expressions (NOW, NOW\_WITH\_OFFSET):** The legacy FilterBuilder renders dedicated UI controls for dynamic date expressions so users can save filters like "created in the last 7 days" that remain accurate over time. The types (`NowExpression`, `NowWithOffsetExpression`, `ThisOrLastPeriodExpression`) are already defined in `src/types/query.ts` and `filter-utils.ts` includes type guards and display formatting for them, but the `FilterBuilder` UI never offers these expressions as selectable value options — only static scalar inputs are rendered.
+25
View File
@@ -0,0 +1,25 @@
# Next UI 1.0 testing period
## Final release is on hold
On September 29, 2026, the project owner requested a couple of weeks to test RC1 in real applications and report issues. **Do not tag, publish, or deploy final 1.0 without renewed, explicit owner approval.** Elapsed time and passing CI are not release approval.
RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.1), source `e901df91017204add7ebffa81765a02094e581b0`. See [RC1 release notes](1.0.0-RC.1.md) for installation details.
## Release candidate cadence
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
Latest published candidate: **[1.0.0-RC.9](1.0.0-RC.9.md)**. Its notes record installation, backend requirements, exact artifact verification and known failures. RC1 through RC8 remain available and unchanged.
## Fixes during testing
Browser stability work continues on `feature/browser-stability`, tracked in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) and the [unreleased changelog](unreleased.md). These changes are not present in the published RC9 artifacts. Earlier PRs #14 and #16 are merged. Check the version of each preview separately; a local preview may intentionally preserve an earlier design checkpoint.
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
## Reporting application issues
Create or update the central QQQ ticket and Project 12 item using the [tracking policy](../ISSUE-TRACKING.md). Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an issue in a published RC from a later branch regression. Never include credentials or sensitive application records.
The compatibility ledger and remaining acceptance work still govern final readiness. The testing hold does not waive those requirements or authorize a QQQ 4.1 release.
+57
View File
@@ -0,0 +1,57 @@
# Next UI 1.0.0-RC.1
Release candidate for testing in real QQQ applications. Published from commit `e901df91017204add7ebffa81765a02094e581b0`. Final 1.0 visual review and compatibility work remain open.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.1</version>
</dependency>
```
Override any older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the dashboard at `/` alongside `/qqq/v1`; no Node server is required. Keep Material available during evaluation if you need to switch back with `-Dqqq.javalin.frontend=material`.
[Maven Central JAR](https://repo.maven.apache.org/maven2/com/kingsrook/qqq/qqq-frontend-next/1.0.0-RC.1/qqq-frontend-next-1.0.0-RC.1.jar) — verified by download, checksum, a consuming Maven project, and real-backend runtime checks.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.1` (linux/amd64 and linux/arm64).
```text
ghcr.io/qrun-io/qqq-frontend-next@sha256:6edd16f20557c189bc618079851cd2d567c04001c6e34a5d3b7dde30895f9958
```
The quickstart image expects its backend at `http://host.docker.internal:8000`; that origin is fixed at build time. For other deployments, use the Maven artifact or build the image with the appropriate backend configuration.
This candidate is tested with the QQQ **4.1.0-SNAPSHOT development backend**, including the Sleep Interactive fix merged in [QQQ PR #896](https://github.com/QRun-IO/qqq/pull/896) (`e33e1fc52c55c203e3ee3014837970ef2614b809`). It does not publish QQQ 4.1 or establish compatibility with every older backend release. Pin and report your backend version/commit.
## Verification
- **547 real-backend Chromium scenarios passed**, with zero failures, skips or flaky retries; all 389 required acceptance rows passed, with three previously approved external-service exclusions. [Exact-candidate CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36601699322/job/109521050432).
- 1,806 unit tests, type checking, lint, mocked E2E, Storybook, license and bundle-budget checks passed. Candidate dependency audit reported zero known vulnerabilities on 2026-09-29.
- The actual Central JAR resolved in a consuming Maven project. It contains 186 dashboard export files, expected routes and license, and no development mock worker. Its checksum verified; a signature is published. The CI production export uses a different bundler from the local 204-file verification build.
- The downloaded JAR ran against the real sample backend: Sleep Interactive defaults, required validation and completion passed; report-filtered statistics and exported CSV matched independent SQL.
- The published container manifest includes both architectures; the downloaded arm64 image started and served the dashboard successfully. This container check is a runtime/HTML smoke test, separate from the full backend workflow suite.
Maven JAR SHA-256: `5a5f56e892164c2b3c220d929372cf57c019dabaa35340839be3b9a6907076eb`.
Testing prioritizes complete Chromium workflows: normal entry/defaults, validation, editing/cancellation, save/reopen, dependent selections, permissions, recovery and persisted/downloaded results. Test counts do not prove every application configuration works.
## Known limitations
- Secondary-browser verification is complete and has unresolved failures. In the [candidate run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36601699322), WebKit passed 546 and failed NAV-034 (legacy process redirect, internal navigation error); Firefox passed 546 and failed WID-021 (process widget visibility timeout); phone/tablet passed 1,043 and failed tablet SEC-002 (internal WebKit navigation error). The [tag run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36605084236) passed Chromium again but failed Firefox RPT-016 (report sharing), WebKit REL-007 (association navigation), and tablet NAV-015 (document-title navigation), with the same respective passing counts. Logs/traces are retained; these failures have not been waived or established as infrastructure-only. No complete cross-browser pass is claimed.
- Hosting beneath a URL base path is not implemented ([QQQ #732](https://github.com/QRun-IO/qqq/issues/732)); evaluate at the origin root.
- Google Drive folder selection remains a stub ([QQQ #704](https://github.com/QRun-IO/qqq/issues/704)).
- Full embedded report/query equivalence remains under review ([QQQ #722](https://github.com/QRun-IO/qqq/issues/722)).
- Remaining input-block configurations and unsupported Material icon names need completion ([QQQ #728](https://github.com/QRun-IO/qqq/issues/728)).
- Script editor autocomplete is absent ([QQQ #724](https://github.com/QRun-IO/qqq/issues/724)).
- Full visual comparison against the original Next UI design remains open. Material is the behavior reference; the original Next UI is the visual reference.
These are recorded RC1 gaps, not approved exclusions from final 1.0. The [behavior parity ledger](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/docs/acceptance/material-parity.md) remains authoritative.
## Guides and bug reports
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/docs/guides/next-ui-agent-guide.md) · [Acceptance instructions](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/tests/acceptance/README.md).
[Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new) with frontend/backend versions, browser, affected widget or metadata, steps from a fresh page, expected/actual behavior and whether it survives reload. Include screenshots or sanitized errors where useful. For reports, include table, filters, sorts and columns; for processes, include step/input configuration. Remove credentials, session cookies and private application data.
+64
View File
@@ -0,0 +1,64 @@
# Next UI 1.0.0-RC.2
Release candidate for testing in real applications. Final 1.0 remains on hold for owner testing and explicit approval. RC1 remains unchanged.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.2</version>
</dependency>
```
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.2` (linux/amd64 and linux/arm64). This quickstart image uses the build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild the image for a different backend origin.
## Changes since RC1
- Report previews support backend table variants consistently across query, count, statistics and full-result copy. Variant switches clear stale preview state and cannot let an old copy request overwrite the clipboard.
- Report previews honor query and count capabilities independently, including Refresh, and stop showing cached data/errors when a capability is removed.
- Report filter editors restore saved-view reset behavior and show active filters in column headers. Statistics and CSV checks compare results with independent SQL.
- Configured Google Drive folder selection replaces the placeholder, with backend metadata/CSP prerequisites and controlled-provider tests. Real-provider verification retains its documented external-service limitation.
- Script editing provides local QQQ helper, keyword and file-identifier suggestions, with keyboard/touch selection and native undo/redo behavior.
- Copy controls align with the form. Selected record sections retain the original Next full-width card and responsive field columns when metadata omits widths; explicit widths still take precedence.
- Pinned table selection cells expose the full checkbox touch target at every density.
- Child-record CSV export returns to the original Next header position with one working control. Export respects content visibility/permissions, and widget render failures can retry with fresh backend data.
## Backend requirements
Use the QQQ 4.1.0-SNAPSHOT development backend with PR #913 (`cba758507dcc54cf282a6638ab50a7d3a1904c7c`) for report variants and concurrent metadata handling, and PR #908 (`bac663044`) for configured Google Drive selection. The primary CI run used backend `cba758507dcc54cf282a6638ab50a7d3a1904c7c`. This release does not include a QQQ 4.1 release; record the backend commit used in your application.
## Verification
- Source: `0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd`; signed tag `v1.0.0-RC.2`.
- **563 real-backend Chromium tests passed**, covering all 390 required rows, with zero failures, skips or flaky results. The CI merge checkout and tagged source have identical file trees. [Primary CI evidence](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36640179043).
- **1,857 unit tests passed**. Types, lint, coverage, Storybook, mocked Chromium tests, bundle budgets and CircleCI passed. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36640179012).
- Public Maven Central JAR, POM, checksum and signature verified. A fresh, independent Maven consumer resolved the identical JAR. License, route assets, RC2 marker and absence of the mock service worker verified. All 77 application/runtime JavaScript chunks match primary CI byte-for-byte; generated build IDs and unused CSS utilities differ between build environments.
- The actual downloaded JAR passed five Chromium workflow checks: Sleep Interactive, parent CSV export, child CSV export, count-only report refresh, and filtered report statistics/CSV. These used an isolated local fixture backend; they supplement the full primary CI run.
- Both container architectures published; anonymous manifest retrieval verified. The actual downloaded arm64 image passed HTML and referenced-JavaScript runtime checks. [Image publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36642612472).
Maven JAR SHA-256: `872d7175feb98dab7d10fd50c659066449ce6801dc511011ead89438b6ef226a`.
Container digest: `sha256:dfd1b793c947a17e3c9f43473e4b8dfc12c5e45c8c1a9b57800bcbd6c3912b64`.
## Known limitations
- Hosting beneath a URL base path remains unresolved; evaluate at the origin root.
- Full embedded report/query equivalence, remaining input-block configurations, and unsupported Material icon names remain open.
- The existing 1,621-image review is complete, but missing states, scroll positions, themes and historical provenance still limit whole-product visual approval. The reference is the original Next UI design.
- Real Google Drive provider verification retains its external-service limitation.
- On this source's PR CI run, WebKit passed 560 tests and failed three: QRY-030 density-menu interaction, NAV-026 navigation widget-request access-control errors, and NAV-056 appearance-preference query-request access-control errors. Traces are retained and causes are not declared resolved. Firefox passed 562 tests and failed REC-048: the expected validation toast was visible, but the subsequent one-toast count observed zero. Mobile passed all 538 tests. Tablet passed 536 and failed two: QRY-030 density-menu interaction and REL-005, where the child list did not show the renamed record within the assertion timeout. These failure causes remain under investigation. No blanket cross-browser pass is claimed.
- The [completed tag acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36642612560) independently passed **563 Chromium tests / 390 required rows** and **538 mobile tests**, without skips or flaky results. WebKit passed 561 and failed QRY-030 (density menu) and RPT-012 (internal navigation error opening a saved report). Firefox passed 561 and failed PRC-061 (bulk-load default choice did not remain selected) and INT-011 (expected failed-save feedback was absent). Tablet passed 537 and failed QRY-030 (density menu). All traces are retained; these causes remain under investigation. The overall tag run failed because of these secondary-browser results.
- Confirmed after publication: multiline `TEXT` fields do not apply configured uppercase/lowercase behavior while editing. A fix and regression tests are in progress for the next candidate; RC2 does not contain that fix.
These are candidate limitations, not waived final-release requirements. See the [compatibility ledger](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/acceptance/material-parity.md) and [browser matrix](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/acceptance/browser-matrix.md) for prior checkpoints.
## Guides and issue reports
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. State whether the issue persists after reload.
+64
View File
@@ -0,0 +1,64 @@
# Next UI 1.0.0-RC.3
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and explicit approval.** RC1 and RC2 remain immutable.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.3</version>
</dependency>
```
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. If an earlier attempt cached a missing RC3 artifact, retry Maven with `-U`.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.3` (linux/amd64 and linux/arm64). This quickstart image uses the build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild the image for a different backend origin.
## Changes since RC2
- Multiline TEXT fields rendered by the shared editor honor configured uppercase/lowercase behavior while typing and submit the displayed value. Enter remains a newline.
- Case conversion preserves cursor position when Unicode characters expand, including ß to SS and İ to i plus combining dot. Continued typing and backward selections retain the intended boundaries.
- Added standalone and process regression coverage with exact database readback. Developer and agent guides document these behaviors. Existing layout and styling are preserved.
## Verified artifacts and workflows
Source: `f1a6a3a16868c04d30a0a807f85ea34c7170c508`; signed tag `v1.0.0-RC.3`.
- Hosted real-backend Chromium: **570 tests and all 391 required feature rows passed**, with zero failures, skips or flaky results. Backend commit: `cba758507dcc54cf282a6638ab50a7d3a1904c7c`. The tested PR merge has an identical Git tree to the tagged source. [Candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36652168977).
- **1,873 unit tests passed**, plus types, lint, unit coverage, mocked Chromium, bundle budgets and Storybook. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36652168856).
- Public Maven JAR checksum and PGP signature verified; a separate consumer resolved the same JAR through normal Maven dependency resolution. The actual downloaded JAR passed seven Chromium case-conversion workflows with independent database readback. [Maven deployment](https://circleci.com/gh/QRun-IO/qqq-frontend-next/122).
- Public container manifest verified anonymously for both architectures. The actual arm64 image served the app route and all 21 referenced JavaScript assets. [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36654410994).
Public JAR SHA-256:
`596d270893ab733376c7b2c39dfdf4d7bf5027cbb984e67ea95d9090df3e7109`
Container manifest digest:
`sha256:fe7141ded4a625bbfa67675945eff4fa6ba0e1562bf4563fe8fab8933ffee69a`
## Backend and known limitations
Use the QQQ 4.1.0-SNAPSHOT development backend including PR #913 (report variants/concurrent metadata handling) and PR #908 (configured Google Drive selection). Record your application's backend commit. This release does not release QQQ 4.1.
The exact candidate acceptance run is complete and failed overall. Results (zero skips or flaky results):
| Browser | Passed | Failed | Failure evidence |
| --- | ---: | ---: | --- |
| Chromium | 570 | 0 | All 391 required feature rows passed |
| Mobile | 545 | 0 | All tagged phone scenarios passed |
| Firefox | 569 | 1 | WID-005: Widget Gallery heading was not found within 15 seconds |
| WebKit | 568 | 2 | NAV-056: application-theme override reported access-control errors for application requests; QRY-030: density-menu coverage timed out |
| Tablet | 543 | 2 | QRY-030: density-menu option did not appear; REC-046: WebKit internal error navigating to the create route |
Reports and traces are retained; causes remain under investigation. The [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36654410984) passed **570 Chromium tests and all 391 required rows**. Its WebKit job passed **568 tests and failed two**: NAV-016 site-root navigation reported application-request access-control errors; QRY-030 density-menu coverage timed out. Its Firefox job passed **568 tests and failed two**: QRY-007 did not observe the light-theme error toast within 15 seconds; WID-073 timed out waiting for the Render Report action. Its mobile job passed **545 tests**; tablet passed **543 and failed two**: NAV-026 navigation-drawer coverage reported widget-request access-control errors; QRY-030 density-menu coverage timed out. The tag run is complete and failed overall, with zero skips or flaky results. All reports and traces are retained; these failures remain open.
Full embedded report/query equivalence, remaining input configurations, unsupported Material icon names, origin sub-path hosting, real Google Drive provider verification and whole-product visual approval remain open. A separately verified follow-up fixes explicitly configured PASSWORD case behavior; it is not included in RC3. Ordinary password case is unchanged. Original Next UI remains the visual reference.
These limitations remain final-release requirements. Final 1.0 needs renewed owner approval after application testing.
## Guides and issue reports
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/f1a6a3a16868c04d30a0a807f85ea34c7170c508/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/f1a6a3a16868c04d30a0a807f85ea34c7170c508/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
+65
View File
@@ -0,0 +1,65 @@
# Next UI 1.0.0-RC.4
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and explicit approval.** RC1, RC2 and RC3 remain immutable.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.4</version>
</dependency>
```
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. If a previous attempt cached a missing RC4 artifact, retry Maven with `-U`.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.4` (linux/amd64 and linux/arm64). This quickstart image uses build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild for a different origin.
## Changes since RC3
- Explicitly configured PASSWORD uppercase/lowercase behavior preserves Unicode-aware cursor position, masking and show/hide values. Ordinary passwords retain their entered case.
- Report previews and editors honor `omitExposedJoins` for new choices while retaining saved joined filters, sorts, columns and widths. Reordering available columns preserves omitted columns in place. Cancel, Basic/Advanced switching, OK, host Save and reopening preserve the intended data. Backend permissions remain authoritative.
- Numeric process and widget inputs preserve their values while scrolling; deliberate typing and arrow-key edits still work.
- Density menus stay open when touch-generated focus returns to their own trigger. Trigger toggling, Escape, focus return and outside dismissal remain functional. This fixes the reproduced Linux WebKit density failure.
- Developer documentation and regression coverage updated. The original Next UI remains the visual reference; these fixes preserve its layout.
## Verified artifacts and primary workflows
Source: `3c409f918699bc1fb9981f5b8829737bc608b55a`; signed tag `v1.0.0-RC.4` (tag object `bbdfea79fb4c5e044f717a735baa2073c32a8c39`).
- Both [candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36664932061) and [tag acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36714593674) passed **577 Chromium tests and all 391 required feature rows**, with zero failures/skips/flaky outcomes and three approved external-service exclusions. Both used backend `e0d57dd31b979b17215370546ef4e26cd8a38d21`. The tested PR merge tree equals the tagged source tree.
- **1,884 unit tests** and **104 mocked Chromium tests** passed, plus types, lint, coverage, Storybook and bundle budgets. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36664931987).
- Public Maven checksum and PGP signature verified. A clean consumer resolved the identical JAR through normal Maven dependency resolution, without manually installing it. [Maven publication](https://app.circleci.com/workflow/fbb640fc-b3a1-4594-abb8-3d95f341fccb).
- The actual downloaded public JAR passed **17 affected real-backend Chromium workflows**, with zero failures, skips, flaky outcomes or report-level errors. Coverage includes configured case conversion/passwords, report preview/editor preservation, numeric inputs and density-menu interactions.
- Public container manifest verified anonymously for AMD64 and ARM64. The downloaded ARM64 image served the app route and all 21 referenced JavaScript assets. This verifies image delivery; backend workflows are verified separately through the Maven JAR. [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36714593869).
Public JAR SHA-256:
`ee521bc61f21b1deb24094578e17fbb4f636a33f34ce7e0e646cab3e6cc43bf6`
Container manifest digest:
`sha256:e01502691008c5f2ce444e3a212875819cd0bbd8500cee5827d7a20fa5e67f20`
## Browser results and remaining limitations
Both acceptance runs completed and failed overall because of secondary-browser failures. All profiles had zero skips or flaky outcomes; failures were retained, not waived or silently retried.
| Browser | Candidate result | Tag result |
| --- | --- | --- |
| Chromium | 577 passed | 577 passed |
| Mobile | 547 passed, 1 failed: REC-054 script-suggestion listbox bounding-box timeout in dark mode | 548 passed |
| Firefox | 576 passed, 1 failed: WID-026 record-heading timeout | 576 passed, 1 failed: WID-019 dashboard-heading timeout |
| WebKit | 576 passed, 1 failed: REC-046 internal navigation error on a read-only user's copy route | 576 passed, 1 failed: REL-005 internal navigation error returning to the pet list |
| Tablet | 547 passed, 1 failed: NAV-026 widget-request access-control errors during drawer navigation | 547 passed, 1 failed: SEC-002 internal navigation error opening a denied ledger create route |
Density scenarios passed in every profile of both runs. The other failures remain open; a later pass does not erase the earlier failure or prove full browser compatibility.
Use the QQQ 4.1.0-SNAPSHOT development backend including report-variant and configured Google Drive support. Record your application's exact backend commit. This RC does not release QQQ 4.1.
Full embedded report/query equivalence, remaining input configurations, unsupported Material icon names, origin sub-path hosting, real Google Drive provider verification and whole-product visual approval remain open. Final 1.0 requires renewed owner approval after application testing.
## Guides and issue reports
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/3c409f918699bc1fb9981f5b8829737bc608b55a/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/3c409f918699bc1fb9981f5b8829737bc608b55a/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
+56
View File
@@ -0,0 +1,56 @@
# Next UI 1.0.0-RC.5
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and explicit approval.** Earlier RCs remain immutable.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.5</version>
</dependency>
```
Override an older BOM-managed frontend version and select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. If Maven cached an earlier missing-artifact result, retry with `-U`.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.5` (linux/amd64 and linux/arm64). Its build-time backend origin is `http://host.docker.internal:8000`; use the Maven JAR or rebuild the image for a different origin.
## Changes since RC4
- Restored original Next uniform table backgrounds, retaining selection highlighting, pinned cells and application CSS hooks.
- Restored original record overview typography and spacing, retaining metadata widths and selected-section layout.
- Report `hidePreview` hides the read-only summary preview while keeping the editing query available.
- Disabling a saved report's pivot table now clears its stored definition when Save is pressed, correcting silent validation failure.
- Column menus fit short windows and scroll when needed, keeping keyboard and pointer actions reachable.
## Verification
Source: `ac2294697519b5ee108d0ba776400afe30d28927`; signed tag `v1.0.0-RC.5`.
- [Candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36751436362): **587 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky results or report errors. Three approved external-service exclusions remain. Backend: `e0d57dd31b979b17215370546ef4e26cd8a38d21`. The actual tested PR merge has the same Git tree as the tagged source.
- **1,884 unit tests** and **104 mocked Chromium tests** passed, plus types, lint, coverage, Storybook and bundle budgets. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36751436048).
- Public Maven checksum and PGP signature verified. A clean consumer resolved the identical JAR through ordinary Maven dependency resolution. The actual downloaded JAR passed **12 affected real-backend Chromium workflows**, with zero failures, skips, flaky results or report errors. [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/131).
- Public container manifest verified anonymously for both architectures. The actual ARM64 image served the app and all 21 referenced JavaScript assets. This checks image delivery; backend workflows were verified through the public JAR. [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36754867839).
Public JAR SHA-256:
`3d4955a763b73cb2e3a3a385164977772fe5e8ec6000699603ab649589070d41`
Container manifest digest:
`sha256:5eba52c5e0a67529048ddb992c609aa622dda5ecd6805ab6f0d6ea1e7ed0b264`
## Known limitations and continuing checks
Candidate WebKit finished with **586 passed and one failed**: REC-046 encountered an internal WebKit navigation error opening a read-only user's copy route. Mobile passed **548 tests**. Tablet passed **547 tests and failed one**: NAV-014 encountered an internal WebKit navigation error opening the Person create route during breadcrumb coverage. These completed profiles had zero skips, flaky outcomes or report errors. Both failures remain open. Candidate Firefox completed with **586 passed and one failed**: WID-073 did not observe the initial report-variant preview row within 15 seconds. It had zero skips, flaky outcomes or report errors. The candidate run is complete and failed overall; all failures remain open. The independent [tag acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36754867847) also passed **587 Chromium tests and all 391 required feature rows**, with zero failures, skips, flaky results or report errors; its WebKit profile completed with **585 passed and two failed**: NAV-026 reported widget-request access-control errors during navigation-drawer coverage, and NAV-015 timed out after an internal WebKit navigation error opening the unknown-route page. WebKit had zero skips, flaky outcomes or report errors. Tag Firefox completed with **586 passed and one failed**: INT-010 did not observe the failed-query error panel within 30 seconds. It had zero skips, flaky outcomes or report errors. Tag mobile passed **548 tests**; tablet passed **546 tests and failed two**: NAV-014 hit an internal WebKit navigation error opening the interactive greeting process, and SEC-043 hit an internal WebKit error reloading during different-user recent-record isolation coverage. Both profiles had zero skips, flaky outcomes or report errors. The tag run is complete and failed overall. Reports and traces for every failed scenario are retained; causes remain under investigation. Prior secondary-browser failures remain under investigation.
Use the QQQ **4.1.0-SNAPSHOT development backend** including report-variant and configured Google Drive support, and record your application's exact backend commit. This frontend RC does not release QQQ 4.1.
Complete embedded report/query equivalence, remaining input configurations, unsupported Material icon names, hosting under an origin sub-path, real Google Drive provider verification and whole-product visual approval remain open. The original Next UI is the visual reference. A separate icon follow-up is not part of this immutable RC5 release. A subsequent regression check also found that plain TIME blocks use minute increments: process blocks can mark second-level values invalid. A separate correction is being verified; it is not included in RC5.
Final 1.0 requires renewed owner approval after application testing.
## Guides and issue reports
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/ac2294697519b5ee108d0ba776400afe30d28927/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/ac2294697519b5ee108d0ba776400afe30d28927/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
+52
View File
@@ -0,0 +1,52 @@
# Next UI 1.0.0-RC.6
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and renewed explicit approval.** Earlier RCs remain immutable.
## Changes since RC5
- Legacy metadata icon names missing from the existing Next maps now render from a pinned, locally bundled Material Icons inventory of 2,234 unique names.
- Existing Next Lucide mappings, widget overrides, custom image priority, colors, sizing and layout are preserved. Unknown names keep generic fallback icons.
- The extra inventory loads only when needed, without an external font service or new package dependency.
- Developer and agent guides document icon resolution and its limits.
## Install
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.6`. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.6`, AMD64 and ARM64. Its build-time backend origin is `http://host.docker.internal:8000`; use the Maven JAR or rebuild for a different origin.
Both public artifacts are available and verified. If Maven cached a missing-artifact result from an earlier attempt, retry with `-U`.
## Verified candidate
Source: `7ddf45fa4d7c94627bafb1ee40916c3f6c47536f`; signed tag `v1.0.0-RC.6`. Tested PR merge `f52387f10885da1d8ad22961915b76fa9e08ab72` has the same source tree.
- [Hosted candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36764199293): **589 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky results or report errors. Three approved external-service exclusions remain. Backend checkout: `e0d57dd31b979b17215370546ef4e26cd8a38d21`.
- [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36764199430): **1,901 unit tests**, **104 mocked Chromium tests**, types, lint, coverage, Storybook and bundle budgets passed.
- Both candidate container architecture builds passed. The local RC6 JAR passed four light/dark desktop/mobile icon workflows; its 193 static export files and bundled font were verified against the build output.
Candidate WebKit completed with **586 passed and three failed**, with zero skips, flaky outcomes or report errors: NAV-034 encountered an internal WebKit navigation error opening a record process; NAV-002 expected raw sidebar text without decorative legacy icon glyphs; WID-073 reported an application-metadata request access-control error. Reports and traces are retained; these failures remain recorded. A subsequent check reproduced NAV-002 against the public JAR and confirmed its raw-text assertion counted decorative SVG characters. A separately reviewed test correction verifies accessible names, order and hidden glyphs; it passed focused Chromium/WebKit/mobile checks and is not part of the original run. The other failures remain under investigation. The candidate run is now complete and failed overall. Firefox finished **586 passed / 3 failed**: NAV-014 and NAV-015 reported legacy icon font download errors; INT-010 timed out waiting for the failed-query error panel. Mobile finished **549 passed / 1 failed** (NAV-002 decorative-glyph raw-text assertion). Tablet finished **548 passed / 2 failed** (the same NAV-002 assertion and NAV-034 internal WebKit session/navigation failure). These profiles had zero skips, flaky outcomes or report errors. All reports and traces are retained. The independent [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36767986263) passed **589 Chromium tests** with zero failures, skips or flaky results. Its WebKit job finished **585 passed / 4 failed**: NAV-026 application-request access-control errors, NAV-016 missing Person grid after site-root navigation, NAV-002 the decorative-glyph raw-text assertion, and SEC-043 an internal WebKit reload error. Firefox finished **586 passed / 3 failed**: NAV-002 the decorative-glyph raw-text assertion, and REC-056/SEC-043 legacy icon font download errors. Mobile finished **548 passed / 2 failed**: NAV-002 and REC-054 a missing script-suggestion popup bounding box in light mode. Tablet finished **549 passed / 1 failed**: NAV-002. The tag run is complete and failed overall; all profiles had zero skips, flaky outcomes or report-level errors. Reports and traces are retained. Earlier secondary-browser failures remain open; Chromium success does not establish full browser compatibility.
## Public artifact verification
- [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/135) built the exact tagged source. The downloaded JAR checksum and PGP signature were verified; a clean consumer resolved identical bytes through ordinary Maven dependency resolution.
- The actual public JAR passed **four affected light/dark desktop/mobile icon workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 dashboard files, three version markers, pinned font and license were checked; no mock worker is packaged.
- [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36767986139) succeeded. Anonymous inspection verified both architectures; the downloaded ARM64 image served the app and all 21 JavaScript assets. Backend behavior was verified separately through the JAR.
Public JAR SHA-256: `81ef0a869afb3caeb356704bd8b0345a7601b7cf908d042f66d5c3c9835b2697`.
Container manifest digest: `sha256:8591aaf10946eb1b44da5a4a5f45a9782dac9afc734bdd5187630c5b1351a5d3`.
## Backend and known limitations
Use the QQQ **4.1.0-SNAPSHOT development backend** with report-variant and configured Google Drive support. Record your application's exact backend commit. This frontend RC does not release QQQ 4.1.
Complete embedded report/query equivalence, remaining input and association-editing configurations, developer-tool gaps, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next is the visual reference. Arbitrary Material Symbols names and distinct icon font-style variants are not claimed.
Plain TIME blocks retain a known seconds-handling defect in this RC. A separate correction is verified and committed, but not included. API playground contrast corrections are also being verified separately and are not included.
## Guides and feedback
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/7ddf45fa4d7c94627bafb1ee40916c3f6c47536f/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/7ddf45fa4d7c94627bafb1ee40916c3f6c47536f/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include the exact frontend and backend versions/commits, browser, workflow, relevant metadata, reproduction steps, expected/actual results and sanitized screenshots or errors.
+69
View File
@@ -0,0 +1,69 @@
# Next UI 1.0.0-RC.7
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and renewed explicit approval.** Earlier RCs remain immutable.
## Changes since RC6
- Plain process and standalone TIME inputs support seconds, matching shared forms. Seeded values remain valid, native stepping advances one second, and saved values retain seconds. Existing timestamp timezone and daylight-saving behavior is preserved.
- API playground text and button contrast use the dashboard theme in light/dark mode. Preferences changes apply immediately; layout is preserved.
- Sidebar acceptance checks wait for legacy icons and verify accessible labels, order and decorative glyph hiding. This corrects a test assertion; no sidebar product behavior changes.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.7</version>
</dependency>
```
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. Use Maven `-U` if an earlier lookup cached a missing artifact.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.7` (AMD64/ARM64). This quickstart image uses build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild for a different origin.
## Verified candidate
Source: `f65cf6bfbd1583538afe1b66f8764812980880c3`. Tested PR merge `f11bb1a6656de71e5a60d5781ac244e5117daa81` has the identical source tree `623efded5c6de13cbe389dc27c9227468a7419c9`.
- [Hosted candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36771453216): **592 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky outcomes or report errors. Three approved external-service exclusions remain. Backend checkout: `e0d57dd31b979b17215370546ef4e26cd8a38d21`. WebKit completed with **591 passed / 1 failed**: NAV-026 navigation-drawer coverage reported widget-request access-control errors. There were zero skips, flaky outcomes or report-level errors. The cause remains under investigation; original reports and traces are retained. Mobile completed with **553 passed / 0 failed**. Tablet completed with **552 passed / 1 failed**: NAV-014 breadcrumb navigation reported API request access-control errors. Both had zero skips, flaky outcomes or report-level errors. Firefox completed with **590 passed / 2 failed**: NAV-016 legacy icon font download failure and WID-041 missing plain widget. The candidate run is complete and failed overall; all profiles had zero skips, flaky outcomes or report-level errors.
- [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36771452885) passed, including unit/coverage, types, lint, mocked Chromium, Storybook and bundle budgets. Fresh local checks passed **1,904 unit tests**, types, lint, export and Maven packaging.
- The actual local candidate JAR passed **14 affected desktop/mobile workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 export files match the build output, version markers and pinned icon font are correct, and no mock service worker is packaged.
## Publication verification
- [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/138) built the exact tagged source. The public JAR checksum and QQQ publishing signature were verified. A clean consumer resolved identical bytes through ordinary Maven dependency resolution.
- The actual downloaded public JAR passed **14 affected desktop/mobile workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 dashboard files, three version markers, pinned icon font and license were verified; no mock worker is packaged. An initial run used stale compiled test fixtures and failed four missing-clock assertions; that evidence was retained, the fixtures were compiled from tagged source, and the complete affected suite passed.
- [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36775272526) succeeded. Anonymous manifest verification confirmed AMD64 and ARM64. The downloaded ARM64 image served the application and all 21 referenced JavaScript assets. This verifies image delivery; backend workflows were verified through the public JAR.
Public JAR SHA-256: `f23f8ede0d82d30f47621efacd14119afb7c00a6961a1936a17ffa2203bebdf0`.
Container manifest digest: `sha256:6d6fc39afd3f6e00aebe619b49a0350a64802f4e6f7cebdacb8aac20284fdba3`.
The independent [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36775272542) is complete and failed overall. Original reports and traces are retained; there were zero skips, flaky outcomes or report-level errors.
| Profile | Passed | Failed | Failure evidence |
| --- | ---: | ---: | --- |
| Chromium | 592 | 0 | All 391 required feature rows passed |
| Mobile | 553 | 0 | All tagged phone scenarios passed |
| WebKit | 591 | 1 | NAV-015 incorrect not-found title with a session-request internal error |
| Firefox | 590 | 2 | NAV-016 legacy icon font download failure; WID-024 missing record heading during child export |
| Tablet | 551 | 2 | NAV-014 and NAV-015 internal navigation errors |
These failures remain open. Passing primary workflows does not establish complete browser compatibility.
## Backend and known gaps
Use QQQ 4.1.0-SNAPSHOT development backend including report-variant and configured Google Drive support. Record your application's exact backend commit. This RC does not release QQQ 4.1.
Complete embedded report/query equivalence, remaining input and association-editing configurations, developer-tool gaps, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next remains the visual reference.
The API playground's inherited narrow-screen specification-button clipping is not fixed in RC7; a separately reviewed follow-up addresses it. A newly reproduced bounded LONG validation issue can throw for non-integer text or round large string bounds; its separate follow-up passed schema/component tests and independent review, with combined candidate verification underway. Neither follow-up is included here. A separate unit test also has an ambiguous duplicate-label assertion; its correction is outside this immutable source.
Final 1.0 requires renewed owner approval after application testing; passing the current matrix does not establish complete compatibility.
## Guides and issue reports
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/b9b1f3da84f49eec16ead0da3fda3af3e5f16b98/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/b9b1f3da84f49eec16ead0da3fda3af3e5f16b98/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
+46
View File
@@ -0,0 +1,46 @@
# Next UI 1.0.0-RC.8
**Public Maven and container delivery verified; RC8 prerelease published for application testing.** Tag `v1.0.0-RC.8` points to `cd3d4731d1910e68e994da9c74a8c28a2f1b7c1c`. RC8 supersedes RC7 as the current application-testing prerelease. Final 1.0 remains on hold for owner application testing and renewed explicit approval.
## Changes since RC7
- API specification Download and View controls remain fully visible and usable in narrow panels, including 320px phones. Labels wrap when needed; wide-panel buttons grow to fit the application font. Real specification download and keyboard new-tab behavior are covered in light/dark and touch workflows.
- Search keeps keyboard focus inside the dialog, returns focus to its opener on close, handles Escape from all controls, and returns focus to the input after clearing.
- Bounded LONG fields reject invalid integer text with a field error instead of throwing during range validation. Exact integer-string bounds retain precision beyond JavaScript's safe-number range. Optional blanks and existing required-field messages are preserved.
- A process unit test scopes its choice-label lookup to the read-only field under test, avoiding ambiguity when an editable field displays the same label. This changes no product behavior.
## Historical verification before version change
Both product fixes passed independent review. API layout verification passed 12 affected desktop/mobile workflows; its 1440px RapiDoc screenshot was identical to RC7. The API layout source passed 596 real-backend Chromium tests and all 391 required feature rows, with zero failures/skips/flaky outcomes/report errors and three approved external-service exclusions.
The combined fixes passed all 1,913 unit tests across 180 files, types, lint/license, a clean production export and existing bundle budgets. The first full combined Chromium run finished with 595 passed and one failed (NAV-025 search navigation), zero skips/flaky outcomes/report errors. Its report and trace are retained; investigation remains open. The trace requested the correct search route successfully and the final snapshot contained the expected results despite the URL assertion reporting the prior record route. Twenty focused repetitions against the RC8 JAR passed; they do not erase the original failure. No timeout, retry or assertion changes were made. Fresh RC8-version checks passed all 1,913 units, types, lint/license, production export, Maven packaging and existing bundle budgets. All 193 packaged dashboard files match the export; version markers and pinned font are verified. The actual RC8 JAR passed 16 affected desktop/mobile workflows with zero failures/skips/flaky outcomes/report errors: API specification visibility/download/keyboard actions, contrast/live theme changes, exact LONG/DECIMAL persistence and composite metadata-editor submission. The fresh full RC8 run at source `f0f0eae98e4b6c883475956a7c59e060d2e5f0a5` finished with **588 passed / 8 failed**, zero skips/flaky outcomes/report errors. The failed cases were QRY-007, QRY-009, two QRY-001 theme cases, QRY-052, QRY-053, REC-063 and QRY-071. macOS power events overlap 98–100% of each failure interval, including a browser-launch timeout; test and teardown timers advanced during host sleep. The run is retained as failed, not converted to passing evidence. This explains its abnormal duration but does not prove every affected workflow correct. An uninterrupted hosted run is required, along with public delivery checks. The earlier NAV-025 search failure remains independently recorded. At that historical checkpoint, no RC8 tag or public artifacts existed.
LONG regression coverage establishes frontend schema/component behavior, including correction and submission of exact digits. It does not establish a new backend range-metadata contract.
## Historical September 30 follow-up verification
The first hosted RC8 candidate at `14d6966` completed its primary Chromium job with **594 passed / 2 failed** (REC-053, desktop API label clipping in both themes). General CI and image build passed. Secondary acceptance jobs remain in progress. Publication is blocked until the corrected combined candidate has fresh release evidence.
A wider-font regression reproduced label overflow at 210px content versus 198px available. The reviewed correction uses RapiDoc public CSS parts scoped to the wide overview. It passed **14 desktop/mobile API workflows**, 1,913 units, types, lint/license (five existing warnings), clean export and bundle budgets. Desktop/mobile light/dark screenshots were inspected. The reviewed search correction independently passed **601 full Chromium workflows and all 391 required feature rows**, with three approved external-service exclusions and zero failures/skips/flaky outcomes/report errors; its unit total is 1,919 and it also passed 34 focused browser cases. These are separate source checks; combined candidate verification is next. Earlier failed runs remain retained.
## Corrected candidate verification and publication
Corrected source `cd3d473` passed **1,919 unit tests**, types, lint/license (five existing warnings), clean static export, Maven packaging and bundle budgets. All 193 packaged dashboard files match the export. The actual local JAR passed **24 affected desktop/mobile workflows**, with zero failures/skips/flaky outcomes/report errors. Local full Chromium and [hosted primary acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799502) each passed **602 cases and all 391 required feature rows**, with three approved external-service exclusions and zero failures/skips/flaky outcomes/report errors. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799444) and [image build](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799498) passed. Corrected WebKit finished **600 passed / 2 failed** (NAV-034 legacy process-URL redirect timeout; REL-005 internal navigation error), zero skips/flaky/report errors. Reports/traces are retained and causes remain unproven. Corrected Firefox finished **600 passed / 2 failed** (NAV-014 font-download diagnostics during breadcrumbs; REC-009 save-success toast not observed after updated record display), zero skips/flaky/report errors. The corrected touch step is marked cancelled; its retained artifact has no final report or gate. No passing touch total is claimed. At artifact collection, GitHub still reported the job in progress despite every exposed step being completed. The cancellation cause is unconfirmed; all four candidate artifacts were archived before the later documentation push.
The original candidate finished WebKit 596/0, mobile 557/0 and tablet 556/1 (REL-005 internal WebKit navigation error). Its unfinished Firefox job was cancelled by the owner-requested candidate push; no passing Firefox result is claimed. Earlier failures remain retained as history and are not erased by the corrected primary passes.
Signed RC8 tag publication started CircleCI Maven jobs 141 and 142, [image publication36800964056](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36800964056), and [tag acceptance36800964105](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36800964105). CircleCI jobs 141 and 142 and image publication 36800964056 completed successfully. Maven Central initially returned HTTP 404 after upload; the public JAR is now available and verified (October 1). Its SHA-1 matches Central, the signature matches publishing key `68A49476754103988EA3ED924B88CBB3D6DCB237`, and a fresh Maven consumer resolves identical bytes. The public JAR passed all **28 affected desktop/mobile workflows**, zero failures/skips/flaky outcomes/report errors. SHA-256: `238c9ae80beea336717be759219f0d65a1d614e8e7d6a145a16374cc392a73a1`. The anonymous public image manifest/pull passed (AMD64/ARM64); the downloaded ARM64 image served its route, all 21 scripts, both stylesheets and the pinned font successfully. Manifest digest `sha256:2be3b74f614b1d038d79aba2894b210b988025000e86a572364a902fb0106c25`. This is an image delivery/assets check, not a replacement for backend workflows.
The immutable tag's Chromium job in run 36800964105 also passed **602 cases and all 391 required rows**, with three approved exclusions and zero failures/skips/flaky outcomes/report errors. Its complete artifact is retained separately in `rc8-tag-ci-chromium/`; tag WebKit completed **601 passed / 1 failed** (PRC-021 process display after a failed `/qqq/v1/manageSession` request with a WebKit internal error), with zero skips/flaky outcomes/report errors. Its complete artifact is retained in `rc8-tag-ci-webkit/`; cause unproven. Tag Firefox finished **600 passed / 2 failed** (NAV-015 and SEC-043 font-download diagnostics), with zero skips/flaky outcomes/report errors. No user-isolation assertion failure is reported for SEC-043; the font diagnostics still fail the test and their cause is unproven. The complete artifact is retained in `rc8-tag-ci-firefox/`. Tag mobile passed **563 cases**; tablet passed **559 and failed 4**: NAV-025 Clear-search focus in light/dark, REL-005 WebKit internal navigation error, and SEC-043 recent-record visibility after same-user reload before the user switch. Zero skips/flaky outcomes/report errors. Full artifacts are retained in `rc8-tag-ci-touch/`; these failures remain open.
## Remaining requirements
Complete embedded report/query equivalence, remaining input configurations, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next remains the visual reference. Do not infer full compatibility from the current acceptance matrix.
Use the QQQ 4.1.0-SNAPSHOT development backend with report-variant and configured Google Drive support. Record the application's exact backend commit. This candidate does not release QQQ 4.1.
## Local owner preview
A separate RC8 preview is running at `http://127.0.0.1:18781/app/person/` using the public ARM64 container and an isolated sample backend. Entry/grid/branding/custom-component checks passed with five Person rows and zero page errors in light mode. The fixture proxy routes acceptance assets and sample branding; the container uses explicit fixture CSP sources. This preview check does not claim full workflow coverage. RC7 remains separately available at port 18779.
Additional diagnostic evidence: candidate Firefox REC-009 captured the save-success toast before an approximately 7.45-second automation delay preceding later checks. Its four-second toast had expired before the subsequent visibility check; save/refetch requests succeeded quickly. This establishes that the notification appeared, while the automation delay remains unexplained. A separate Chromium probe of the local RC8 JAR verified that a preview popup carries temporary sort and stored criteria, matches SQL row order, and leaves the saved report unchanged. These findings do not alter the failed-run totals or the 602-case gate.
+41
View File
@@ -0,0 +1,41 @@
# Next UI 1.0.0-RC.9
Release candidate for application testing, October 1, 2026. The [GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) records public artifact availability and final delivery verification; a source tag alone is not delivery confirmation. Final 1.0 remains held for owner application testing and explicit approval.
## Changes from RC8
RC9 integrates the GitHub tracking policy, current roadmap links, and updated developer/agent documentation. Application code and dependencies are unchanged from RC8. This candidate does not claim fixes for the open functional or visual findings.
[QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713) is the source of truth for current status and remaining work. Original issue reports and historical acceptance evidence remain available. See the [tracking policy](../ISSUE-TRACKING.md).
## Verification baseline and known issues
The documentation source `354ee2709d8ee553de5e3ad823ce9d1190887d4c`, merged through PR #16, passed general CI (typecheck, lint, unit coverage, mocked Chromium E2E, Storybook and bundle budgets) and both container architecture builds. Real-backend [run 36861609503](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36861609503) passed all 602 Chromium cases and all 391 required rows, with three approved real-provider exclusions.
Its secondary reports remain failed: Firefox 599 passed / 3 failed (NAV-014, NAV-016 and REC-056 legacy-font diagnostics, [#951](https://github.com/QRun-IO/qqq/issues/951)), WebKit 601 / 1 (WID-073 metadata access-control diagnostic, [#961](https://github.com/QRun-IO/qqq/issues/961)), and combined touch 1,124 / 2 (tablet NAV-025 and REL-005). Reports contain zero skips/flaky outcomes/report errors. These are pre-RC9-version source results; final tag results and public artifact receipts are recorded separately below. No all-browser pass is claimed.
Current open work includes embedded report/query equivalence, remaining input configurations, subpath hosting, real Auth0/Google Drive/QuickSight checks, browser investigations, original-Next visual approval, real-application soak and final release approval. See [#713](https://github.com/QRun-IO/qqq/issues/713) and [browser evidence](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual issues. Material remains the behavior reference; the original Next design remains the visual reference.
## Versioned local verification
RC9 passes all 1,919 unit tests across 181 files, typechecking, lint/license (zero errors, five existing warnings), production export, Maven packaging and unchanged bundle budgets. All 193 packaged dashboard files are present; the Maven version is RC9 and no mock service worker is included. The actual locally packaged RC9 JAR passes 28 affected desktop/mobile browser cases with zero failures/skips/flaky outcomes/report errors. These checks cover API controls/theme/search focus, exact numeric persistence and composite field submission against backend e0d57dd31. They do not replace public artifact verification or certify other profiles.
## Installation
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.9`.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.9` (AMD64/ARM64 verified). The container backend origin is fixed at build time; the published image targets `host.docker.internal:8000`. Use the JAR or rebuild for another origin.
As with RC8, verification uses the QQQ 4.1 development backend. The locally verified backend source is `e0d57dd31b979b17215370546ef4e26cd8a38d21`; hosted workflows resolve their configured QQQ ref separately. This release does not publish QQQ 4.1 or certify every real consumer/backend combination.
## Public delivery — October 1, 2026
[RC9](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) was published at 19:25:28 UTC as a prerelease, latest=false. PR16 and PR14 are merged. The signed immutable tag points to `c029ad2a70948aac6469d8a71ccefc80c6e00d74`.
- Public JAR: 1,864,121 bytes; SHA256 `969034566c6b30a5034680dcf2d46aec327240db6cdd3fb7b31f1fae7cb9f17d`; SHA1 `3943c8641c14554999206cf52357c32b8f397b7b`.
- Release signature verified against fingerprint `68A49476754103988EA3ED924B88CBB3D6DCB237`; a clean ordinary Maven consumer resolved identical bytes without manual installation.
- Actual public JAR: all 28 targeted desktop/mobile cases passed, zero failures/skips/flaky outcomes/report errors. All 193 dashboard files and RC9 version markers are present, without the mock service worker.
- Public image: `sha256:c2c684733deb98fd8355d4a9e2a53bf4af429bd16d9e7802641f9ef9d44e1017`. Anonymous AMD64/ARM64 pulls and both architecture startup, route, JavaScript, pinned-font and version checks passed.
- Merged-source general CI36912597438 passed. Fresh full dependency audit reports zero advisories across1,038dependencies; GitHub marked all four outstanding alerts fixed, none dismissed. Issues #647/#698 are closed with that evidence.
Full immutable-tag acceptance [36912641399](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36912641399) completed after publication: Chromium 602 passed / 0 failed; desktop WebKit 602 / 0; Firefox 600 / 2; mobile 563 / 0; tablet 560 / 3. All reports contain zero skips, flaky outcomes and report errors. Firefox failed NAV-016 legacy-font diagnostics and REC-048 validation-toast visibility; tablet failed NAV-015 JavaScript loading, NAV-016 root navigation (both WebKit internal errors), and NAV-025 Clear-search focus in dark mode. The overall run failed; later passing cases do not resolve older investigations. Follow [frontend#12](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual failure tracking and [#713](https://github.com/QRun-IO/qqq/issues/713) for the live backlog. Final 1.0 remains held.
+24
View File
@@ -0,0 +1,24 @@
# Next UI — unreleased
The browser stability changes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are under review and verification. No new version or release is declared here. Published [RC9](1.0.0-RC.9.md) remains immutable and does not contain these fixes. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
## Product fixes
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
## Browser testing and infrastructure
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
## Verification and remaining work
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes; no corrected all-browser pass or release approval is claimed by this changelog. The original causes of the same-user session stall and legacy process-navigation stall remain unresolved in [#952](https://github.com/QRun-IO/qqq/issues/952) and [#960](https://github.com/QRun-IO/qqq/issues/960).
Final 1.0 remains held for real-application soak and explicit owner approval under [#712](https://github.com/QRun-IO/qqq/issues/712). This work does not authorize a QQQ 4.1 release. See the [testing-period policy](1.0-testing.md) and [issue lifecycle](../ISSUE-TRACKING.md).
+299
View File
@@ -0,0 +1,299 @@
# Next UI security review (QRun-IO/qqq#696)
Independent review of the QQQ Next admin UI against OWASP ASVS 4.0.3 Level 2, for the
Next UI 1.0 milestone (epic QRun-IO/qqq#713). Reviewed on 2026-09-25 at
`feature/GH-649-next-acceptance` (`2b6569f`, release 0.2.0) with the backend at
`feature/GH-649-next-default` (`f7bf8c4ac`). Fixes are on `feature/next-1.0-security`
(frontend), `feature/next-1.0-security-backend` (qqq) and, for TABLE_BASED,
`feature/next-1.0-security-auth-backend` (qqq, on top of `feature/next-1.0-auth-backend`).
## Scope
- Sign-in lifecycle for every supported authentication type: MOCK, FULLY_ANONYMOUS,
OAUTH2 (authorization code + PKCE, code redeemed by the backend) and AUTH_0
(authorization code + PKCE as a public client). TABLE_BASED (password sign-in, being added
in QRun-IO/qqq#700) was reviewed at the backend: session validation, password attempts
and the protection of its user and session tables (NUI-12 to NUI-14).
- Token and session storage in the browser: the `sessionUUID` / `sessionId` cookies, the
localStorage auth-metadata cache and stored identity, the sessionStorage PKCE attempt.
- Logout and server-side revocation (QRun-IO/qqq#674, #675).
- Post-login `returnTo` handling (QRun-IO/qqq#669).
- HTML rendering: every `dangerouslySetInnerHTML` / `innerHTML` sink (field values, grid
cells, help content, widgets, blocks, process steps, banners, the rich text editor) and
branding `customCss`.
- Permission gating in the UI (QRun-IO/qqq#671) and its server-side enforcement.
- Response headers for the dashboard (QRun-IO/qqq#695, delivered with this review).
Out of scope: the QQQ backend authentication modules themselves (token validation, session
store lifetime), the Material dashboard, and deployment concerns (TLS, HSTS at the proxy).
## Method
Manual code review of `src/lib/auth`, `src/lib/api`, `src/app/(auth)`, the dashboard layout
and every HTML sink, the backend session and cookie code in `qqq-middleware-javalin`, and the
built static export (inline scripts, runtime style injection, external origins). Each
finding was reproduced (unit test or browser) before it was fixed, and every fix has a
regression test: a Vitest unit test and, where the behavior is visible end to end, a row in
the real-backend acceptance matrix (`tests/acceptance/matrix/security.json`), run in
Chromium, Firefox, WebKit and the phone profile.
## Summary
| ID | Finding | ASVS 4.0.3 | Severity | Disposition |
|----|---------|------------|----------|-------------|
| NUI-01 | `returnTo` paths with dot segments normalize to `//host` (open redirect) | 5.1.5 | Medium | Fixed |
| NUI-02 | Session cookies had no `SameSite` and no `Secure` | 3.4.1, 3.4.3 | Medium | Fixed (backend) |
| NUI-03 | Sanitized HTML could still add forms, password inputs, style sheets and page-covering overlays | 5.2.1 | Medium | Fixed |
| NUI-04 | No Content-Security-Policy, framing, referrer or permissions headers on the dashboard | 14.4.3, 14.4.6, 14.4.7 | Medium | Fixed (#695) |
| NUI-05 | `sessionUUID` is readable by script (not `HttpOnly`) | 3.4.2 | Medium | Follow-up QRun-IO/qqq#733 |
| NUI-06 | Recently viewed records and the stored identity survive a session expiry and are shown to the next user of the browser | 8.2.3 | Low | Fixed |
| NUI-07 | The login page displays arbitrary `?error=` text (content spoofing) | 7.4.1 | Low | Fixed |
| NUI-08 | Unused browser auth SDKs and cookie/crypto helpers in the dependencies | 14.2.2 | Low | Fixed |
| NUI-09 | The standalone (container image) Next server sends none of the #695 headers | 14.4.3 | Low | Follow-up QRun-IO/qqq#734 |
| NUI-10 | `style-src` allows `'unsafe-inline'` | 14.4.3 | Info | Accepted |
| NUI-11 | Branding `customCss` is filtered with a deny-list | 5.2.1 | Info | Accepted |
| NUI-12 | TABLE_BASED: the inactivity timeout was not checked for 30 minutes after each validation | 3.3.2 | Medium | Fixed (backend) |
| NUI-13 | TABLE_BASED: no rate limiting or lockout of password attempts | 2.2.1 | Medium | Fixed (backend) |
| NUI-14 | TABLE_BASED: the user table (password hashes) and session table (session ids) were readable by any session unless the application protected them | 4.1.3, 8.3.4 | High | Fixed (backend) |
No Next UI finding allowed script execution, session theft without prior script execution,
or a permission bypass: the backend enforced every permission the UI hides (SEC-001 to
SEC-015 prove the refusals by API and SQL), DOMPurify removed all active content, and React
19 blocks `javascript:` URLs. The most serious finding is in the TABLE_BASED backend module
(NUI-14): its default metadata left the session table, and with it other users' session ids,
readable by any signed-in session.
## Findings
### NUI-01 Open redirect through dot segments in `returnTo` (fixed)
`safeReturnTo()` rejected `//host` and `/\host` before parsing, then returned the parsed
`pathname`. The WHATWG URL parser removes dot segments, so `/.//evil.example`,
`/..//evil.example`, `/app/..//evil.example` and `/app/../\evil.example` passed the check and
came back as `//evil.example`, which `router.replace()` treats as another origin. The login
page and the OAuth callback redirect to this value.
Fix: `src/lib/auth/return-to.ts` also rejects a normalized path that starts with `//` or
`/\`. Tests: `src/lib/auth/return-to.test.ts` (seven bypass shapes, harmless dot segments
still normalize); acceptance SEC-024 (new negative scenario, `headers.spec.ts`).
### NUI-02 Session cookies without `SameSite` and `Secure` (fixed, backend)
`sessionUUID` and `sessionId` were set with `context.cookie(name, value, maxAge)`: no
`SameSite`, no `Secure`. Chromium treats that as `Lax`, but Firefox and Safari send such
cookies with cross-site POSTs, and several legacy endpoints accept form-encoded and multipart
bodies, so another site could submit requests with the user's session (CSRF, ASVS 4.2.2).
Over HTTPS the cookies could also be sent over plain HTTP.
Fix: `QJavalinImplementation.setSessionCookie()` sets both cookies (legacy and v1
manageSession, the per-request `sessionId` refresh) with `Path=/`, `SameSite=Lax`, and
`Secure` when the request came over HTTPS (the connection or `X-Forwarded-Proto`). Plain
HTTP development keeps working. The OAuth return from the identity provider is a top-level
GET, which Lax cookies accompany. Tests: `ManageSessionSpecV1Test.testSessionCookieAttributes`;
acceptance SEC-044.
### NUI-03 Stored HTML could impersonate the application (fixed)
All sinks used DOMPurify's default profile. It removes scripts, handlers and `javascript:`
URLs, but keeps `<style>`, `<form>`, form controls and inline `position: fixed`. An HTML field
value (any user who can edit the record), a widget or a process step could therefore show a
full-page "session expired" overlay with a password field that posts elsewhere, or restyle
the whole page, inside the trusted origin.
Fix: one sanitizer, `src/lib/utils/sanitize-html.ts`, used by every sink (FieldValue,
DataCell, HelpContent, SafeHtml and the widgets that use it, BlockWidget, HtmlComponent and
ProcessStepScreen, Banner, RichTextField). It keeps DOMPurify's defaults and additionally
removes `style`, `form`, `input`, `button`, `textarea`, `select`, `option`, `optgroup`,
`datalist` and `dialog`, and drops inline `position` other than `static`/`relative`
(including `var()` values). Formatting, links, images, tables and other inline styles stay.
The Content-Security-Policy's `form-action 'self'` blocks off-site form posts as a second
layer. Tests: `src/lib/utils/sanitize-html.test.ts`; acceptance SEC-042 (a stored form,
password input, style element and fixed overlay render as plain content; the database value
is unchanged); REC-020 still passes.
### NUI-04 No security headers on the dashboard (fixed, QRun-IO/qqq#695)
`NextDashboardRouteProvider` sent only `X-Content-Type-Options`. It now sends, per
`NextDashboardSecurityHeaders`:
```
Content-Security-Policy: default-src 'self'; script-src 'self' <custom component origins> 'sha256-<each inline script>';
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:;
connect-src 'self' <identity provider origins>; frame-src 'self' <QuickSight when used>;
worker-src 'self' blob:; manifest-src 'self'; media-src 'self' data: blob:; object-src 'none';
base-uri 'self'; form-action 'self'; frame-ancestors 'none'
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), hid=(),
magnetometer=(), microphone=(), midi=(), payment=(), serial=(), usb=()
X-Content-Type-Options: nosniff
```
- The static export has two inline scripts per page (the router payload bootstrap). The
provider hashes the inline scripts of each exported HTML file once and lists exactly those
hashes, so no other inline script, inline event handler or `javascript:` URL runs, and no
`'unsafe-eval'` is needed.
- Origins come from metadata: OAUTH2 and AUTH_0 `baseUrl` (browser discovery and the Auth0
token exchange), `https://*.quicksight.aws.amazon.com` when a quickSightChart widget
exists, and each customComponent widget's `componentSourceUrl` origin.
- Override hook: `QApplicationJavalinServer.withNextDashboardSecurityHeadersCustomizer(...)`
(or `NextDashboardRouteProvider.withSecurityHeadersCustomizer`) receives the headers after
those additions, can add, replace or remove directives and headers, switch to report-only,
and runs again on hot swap. Values are validated (no `;`, `,` or line breaks).
- Referrer: other origins get only the application origin, never a path or query (the
`/token?code=` URL included).
Tests: `NextDashboardRouteProviderTest` (exact headers, hashes computed independently,
instance origins, customizer and hot swap, report-only/disabled, rejected values) and
`QApplicationJavalinServerTest`; acceptance SEC-038 (every document type, hashes match the
served document, framing refused, injected inline script and handler refused and reported),
SEC-039 (OAUTH2 and AUTH_0 sign-in under the policy, other origins refused), SEC-040 (the
acceptance application's override hook and the metadata-derived origins). The diagnostics
fixture now fails any test on a `securitypolicyviolation` event, in every frame and browser,
so the whole suite proves the policy breaks nothing.
### NUI-05 `sessionUUID` readable by script (follow-up QRun-IO/qqq#733)
The backend sets `sessionUUID` without `HttpOnly` because both dashboards read it:
the Next UI resumes OAUTH2/AUTH_0 sessions by posting the value to `manageSession`, and the
Material dashboard does the same. Making it `HttpOnly` needs a backend resume-from-cookie
path and changes in both UIs, which is outside this review. Mitigations in place: the
Content-Security-Policy blocks injected script (NUI-04), the sanitizer (NUI-03), `SameSite`
and `Secure` (NUI-02), and server-side logout invalidation (SEC-033).
### NUI-06 Per-user browser data survived session expiry (fixed)
Logout cleared the recently viewed records and stored identity, but a session that expired
(or was revoked) left them in localStorage; the next person to sign in on the browser saw the
previous user's record labels on the home page, and an OAUTH2 resume without identity values
could show the previous user's name.
Fix: `claimClientData()` (`src/lib/auth/auth-storage.ts`) binds that data to the signed-in
identity; the auth provider calls it before storing a new identity (session creation, resume
and the OAuth callback), and a different identity clears it. Tests:
`src/lib/auth/auth-storage.test.ts`; acceptance SEC-043.
### NUI-07 Login page content spoofing (fixed)
`/login?error=<text>` rendered `Sign-in failed (<text>).` for any value, so a link could put
arbitrary instructions ("call this number") on the sign-in page. React escaped it, so this
was spoofing, not injection.
Fix: `src/lib/auth/callback-errors.ts` explains known OAuth and callback codes, shows other
well-formed codes (`^[a-z][a-z0-9_]{0,63}$`) as a code, and shows `Sign-in failed.` for
anything else. Tests: `src/lib/auth/callback-errors.test.ts`; acceptance SEC-041.
### NUI-08 Unused auth dependencies (fixed)
`@auth0/auth0-react`, `oidc-client-ts`, `universal-cookie` and `ts-md5` were declared but
never imported: the flows are implemented once in `src/lib/auth/oidc.ts`. They were not in
the bundle, but looked like a second auth stack to reviewers and dependency scanners. They
are removed; `src/lib/auth/auth-dependencies.test.ts` keeps them out. (Other unused runtime
dependencies, such as `lodash` and `date-fns`, are not security-relevant and are left to the
dependency work in QRun-IO/qqq#698.)
### NUI-09 Standalone server headers (follow-up QRun-IO/qqq#734)
The container image runs the Next standalone server, which does not go through
`NextDashboardRouteProvider`. It needs the same headers; the script hashes have to be
computed from the prerendered pages after the build. Applications served by QQQ (javalin
mode, the default) have them now.
### NUI-10 `style-src 'unsafe-inline'` (accepted)
The UI creates style elements at runtime: the dialog scroll lock (react-remove-scroll /
react-style-singleton, used by every Radix dialog), sonner toasts, React's style hoisting and
branding `customCss`, and the prerendered pages carry style attributes. Hashes cannot cover
runtime styles and a per-response nonce does not fit a static export. Inline styles cannot
run script; the injection paths for style elements from data are closed by the sanitizer
(NUI-03), and image exfiltration through CSS is limited to HTTPS origins.
### NUI-11 `customCss` deny-list (accepted)
Branding `customCss` comes from the application's metadata (developer-controlled, not user
data). The layout strips `</style>`, `expression(`, `@import`, `javascript:` and `data:` URLs
and applies it through `textContent`. With the new policy it cannot load script, fonts or
frames from other origins. Accepted as trusted configuration.
### TABLE_BASED authentication (NUI-12 to NUI-14)
Added to the review scope from the TABLE_BASED work (QRun-IO/qqq#700, branch
`feature/next-1.0-auth-backend`). The fixes are on `feature/next-1.0-security-auth-backend`,
which is based on that branch and must be merged after it.
**NUI-12 Inactivity timeout not enforced promptly (fixed).**
`TableBasedAuthenticationModule.isSessionValid` trusted a validation for 30 minutes without
looking at the session's idle time, so an `inactivityTimeoutSeconds` below about 30 minutes
was not enforced; the table's access time was also only refreshed on revalidation, which
could expire an active session with a short timeout. Now the last request per session is
kept next to the last validation, a request after more than the timeout of idleness is
checked against the table again (using the later of the table's and this server's last
activity), and a validation is trusted for at most half the timeout (still at most 30
minutes). Expired validations are forgotten. Tests: `TableBasedAuthenticationModuleTest`
(`testShortInactivityTimeoutIsEnforcedPromptly`, `testActiveSessionIsNotExpiredEarly`,
`testValidationIntervalIsAtMostHalfTheTimeout`).
**NUI-13 No password attempt limiting (fixed).** Password sign-in (Basic credentials on v1
`manageSession` and every other Basic-auth path into the TABLE_BASED module) had no limit.
A soft lockout now refuses a username for `signInLockoutSeconds` (default 900) after
`maxFailedSignInAttempts` (default 5) failures within that window, before the password is
checked, with "Too many failed sign-in attempts. Try again later." Unknown usernames are
counted the same way (no enumeration; the unknown-user path already hashes). A success
resets the count; both settings are on `TableBasedAuthenticationMetaData`, and 0 disables
the lockout. The count is kept per server in memory; per-address limiting belongs at the
proxy. Tests: `TableBasedAuthenticationModuleTest` (`testSignInLockout`,
`testSignInLockoutResetAndDisable`), `TableBasedSessionSpecV1Test.testRepeatedFailuresLockTheUsernameOut`
(over HTTP; no session row is created).
**NUI-14 Authentication tables readable (fixed).** With the instance's default permission
rules (often `NOT_PROTECTED`), any signed-in session could query the user table (password
hashes) and the session table (whose ids are bearer tokens for other users' sessions).
`QInstanceEnricher` now protects the configured user and session tables of every
TABLE_BASED provider when the application has not set permission rules on them:
`READ_INSERT_EDIT_DELETE_PERMISSIONS` with `DenyBehavior.HIDDEN`, so only sessions granted
`user.read` (and so on) see them. The password hash field is always hidden, so the API never
returns it even to such users. The module reads the hash itself with hidden fields
included, and works on both tables with an internal session that holds every permission.
Applications that set their own rules keep them. Tests:
`TableBasedAuthenticationModuleTest.testAuthenticationTablesProtectedByDefault`.
## Verified without findings
| Area | What was checked | Evidence |
|------|------------------|----------|
| PKCE | S256; 32-byte verifier (43 chars); 16-byte state; state removed before comparison (single use); verifier removed after use and on failure; fixed redirect URI `{origin}/token` | `src/lib/auth/oidc.ts`, `CallbackContent.tsx`; SEC-025, SEC-026 (forged state rejected before any token exchange) |
| OAUTH2 code exchange | Redeemed by the backend with its client secret and the verifier; the browser never holds tokens | SEC-025 |
| AUTH_0 tokens | Public client with PKCE; the access token is only posted to `manageSession` and never stored; the ID token is used for display only and the backend verifies the access token against the provider keys | `auth-provider.tsx`; SEC-029, SEC-033 |
| Anonymous types | MOCK and FULLY_ANONYMOUS create a session through `manageSession`; a denied session is an error, never a silent anonymous fallback | SEC-020, SEC-023, SEC-031 |
| Logout | Backend session deleted and every issued cookie expired (#674, #675); provider end-session called; client caches, identity and recent records cleared; the tab stays signed out | SEC-021, SEC-027, SEC-033 |
| Expiry and revocation | Any 401 outside the session endpoints clears the query cache and returns to login with `returnTo`; the automatic re-authentication loop stops after three attempts a minute | `client.ts`, `auth-storage.ts`; SEC-022, SEC-028 |
| Browser storage | localStorage: auth metadata (public configuration, 10 minute TTL), display identity, recent records, UI preferences; sessionStorage: one PKCE attempt and the signed-out marker; no tokens or secrets | `auth-storage.ts`, `api/auth.ts`, `oidc.ts` |
| Links | React 19 replaces `javascript:` URLs; link adornments and file URLs are limited to http(s) and same-origin paths; every `target="_blank"` link has `rel="noopener noreferrer"` | `adornment-utils.ts`, `FieldValue.tsx`, `BlockSlot.tsx` |
| Permission gating | Tables, records, processes, reports, widgets and apps are hidden or disabled from metadata permissions and capabilities; the backend refuses the same actions | `permissions.ts`; SEC-001 to SEC-015 |
| Secrets in metadata | Widget secrets (QuickSight keys) never reach the browser | WID-015 |
## Evidence
Recorded 2026-09-25 on macOS, sample `4.1.0-SNAPSHOT` built from
`feature/next-1.0-security-backend`, static export of `feature/next-1.0-security`, the
policy on and the diagnostics fixture failing on any CSP violation:
- `QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile node scripts/acceptance.mjs`:
chromium 337/337, webkit 337/337, mobile 15/15, firefox 336/337. The one Firefox failure
was REC-045 (a possible-value search result not listed within 15 s under machine load; no
diagnostics or policy violation); it passed 5 of 5 on repeat and in a rerun of the records
area. Every new row (SEC-038 to SEC-044) passed in every project that runs it.
- Unit: `pnpm test` 96 files / 957 tests; `pnpm typecheck`; `pnpm lint` (0 errors).
- Backend: `mvn -pl qqq-middleware-javalin verify` 473 tests, checkstyle 0, coverage met;
`mvn -pl qqq-backend-core verify` (TABLE_BASED branch) 1988 tests, checkstyle 0, coverage
met; `TableBasedSessionSpecV1Test` 3/3.
## Residual risk and recommendations
- Session lifetime (ASVS 3.3.2) is owned by the backend authentication modules and the
session store; cookies last 24 hours. TABLE_BASED now enforces its inactivity timeout
(NUI-12); it has no absolute lifetime, and the other modules follow their provider.
- The TABLE_BASED lockout (NUI-13) is per server and in memory; clustered deployments get
one budget per node, so put per-address rate limiting at the proxy as well.
- HSTS belongs at the TLS terminator in front of the application.
- NUI-05 and NUI-09 are tracked in QRun-IO/qqq#733 and #734.
+73 -11
View File
@@ -1,16 +1,78 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
import nextConfig from 'eslint-config-next'
import prettierConfig from 'eslint-config-prettier'
import jsdoc from 'eslint-plugin-jsdoc'
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
...nextConfig,
prettierConfig,
{
rules: {
// React Compiler rules — project does not use React Compiler; disable to avoid false positives
'react-hooks/purity': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/refs': 'off',
'react-hooks/incompatible-library': 'off',
'react-hooks/globals': 'off',
},
},
// ---------------------------------------------------------------------------
// JSDoc enforcement — all TypeScript source files except tests and stories
// ---------------------------------------------------------------------------
{
files: ['src/**/*.{ts,tsx}'],
ignores: [
'src/**/*.test.{ts,tsx}',
'src/**/*.spec.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
],
plugins: { jsdoc },
settings: {
jsdoc: {
mode: 'typescript',
},
},
rules: {
// Require JSDoc on all exported functions, classes, and methods
'jsdoc/require-jsdoc': [
'error',
{
publicOnly: false,
require: {
FunctionDeclaration: true,
MethodDefinition: true,
ClassDeclaration: true,
ArrowFunctionExpression: false, // too noisy for inline callbacks
FunctionExpression: false, // too noisy for inline expressions
},
checkConstructors: false, // constructors documented on the class
contexts: [
// Exported arrow functions assigned to a const
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression',
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > FunctionExpression',
],
enableFixer: false,
},
],
// Every JSDoc block must have a non-empty description
'jsdoc/require-description': ['error', { descriptionStyle: 'any' }],
// Params and returns
'jsdoc/require-param': ['error', { checkDestructured: false }],
'jsdoc/require-param-description': 'error',
'jsdoc/require-returns': ['error', { forceReturnsWithAsync: false }],
'jsdoc/require-returns-description': 'error',
// Correctness checks
'jsdoc/check-param-names': ['error', { checkDestructured: false }],
'jsdoc/check-tag-names': ['error', { typed: true }],
'jsdoc/no-undefined-types': 'off', // too many false positives with complex TS generics
// File-level overview
'jsdoc/require-file-overview': 'error',
},
},
]
export default eslintConfig
+33 -3
View File
@@ -1,13 +1,43 @@
import type { NextConfig } from 'next'
import packageJson from './package.json'
import backendPrefixes from './standalone/backend-prefixes.json'
/**
* Two build outputs:
* - `QQQ_NEXT_OUTPUT=export`: static files for the QQQ Javalin server to host from its
* classpath (the default dashboard). Same origin as the API, so no rewrites.
* - otherwise `standalone`: a Node server; `QQQ_BACKEND_URL` bakes same-origin API rewrites
* into the build (the quickstart container image). The image starts it through
* standalone/qqq-server.mjs, which adds the dashboard security headers (QRun-IO/qqq#734).
*/
const exportBuild = process.env.QQQ_NEXT_OUTPUT === 'export'
/**
* Backend route prefixes the frontend calls; forwarded by the standalone server, which
* leaves the backend's responses to them without the dashboard's headers.
*/
const BACKEND_PREFIXES: string[] = backendPrefixes
const nextConfig: NextConfig = {
reactStrictMode: true,
// Reported to the backend as frontendVersion and shown on the developer page.
env: { NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version },
...(exportBuild
? { output: 'export', trailingSlash: true, images: { unoptimized: true } }
: {
output: 'standalone',
async rewrites() {
const backend = process.env.QQQ_BACKEND_URL
if (!backend) return []
return BACKEND_PREFIXES.map((prefix) => ({
source: `/${prefix}/:path*`,
destination: `${backend}/${prefix}/:path*`,
}))
},
}),
typescript: {
tsconfigPath: './tsconfig.json',
},
eslint: {
ignoreDuringBuilds: false,
},
}
export default nextConfig
+69 -44
View File
@@ -1,81 +1,106 @@
{
"name": "qqq-frontend-next",
"version": "0.1.0",
"version": "1.0.0-RC.9",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build:export": "QQQ_NEXT_OUTPUT=export NEXT_PUBLIC_MOCK_API=false next build",
"build:jar": "pnpm build:export && mvn -B -q clean package",
"start": "next start",
"lint": "next lint",
"lint": "eslint src && node scripts/check-license-headers.mjs",
"lint:jsdoc": "eslint src --ext .ts,.tsx",
"lint:license": "node scripts/check-license-headers.mjs",
"lint:license:fix": "node scripts/check-license-headers.mjs --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"tsc": "tsc"
"test:acceptance": "node scripts/acceptance.mjs",
"perf:budget": "node scripts/perf-budget.mjs",
"tsc": "tsc",
"typecheck": "tsc --noEmit",
"prepare": "git config core.hooksPath .githooks || true",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@auth0/auth0-react": "^2.2.4",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.69.0",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.8.4",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"dompurify": "^3.4.16",
"lodash": "^4.18.1",
"lucide-react": "^0.485.0",
"next": "15.2.3",
"oidc-client-ts": "^3.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"next": "16.3.6",
"rapidoc": "9.3.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"ts-md5": "^1.3.1",
"universal-cookie": "^7.2.2",
"zod": "^3.24.2"
"tailwind-merge": "^3.5.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/eslintrc": "^3.3.4",
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@playwright/test": "1.64.0-alpha-2026-10-01",
"@storybook/addon-docs": "10.6.0",
"@storybook/nextjs-vite": "10.6.0",
"@storybook/react": "10.6.0",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/jest-axe": "^3.5.9",
"@types/lodash": "^4.17.24",
"@types/node": "^22.19.11",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^3.0.9",
"@vitest/ui": "^3.0.9",
"autoprefixer": "^10.4.21",
"eslint": "^9.22.0",
"eslint-config-next": "15.2.3",
"eslint-config-prettier": "^10.1.1",
"jsdom": "^26.0.0",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-config-next": "16.3.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsdoc": "^62.7.1",
"jest-axe": "^10.0.0",
"jsdom": "^26.1.0",
"msw": "^2.12.10",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
"postcss": "^8.5.28",
"prettier": "^3.8.1",
"storybook": "10.6.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
},
"msw": {
"workerDirectory": [
"public"
]
},
"pnpm": {
"overrides": {
"serialize-javascript": ">=7.0.3",
"@humanfs/node": ">=0.16.8",
"@babel/core": ">=7.29.6 <8"
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit.",
"firstLoadJsGzipKB": 350,
"routes": {
"/": 255,
"/404": 255,
"/404.html": 255,
"/_not-found": 255,
"/callback": 255,
"/login": 255,
"/token": 255,
"/app/_": 350,
"/app/_/_": 350,
"/app/_/_/copy": 390,
"/app/_/_/edit": 350,
"/app/_/create": 350,
"/app/_/savedView/_": 350
},
"cssGzipKB": 21,
"totalJsGzipKB": 1050,
"largestChunkGzipKB": 100,
"lazyChunks": {
"rapidoc": {
"$comment": "RapiDoc (the table developer view's API docs, QRun-IO/qqq#714) loads only when that view opens: import('rapidoc') in TableApiDocs. Measured 204.5 KB gzip at 1.0; budget about 5% above.",
"marker": "customElements.define(\"rapi-doc\"",
"gzipKB": 215
}
}
}
+19 -7
View File
@@ -1,15 +1,25 @@
import { defineConfig, devices } from '@playwright/test'
// Mocked e2e suite: every API call is answered by page.route mocks (tests/e2e/api-mocks.ts),
// so no backend runs.
// - QQQ_E2E_PORT: the frontend port (default 3000).
// - QQQ_E2E_SERVER=production: serve the standalone build from `pnpm build` (what CI runs:
// no on-demand compilation, so no first-hit timeouts); otherwise `pnpm dev`.
const port = Number(process.env.QQQ_E2E_PORT || 3000)
const baseURL = `http://127.0.0.1:${port}`
const production = process.env.QQQ_E2E_SERVER === 'production'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
// No retries: a test that needs one is flaky and must be fixed, not re-run.
retries: 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
baseURL,
trace: 'retain-on-failure',
},
projects: [
{
@@ -18,8 +28,10 @@ export default defineConfig({
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
command: production ? 'node scripts/e2e-server.mjs' : `pnpm dev --port ${port}`,
url: baseURL,
env: { QQQ_E2E_PORT: String(port) },
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+4205 -1602
View File
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2026 QRun.IO, Inc.
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
-->
<!--
Packages the static export (QQQ_NEXT_OUTPUT=export pnpm build, written to out/) as a
resource-only jar with the files under next-dashboard/. QQQ's QApplicationJavalinServer
serves that folder at "/" when the jar is on the classpath. Build with: pnpm build:jar
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QQQ Frontend Next</name>
<description>Metadata-driven Next.js admin dashboard for QQQ, packaged for the QQQ Javalin server</description>
<url>https://github.com/QRun-IO/qqq-frontend-next</url>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qqq-frontend-next.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qqq-frontend-next.git</developerConnection>
<url>https://github.com/QRun-IO/qqq-frontend-next</url>
</scm>
<developers>
<developer>
<name>QRun.IO Development Team</name>
<organization>QRun.IO, Inc.</organization>
<organizationUrl>https://qrun.io</organizationUrl>
</developer>
</developers>
<properties>
<revision>1.0.0-RC.9</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
<build>
<resources>
<resource>
<directory>out</directory>
<targetPath>next-dashboard</targetPath>
<excludes>
<!-- development-only browser mock worker -->
<exclude>mockServiceWorker.js</exclude>
</excludes>
</resource>
<resource>
<directory>.</directory>
<targetPath>META-INF</targetPath>
<includes>
<include>LICENSE</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>require-static-export</id>
<phase>validate</phase>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireFilesExist>
<files>
<file>${project.basedir}/out/index.html</file>
<file>${project.basedir}/out/app/_/_/index.html</file>
</files>
<message>Run "QQQ_NEXT_OUTPUT=export pnpm build" (or pnpm build:jar) before packaging.</message>
</requireFilesExist>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<!-- Maven Central requires sources and javadoc jars; this artifact has no Java code. -->
<execution>
<id>empty-sources</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classifier>sources</classifier>
<classesDirectory>${project.basedir}/docs/maven-artifact</classesDirectory>
</configuration>
</execution>
<execution>
<id>empty-javadoc</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classifier>javadoc</classifier>
<classesDirectory>${project.basedir}/docs/maven-artifact</classesDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<!-- Release profile for Maven Central: sign and publish (used by qqq-orb mvn_jar_deploy) -->
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.4</version>
<configuration>
<keyname>${gpg.keyname}</keyname>
<gpgArguments>
<arg>--pinentry-mode</arg>
<arg>loopback</arg>
</gpgArguments>
</configuration>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.8.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>uploaded</waitUntil>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
# Legacy Material Icons fallback
These unmodified upstream files are distributed under the adjacent Apache-2.0 LICENSE.
Source: https://github.com/google/material-design-icons/tree/bd8cb85bd4bad964fe6918f79665bb40c3a8efef/font
- MaterialIcons-Regular.ttf SHA-256: ef149f08bdd2ff09a4e2c8573476b7b0f3fbb15b623954ade59899e7175bedda
- MaterialIcons-Regular.codepoints SHA-256: 530f25bf7b2d71c8e1da9476d53f9a9bb6b7e187bff69bb7128bb679b8194894
The codepoint JSON in src/lib/utils/material-icon-codepoints.json maps every line of the upstream inventory to its Unicode character. Existing Lucide mappings take precedence. This font supplies only valid names that have no existing mapping; it does not replace Next UI's icon design or use Google Fonts at runtime.
The inventory contains 2,235 rows and 2,234 unique names: `flourescent` appears twice with identical glyph outlines. The JSON retains the last entry for that name.
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Compiles the owned acceptance fixture against the sample JAR and runs it on loopback.
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import { ACCEPTANCE_MODE, EXPORT_CLASSPATH, resolveFixtureClasspath } from './acceptance-paths.mjs'
const port = process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'
const fixtureClasspath = resolveFixtureClasspath()
const classes = path.resolve('test-results/acceptance/fixture-classes')
rmSync(classes, { recursive: true, force: true })
mkdirSync(classes, { recursive: true })
const fixtureDirectory = path.resolve('tests/acceptance/fixture')
const sources = readdirSync(fixtureDirectory).filter((name) => name.endsWith('.java')).map((name) => path.join(fixtureDirectory, name))
const javac = spawnSync('javac', ['-proc:none', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
if (javac.status !== 0) {
console.error('Acceptance fixture compilation failed.')
process.exit(1)
}
if (!existsSync(path.join(classes, 'AcceptanceSampleServer.class'))) process.exit(1)
const server = spawn('java', [
'-Dqqq.sample.mockAuthentication=true',
'-Dqqq.sample.sharing=true',
`-Dqqq.sample.port=${port}`,
`-Dqqq.sample.esb.port=${process.env.QQQ_ACCEPTANCE_ESB_PORT ?? '61616'}`,
'-Duser.timezone=UTC',
// javalin mode: the fresh export shadows any dashboard bundled in the sample jar
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), classes, fixtureClasspath].join(path.delimiter),
...(ACCEPTANCE_MODE === 'javalin' ? ['-Dqqq.javalin.frontend=next'] : ['-Dqqq.javalin.frontend=none']),
'AcceptanceSampleServer',
], { stdio: 'inherit' })
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
server.on('exit', (code) => process.exit(code ?? 1))
+62
View File
@@ -0,0 +1,62 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Serves the production standalone build exactly as the published image does: through
// standalone/qqq-server.mjs, which adds the dashboard security headers (QRun-IO/qqq#734).
import { spawn } from 'node:child_process'
import { cpSync, existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { BUILD_MARKER } from './acceptance-paths.mjs'
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
const standalone = path.resolve('.next/standalone')
if (!existsSync(path.join(standalone, 'server.js')) || !existsSync(BUILD_MARKER)
|| readFileSync(BUILD_MARKER, 'utf8').trim() !== backend) {
console.error(`No production build for ${backend}; run node scripts/acceptance.mjs.`)
process.exit(1)
}
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true })
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true })
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'backend-prefixes.json']) {
cpSync(path.resolve('standalone', file), path.join(standalone, file))
}
/**
* The application's policy additions (its QuickSight embed, custom component bundles and
* the fixture's loopback service), which the QQQ server derives in javalin mode and a
* container deployment sets in QQQ_DASHBOARD_CSP_SOURCES.
*
* @returns {Promise<string>} The sources, once the acceptance backend is up.
*/
async function dashboardCspSources() {
const deadline = Date.now() + 120_000
while (Date.now() < deadline) {
try {
const response = await fetch(`${backend}/acceptance/dashboard-csp-sources`)
if (response.ok) return (await response.text()).trim()
} catch {
// the backend is still starting
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
throw new Error(`The acceptance backend at ${backend} did not answer /acceptance/dashboard-csp-sources.`)
}
const sources = await dashboardCspSources()
const server = spawn('node', ['qqq-server.mjs'], {
cwd: standalone,
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'production',
HOSTNAME: '127.0.0.1',
PORT: process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? '13765',
QQQ_DASHBOARD_CSP_SOURCES: sources,
},
})
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
server.on('exit', (code) => process.exit(code ?? 1))
+105
View File
@@ -0,0 +1,105 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Fails unless every required matrix row has at least one acceptance test and every test for it
// passed on the first attempt in every configured browser. Unknown IDs, skips and flakes fail.
// --partial checks only the rows exercised by a filtered run and never reports acceptance.
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
const partial = process.argv.includes('--partial')
const matrixFiles = readdirSync('tests/acceptance/matrix').filter((name) => name.endsWith('.json')).sort()
const matrix = { rows: matrixFiles.flatMap((name) => {
const area = JSON.parse(readFileSync(`tests/acceptance/matrix/${name}`, 'utf8'))
return area.rows.map((row) => {
if (!area.prefixes.some((prefix) => row.id.startsWith(prefix + '-'))) throw new Error(`${row.id} does not belong to ${name}`)
return { ...row, area: area.area }
})
}) }
const duplicates = matrix.rows.map((row) => row.id).filter((id, index, ids) => ids.indexOf(id) !== index)
if (duplicates.length) throw new Error(`Duplicate matrix IDs: ${duplicates.join(', ')}`)
const report = JSON.parse(readFileSync('test-results/acceptance/report.json', 'utf8'))
const rows = new Map(matrix.rows.map((row) => [row.id, row]))
const results = new Map()
const problems = []
const byProject = Object.fromEntries((report.config?.projects ?? []).map((project) => [project.name, { passed: 0, failed: 0, skipped: 0, flaky: 0 }]))
function walk(suite, titles = []) {
for (const child of suite.suites ?? []) walk(child, [...titles, child.title])
for (const spec of suite.specs ?? []) {
const ids = [...`${titles.join(' ')} ${spec.title}`.matchAll(/\[([A-Z]{3}-\d{3})\]/g)].map((match) => match[1])
if (ids.length === 0) problems.push(`Test without a matrix ID: ${spec.file} ${spec.title}`)
for (const test of spec.tests) {
const attempts = test.results ?? []
const outcome = test.status === 'skipped' ? 'skipped'
: attempts.length === 1 && attempts[0].status === 'passed' ? 'passed'
: attempts.some((attempt) => attempt.status === 'passed') ? 'flaky' : 'failed'
byProject[test.projectName] ??= { passed: 0, failed: 0, skipped: 0, flaky: 0 }
byProject[test.projectName][outcome]++
for (const id of ids) {
if (!rows.has(id)) problems.push(`Unknown matrix ID ${id} in ${spec.file}`)
const list = results.get(id) ?? []
list.push({ title: spec.title, project: test.projectName, outcome })
results.set(id, list)
}
}
}
}
for (const suite of report.suites ?? []) walk(suite)
const summary = { passed: 0, failed: 0, missing: 0, excluded: 0 }
for (const row of matrix.rows) {
if (row.required === false) {
summary.excluded++
if (!row.approval) problems.push(`${row.id} is excluded without a recorded approval`)
continue
}
const list = results.get(row.id)
if (!list) {
if (partial) continue
summary.missing++
problems.push(`${row.id} ${row.feature}: no acceptance test`)
continue
}
const bad = list.filter(({ outcome }) => outcome !== 'passed')
if (bad.length) {
summary.failed++
for (const { title, project, outcome } of bad) problems.push(`${row.id} ${outcome} [${project}] ${title}`)
} else summary.passed++
}
// Phone coverage (QRun-IO/qqq#708): when a touch project ran (mobile: phone, tablet: iPad), every
// required row passes there too, unless the row names why it is not a touch scenario (`desktopOnly`).
const touchProjects = ['mobile', 'tablet'].filter((project) => byProject[project])
const phone = { covered: 0, desktopOnly: 0, uncovered: 0 }
if (touchProjects.length) for (const row of matrix.rows) {
if (row.required === false) continue
if (row.desktopOnly !== undefined) {
if (typeof row.desktopOnly !== 'string' || !row.desktopOnly.trim()) problems.push(`${row.id} desktopOnly needs the reason it is not a touch scenario`)
phone.desktopOnly++
continue
}
const list = results.get(row.id) ?? []
if (partial && !list.length) continue
const missing = touchProjects.filter((project) => !list.some((result) => result.project === project && result.outcome === 'passed'))
if (missing.length) {
phone.uncovered++
problems.push(`${row.id} ${row.feature}: no passing @mobile test in ${missing.join(', ')}`)
} else phone.covered++
}
if (touchProjects.length) summary.phone = phone
// Every configured project must run something (the phone project runs only @mobile tests).
if (!partial) for (const [project, counts] of Object.entries(byProject)) {
if (Object.values(counts).every((count) => count === 0)) problems.push(`Project ${project} ran no tests`)
}
const status = { partial, generatedAt: new Date().toISOString(), summary, byProject, problems,
rows: matrix.rows.map((row) => ({ id: row.id, results: results.get(row.id) ?? [] })) }
writeFileSync('test-results/acceptance/gate.json', JSON.stringify(status, null, 2) + '\n')
console.log(`Acceptance gate${partial ? ' (partial run, not an acceptance result)' : ''}:`, JSON.stringify(summary), JSON.stringify(byProject))
for (const problem of problems) console.log(` FAIL ${problem}`)
process.exit(problems.length ? 1 : 0)
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Renders docs/acceptance/feature-matrix.md from tests/acceptance/matrix/*.json and, when
// present, the gate results of one or more runs (test-results/acceptance/gate.json or paths
// given as arguments, e.g. one per browser). Usage:
// node scripts/acceptance-matrix-md.mjs [gate.json ...] [--evidence "<versions and commands>"]
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
const args = process.argv.slice(2)
const evidenceIndex = args.indexOf('--evidence')
const evidence = evidenceIndex >= 0 ? args[evidenceIndex + 1] : ''
const gateFiles = args.filter((arg, index) => arg.endsWith('.json') && index !== evidenceIndex + 1)
if (gateFiles.length === 0 && existsSync('test-results/acceptance/gate.json')) gateFiles.push('test-results/acceptance/gate.json')
const runs = gateFiles.map((file) => ({ file, gate: JSON.parse(readFileSync(file, 'utf8')) }))
const outcomes = new Map()
for (const { gate } of runs) {
for (const row of gate.rows) {
const list = outcomes.get(row.id) ?? []
list.push(...row.results)
outcomes.set(row.id, list)
}
}
const escape = (text) => String(text ?? '').replaceAll('|', '\\|').replaceAll('\n', ' ')
const areas = readdirSync('tests/acceptance/matrix').filter((name) => name.endsWith('.json')).sort()
.map((name) => JSON.parse(readFileSync(`tests/acceptance/matrix/${name}`, 'utf8')))
let total = 0
let passing = 0
const sections = areas.map((area) => {
const lines = [`## ${area.description}`, '', '| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |', '|---|---|---|---|---|---|---|']
for (const row of area.rows) {
total++
const results = outcomes.get(row.id) ?? []
const projects = [...new Set(results.map((result) => result.project))].sort()
const ok = results.length > 0 && results.every((result) => result.outcome === 'passed')
if (ok && row.required !== false) passing++
const result = row.required === false ? `excluded (${escape(row.approval)})`
: results.length === 0 ? 'not run'
: ok ? `pass (${projects.join(', ')})`
: `FAIL: ${results.filter((r) => r.outcome !== 'passed').map((r) => `${r.project} ${r.outcome}`).join(', ')}`
const issues = (row.issues ?? []).map((issue) => {
const match = String(issue).match(/^(?:([\w.-]+\/[\w.-]+))?#?(\d+)$/)
if (!match) return escape(issue)
const repo = match[1] ?? 'QRun-IO/qqq'
return `[${repo === 'QRun-IO/qqq' ? '' : repo}#${match[2]}](https://github.com/${repo}/issues/${match[2]})`
}).join(' ')
lines.push(`| ${row.id} | ${escape(row.feature)} | ${escape((row.scenarios ?? []).join('; '))} | ${escape((row.negative ?? []).join('; '))} | ${escape(row.fixture)} | ${result} | ${issues} |`)
}
return lines.join('\n')
})
const header = [
'# Next UI feature acceptance matrix',
'',
'Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the',
'acceptance gate results. Each row is a supported QQQ UI feature or use case with executable',
'real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.',
'',
`Rows: ${total}. Required rows passing in the recorded runs: ${passing}.`,
'',
runs.length ? `Recorded runs: ${runs.map(({ file, gate }) => `\`${file}\` (${gate.generatedAt}${gate.partial ? ', partial' : ''})`).join(', ')}.` : 'No recorded run.',
...(evidence ? ['', evidence] : []),
'',
]
mkdirSync('docs/acceptance', { recursive: true })
writeFileSync('docs/acceptance/feature-matrix.md', header.join('\n') + '\n' + sections.join('\n\n') + '\n')
console.log(`Wrote docs/acceptance/feature-matrix.md (${total} rows, ${passing} passing required rows)`)
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { existsSync, readdirSync } from 'node:fs'
import { homedir } from 'node:os'
import path from 'node:path'
/** Sample JAR from QQQ_SAMPLE_JAR, else the newest jar-with-dependencies in a sibling qqq checkout. */
export function resolveSampleJar() {
const explicit = process.env.QQQ_SAMPLE_JAR
if (explicit) {
if (!existsSync(explicit)) throw new Error(`QQQ_SAMPLE_JAR does not exist: ${explicit}`)
return path.resolve(explicit)
}
const target = path.resolve('../qqq/qqq-sample-project/target')
const jars = existsSync(target) ? readdirSync(target).filter((name) => name.endsWith('-jar-with-dependencies.jar')) : []
if (jars.length !== 1) {
throw new Error('Set QQQ_SAMPLE_JAR to the qqq-sample-project jar-with-dependencies (see tests/acceptance/README.md).')
}
return path.join(target, jars[0])
}
/** Java dependencies shared by the main and authentication-variant fixtures. */
export function resolveFixtureClasspath(jar = resolveSampleJar()) {
const sampleVersion = path.basename(jar).match(/^qqq-sample-project-(.+)-jar-with-dependencies\.jar$/)?.[1]
if (!sampleVersion) throw new Error(`Cannot determine QQQ version from sample JAR: ${jar}`)
const apiJar = process.env.QQQ_MIDDLEWARE_API_JAR ?? path.join(homedir(), '.m2', 'repository', 'com', 'kingsrook', 'qqq',
'qqq-middleware-api', sampleVersion, `qqq-middleware-api-${sampleVersion}.jar`)
if (!existsSync(apiJar)) throw new Error(`QQQ middleware API JAR does not exist: ${apiJar}`)
return [jar, apiJar].join(path.delimiter)
}
export const BUILD_MARKER = path.resolve('.next/acceptance-backend.txt')
/** Classpath folder holding a copy of out/ as next-dashboard/, ahead of the sample jar. */
export const EXPORT_CLASSPATH = path.resolve('test-results/acceptance/export-classpath')
export const ACCEPTANCE_MODE = process.env.QQQ_ACCEPTANCE_MODE === 'standalone' ? 'standalone' : 'javalin'
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Full real-backend acceptance: production build, Playwright suite, then the matrix gate.
// Usage: QQQ_SAMPLE_JAR=<jar> node scripts/acceptance.mjs [--skip-build] [playwright args...]
import { spawnSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
const args = process.argv.slice(2)
const skipBuild = args.includes('--skip-build')
const passthrough = args.filter((arg) => arg !== '--skip-build')
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
resolveSampleJar()
function run(command, commandArgs, env = {}) {
const result = spawnSync(command, commandArgs, { stdio: 'inherit', env: { ...process.env, ...env } })
return result.status ?? 1
}
// An acceptance build starts from a clean .next: a Turbopack build reusing its cache after a
// merge was seen to emit stale CSS (the touch-target rules of globals.css were missing), which
// would test something other than the source.
if (!skipBuild) rmSync('.next', { recursive: true, force: true })
if (!skipBuild && ACCEPTANCE_MODE === 'javalin') {
const status = run('pnpm', ['build:export'], { NEXT_TELEMETRY_DISABLED: '1' })
if (status !== 0) process.exit(status)
}
if (ACCEPTANCE_MODE === 'javalin') {
if (!existsSync('out/index.html')) throw new Error('No static export in out/; run without --skip-build.')
rmSync(EXPORT_CLASSPATH, { recursive: true, force: true })
mkdirSync(EXPORT_CLASSPATH, { recursive: true })
cpSync('out', path.join(EXPORT_CLASSPATH, 'next-dashboard'), { recursive: true, filter: (source) => !source.endsWith('mockServiceWorker.js') })
}
if (!skipBuild && ACCEPTANCE_MODE === 'standalone') {
const status = run('pnpm', ['build'], { QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
if (status !== 0) process.exit(status)
writeFileSync(BUILD_MARKER, backend + '\n')
}
const tests = run('pnpm', ['exec', 'playwright', 'test', '-c', 'tests/acceptance/playwright.config.ts', ...passthrough])
const filtered = passthrough.some((arg) => !arg.startsWith('--project'))
const gate = run('node', ['scripts/acceptance-gate.mjs', ...(filtered ? ['--partial'] : [])])
process.exit(tests || gate)
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* check-license-headers.mjs
*
* Validates that every TypeScript/TSX source file in src/ begins with the
* Apache 2.0 license header required by this project.
*
* Usage:
* node scripts/check-license-headers.mjs # check only (exits 1 on failure)
* node scripts/check-license-headers.mjs --fix # auto-prepend missing headers
*
* Integrated via:
* pnpm lint:license → check mode
* pnpm lint:license:fix → fix mode
* pnpm lint → check mode (enforced in CI)
*/
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const ROOT = join(__dirname, '..')
const SRC = join(ROOT, 'src')
const FIX = process.argv.includes('--fix')
/** Full Apache 2.0 header prepended to files missing it (fix mode). */
const HEADER = `/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/`
/**
* Canonical identifier used when scanning for the header.
* Matching a substring instead of the full block makes the check robust to
* minor formatting differences (trailing spaces, year updates, etc.).
*/
const HEADER_MARKER = 'Licensed under the Apache License, Version 2.0'
/** Directories inside src/ that are generated or third-party — skip entirely. */
const SKIP_DIRS = new Set(['node_modules', '.next', 'coverage', '__generated__', 'out'])
/**
* Recursively yields every .ts / .tsx file under `dir`, skipping directories
* listed in SKIP_DIRS.
*
* @param {string} dir - Absolute path of the directory to walk.
* @yields {string} Absolute path of each matching file.
*/
function* walkTs(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue
yield* walkTs(full)
} else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
yield full
}
}
}
const missing = []
for (const file of walkTs(SRC)) {
const content = readFileSync(file, 'utf8')
if (!content.includes(HEADER_MARKER)) {
missing.push(file)
if (FIX) {
writeFileSync(file, HEADER + '\n\n' + content, 'utf8')
console.log(` fixed ${relative(ROOT, file)}`)
}
}
}
if (missing.length === 0) {
console.log('✓ All source files carry the Apache 2.0 license header.')
process.exit(0)
}
if (FIX) {
console.log(`\n✓ Added license header to ${missing.length} file(s).`)
process.exit(0)
}
// Check-only mode: report and fail.
console.error(`\n❌ ${missing.length} file(s) are missing the Apache 2.0 license header:\n`)
for (const f of missing) {
console.error(` ${relative(ROOT, f)}`)
}
console.error('\nRun pnpm lint:license:fix to add the headers automatically.\n')
process.exit(1)
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Serves the production standalone build (`pnpm build`) for the mocked e2e suite. No backend
// runs: the specs answer every API call with page.route mocks (tests/e2e/api-mocks.ts).
import { spawn } from 'node:child_process'
import { cpSync, existsSync } from 'node:fs'
import path from 'node:path'
const standalone = path.resolve('.next/standalone')
if (!existsSync(path.join(standalone, 'server.js'))) {
console.error('No standalone build in .next/standalone; run `pnpm build` first.')
process.exit(1)
}
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true })
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true })
const server = spawn('node', ['server.js'], {
cwd: standalone,
stdio: 'inherit',
env: { ...process.env, NODE_ENV: 'production', HOSTNAME: '127.0.0.1', PORT: process.env.QQQ_E2E_PORT ?? '3000' },
})
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
server.on('exit', (code) => process.exit(code ?? 1))
+155
View File
@@ -0,0 +1,155 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Bundle budget for the static export (QRun-IO/qqq#710). Run after `pnpm build:export`:
//
// node scripts/perf-budget.mjs # check out/ against perf-budget.json, exit 1 on a breach
// node scripts/perf-budget.mjs --json # also print the measurement as JSON
//
// First-load JS of a route is every script the route's HTML loads before hydration (script src
// and preload links), gzip-compressed the way the server sends it. Next 16 no longer prints these
// sizes, so they are measured from out/. The report lands in test-results/performance/bundle.json.
//
// A library that only one screen loads on demand (RapiDoc for the table API docs, QRun-IO/qqq#714)
// is budgeted on its own under `lazyChunks`: its chunk is found by a marker in its code, must not be
// part of any route's first load, must stay within its own budget, and is left out of the total and
// largest-chunk budgets, which keep measuring the rest of the application.
import { gzipSync } from 'node:zlib'
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import path from 'node:path'
const OUT = path.resolve(process.env.QQQ_EXPORT_DIR ?? 'out')
const BUDGET_FILE = path.resolve('perf-budget.json')
const REPORT = path.resolve('test-results/performance/bundle.json')
/** Recursively lists files under a directory. */
function walk(dir) {
return readdirSync(dir).flatMap((name) => {
const file = path.join(dir, name)
return statSync(file).isDirectory() ? walk(file) : [file]
})
}
const sizeCache = new Map()
/** Raw and gzip size (bytes) of an exported asset. */
function sizes(file) {
if (!sizeCache.has(file)) {
const bytes = readFileSync(file)
sizeCache.set(file, { raw: bytes.length, gzip: gzipSync(bytes, { level: 9 }).length })
}
return sizeCache.get(file)
}
/** The route an exported HTML file serves ("_" is the placeholder of a dynamic segment). */
function routeOf(file) {
const rel = path.relative(OUT, file).split(path.sep).join('/')
if (rel === 'index.html') return '/'
return '/' + rel.replace(/\/index\.html$/, '')
}
const kb = (bytes) => Math.round((bytes / 1024) * 10) / 10
/** Measures every exported route. */
function measure(lazyBudgets = {}) {
const files = walk(OUT)
const html = files.filter((f) => f.endsWith('.html'))
const routes = html.map((file) => {
const text = readFileSync(file, 'utf8')
const refs = new Set()
for (const match of text.matchAll(/(?:src|href)="(\/_next\/static\/[^"?#]+\.(?:js|css))"/g)) refs.add(match[1])
const js = [...refs].filter((r) => r.endsWith('.js'))
const css = [...refs].filter((r) => r.endsWith('.css'))
const sum = (list, key) => list.reduce((total, ref) => total + sizes(path.join(OUT, decodeURIComponent(ref)))[key], 0)
return {
route: routeOf(file),
scripts: js.length,
firstLoadJsGzipKB: kb(sum(js, 'gzip')),
firstLoadJsRawKB: kb(sum(js, 'raw')),
cssGzipKB: kb(sum(css, 'gzip')),
htmlGzipKB: kb(sizes(file).gzip),
}
}).sort((a, b) => a.route.localeCompare(b.route))
const chunks = files.filter((f) => f.includes(`${path.sep}_next${path.sep}static${path.sep}`) && f.endsWith('.js'))
const firstLoad = new Set(html.flatMap((file) => [...readFileSync(file, 'utf8').matchAll(/(?:src|href)="\/(_next\/static\/[^"?#]+\.js)"/g)].map((m) => decodeURIComponent(m[1]))))
const lazy = Object.entries(lazyBudgets).map(([name, { marker }]) => {
const matched = chunks.filter((f) => readFileSync(f, 'utf8').includes(marker)).map((f) => path.relative(OUT, f).split(path.sep).join('/'))
return {
name,
chunks: matched,
gzipKB: kb(matched.reduce((t, c) => t + sizes(path.join(OUT, c)).gzip, 0)),
inFirstLoad: matched.filter((c) => firstLoad.has(c)),
}
})
const lazyFiles = new Set(lazy.flatMap((entry) => entry.chunks))
const largest = chunks.map((f) => ({ file: path.relative(OUT, f).split(path.sep).join('/'), ...sizes(f) }))
.filter((c) => !lazyFiles.has(c.file))
.sort((a, b) => b.gzip - a.gzip)
return {
routes,
lazy,
totalJsGzipKB: kb(largest.reduce((t, c) => t + c.gzip, 0)),
totalJsRawKB: kb(largest.reduce((t, c) => t + c.raw, 0)),
chunkCount: largest.length,
largestChunks: largest.slice(0, 5).map((c) => ({ file: c.file, gzipKB: kb(c.gzip), rawKB: kb(c.raw) })),
}
}
/** Compares a measurement with the budget; returns the breaches. */
function check(result, budget) {
const problems = []
const over = (label, actual, limit) => {
if (actual > limit) problems.push(`${label}: ${actual} KB > budget ${limit} KB`)
}
for (const route of result.routes) {
const limit = budget.routes?.[route.route] ?? budget.firstLoadJsGzipKB
over(`${route.route} first-load JS (gzip)`, route.firstLoadJsGzipKB, limit)
over(`${route.route} CSS (gzip)`, route.cssGzipKB, budget.cssGzipKB)
}
for (const route of Object.keys(budget.routes ?? {})) {
if (!result.routes.some((r) => r.route === route)) problems.push(`${route}: budgeted route missing from the export`)
}
for (const entry of result.lazy) {
const limit = budget.lazyChunks[entry.name].gzipKB
if (!entry.chunks.length) problems.push(`lazy chunk ${entry.name}: no chunk contains its marker (update the marker, or remove the entry)`)
for (const chunk of entry.inFirstLoad) problems.push(`lazy chunk ${entry.name}: ${chunk} is loaded on first load of a route`)
over(`lazy chunk ${entry.name} (gzip)`, entry.gzipKB, limit)
}
over('total JS (gzip)', result.totalJsGzipKB, budget.totalJsGzipKB)
over(`largest chunk ${result.largestChunks[0]?.file} (gzip)`, result.largestChunks[0]?.gzipKB ?? 0, budget.largestChunkGzipKB)
return problems
}
const budget = JSON.parse(readFileSync(BUDGET_FILE, 'utf8'))
const result = measure(budget.lazyChunks)
if (!result.routes.length) {
console.error(`No exported HTML under ${OUT}; run \`pnpm build:export\` first.`)
process.exit(1)
}
const problems = check(result, budget)
console.log('Route scripts first-load JS gzip (raw) CSS gzip')
for (const r of result.routes) {
const limit = budget.routes?.[r.route] ?? budget.firstLoadJsGzipKB
console.log(`${r.route.padEnd(30)} ${String(r.scripts).padStart(7)} ${`${r.firstLoadJsGzipKB} KB (${r.firstLoadJsRawKB} KB)`.padStart(20)} / ${limit} KB ${r.cssGzipKB} KB`)
}
console.log(`Total JS: ${result.totalJsGzipKB} KB gzip (${result.totalJsRawKB} KB raw) in ${result.chunkCount} chunks; budget ${budget.totalJsGzipKB} KB`)
console.log(`Largest chunks: ${result.largestChunks.map((c) => `${c.file} ${c.gzipKB} KB`).join(', ')}; budget ${budget.largestChunkGzipKB} KB`)
for (const entry of result.lazy) {
console.log(`Lazy chunk ${entry.name}: ${entry.gzipKB} KB gzip in ${entry.chunks.join(', ') || 'no chunk'}; budget ${budget.lazyChunks[entry.name].gzipKB} KB; first load: ${entry.inFirstLoad.length ? entry.inFirstLoad.join(', ') : 'none'}`)
}
mkdirSync(path.dirname(REPORT), { recursive: true })
writeFileSync(REPORT, JSON.stringify({ budget, result, problems }, null, 2) + '\n')
if (process.argv.includes('--json')) console.log(JSON.stringify(result, null, 2))
if (problems.length) {
console.error(`\nPerformance budget exceeded (${problems.length}):\n- ${problems.join('\n- ')}`)
console.error('Reduce the bundle, or raise the budget in perf-budget.json with the reason in the commit.')
process.exit(1)
}
console.log('\nPerformance budget: OK')
+108
View File
@@ -0,0 +1,108 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file CallbackContent — completes the OAuth2 / Auth0 PKCE redirect (`/token`, `/callback`).
*/
'use client'
import React, { useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/use-auth'
import { saveProviderError } from '@/lib/auth/callback-errors'
import { PKCE_STORAGE } from '@/lib/auth/oidc'
import { safeReturnTo } from '@/lib/auth/return-to'
/**
* Inner callback handler — reads `code`, `state`, and `error` from search params,
* retrieves the PKCE verifier from sessionStorage, and delegates to
* `handleOAuthCallback`. Redirects on success or failure.
*
* @returns A loading card while the callback is being processed.
*/
export default function CallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { handleOAuthCallback } = useAuth()
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const returnTo = safeReturnTo(sessionStorage.getItem(PKCE_STORAGE.returnTo))
const toLogin = (reason: string) => {
sessionStorage.removeItem(PKCE_STORAGE.verifier)
sessionStorage.removeItem(PKCE_STORAGE.state)
sessionStorage.removeItem(PKCE_STORAGE.redirectUri)
sessionStorage.removeItem(PKCE_STORAGE.returnTo)
router.replace(`/login?error=${encodeURIComponent(reason)}&returnTo=${encodeURIComponent(returnTo)}`)
}
if (error) {
if (!state || state !== sessionStorage.getItem(PKCE_STORAGE.state)) {
console.warn('[Callback] Identity provider error with an invalid state:', error)
toLogin('callback_failed')
return
}
saveProviderError(error, searchParams.get('error_description'))
console.warn('[Callback] Identity provider error:', error)
toLogin(error)
return
}
if (!code || !state) {
console.warn('[Callback] Missing code or state')
toLogin('callback_failed')
return
}
// The verifier the login page stored before redirecting to the provider.
const codeVerifier = sessionStorage.getItem(PKCE_STORAGE.verifier) ?? undefined
sessionStorage.removeItem(PKCE_STORAGE.verifier)
handleOAuthCallback(code, state, codeVerifier)
.then(() => {
sessionStorage.removeItem(PKCE_STORAGE.returnTo)
router.replace(returnTo)
})
.catch((err: unknown) => {
console.warn('[Callback] Sign-in could not be completed:', err instanceof Error ? err.message : err)
toLogin('callback_failed')
})
}, [searchParams, handleOAuthCallback, router])
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Completing sign-in...
</h1>
<p className="text-sm text-muted-foreground" role="status">
Please wait while we complete authentication.
</p>
</div>
</div>
)
}
+49 -65
View File
@@ -1,74 +1,58 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file OAuth2 / Auth0 callback page — handles the PKCE redirect from the authorization server.
*/
'use client'
// OAuth2 callback page — handles the redirect from authorization server
// Wrapped in Suspense because it uses useSearchParams()
// OAuth2 / Auth0 callback page — handles the redirect from the authorization server.
//
// After a successful PKCE redirect from the login page the IdP returns the user
// here with `?code=...&state=...` query parameters. This page:
//
// 1. Validates that `state` matches the nonce stored in sessionStorage (CSRF guard).
// 2. Retrieves the `pkce_code_verifier` stored by the login page.
// 3. Calls `handleOAuthCallback(code, state, codeVerifier)` which:
// a. Re-validates the state nonce.
// b. OAUTH2: posts code + verifier + redirect URI to the backend, which
// exchanges them with the IdP and issues a session cookie.
// AUTH_0: exchanges the code with Auth0 (public PKCE client) and posts
// the access token to the backend.
// 4. Redirects to `oauth2ReturnTo` (set by the login page) or '/'.
//
// `/token` (the registered redirect URI) renders the same handler.
// Wrapped in Suspense because it uses useSearchParams().
import React, { Suspense, useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { Suspense } from 'react'
import { useAuth } from '@/lib/auth/use-auth'
function CallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { handleOAuthCallback } = useAuth()
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
if (error) {
console.error('[Callback] Auth error:', error, errorDescription)
router.replace(`/login?error=${encodeURIComponent(error)}`)
return
}
if (!code || !state) {
console.error('[Callback] Missing code or state params')
router.replace('/login')
return
}
handleOAuthCallback(code, state)
.then(() => {
// Get stored returnTo from session storage or default to home
const returnTo = sessionStorage.getItem('oauth2ReturnTo') ?? '/'
sessionStorage.removeItem('oauth2ReturnTo')
router.replace(returnTo)
})
.catch((err) => {
console.error('[Callback] Failed to handle callback:', err)
router.replace('/login?error=callback_failed')
})
}, [searchParams, handleOAuthCallback, router])
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Completing login...
</h1>
<p className="text-sm text-muted-foreground">
Please wait while we complete authentication.
</p>
</div>
</div>
)
}
import CallbackContent from './CallbackContent'
/**
* OAuth2 / Auth0 callback page exported as the Next.js default for `/callback`.
*
* Wraps `CallbackContent` in a `<Suspense>` boundary because `useSearchParams()`
* requires Suspense in the App Router.
*
* @returns The callback page wrapped in a full-screen centered container.
*/
export default function CallbackPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
<Suspense
fallback={
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
@@ -80,6 +64,6 @@ export default function CallbackPage() {
>
<CallbackContent />
</Suspense>
</div>
</main>
)
}
+142
View File
@@ -0,0 +1,142 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the login page: TABLE_BASED password sign-in (QRun-IO/qqq#700) and
// pre-sign-in branding (QRun-IO/qqq#703)
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { axe } from 'jest-axe'
import type { AuthContextType } from '@/lib/auth/auth-provider'
import type { QAuthenticationMetaData } from '@/types'
const authState: { value: AuthContextType } = { value: {} as AuthContextType }
vi.mock('@/lib/auth/use-auth', () => ({ useAuth: () => authState.value }))
import LoginPage from './page'
const BRANDING = { appName: 'QQQ Sample', logo: '/samples-logo.png', icon: '/kr-icon.png', accentColor: '#1d4ed8' }
/**
* Sets the auth context the page sees.
*
* @param metadata - Authentication metadata.
* @param overrides - Other context values.
*/
function withAuth(metadata: QAuthenticationMetaData | null, overrides: Partial<AuthContextType> = {}) {
authState.value = {
isAuthenticated: false,
isLoading: false,
user: null,
authMetadata: metadata,
authError: null,
isSignedOut: false,
signIn: vi.fn(async () => {}),
signInWithPassword: vi.fn(async () => {}),
logout: vi.fn(async () => {}),
handleOAuthCallback: vi.fn(async () => {}),
...overrides,
}
}
const tableBased: QAuthenticationMetaData = { name: 'tableBased', type: 'TABLE_BASED', branding: BRANDING }
describe('LoginPage', () => {
beforeEach(() => {
document.head.innerHTML = '<link rel="icon" href="/favicon.ico">'
})
afterEach(() => {
document.documentElement.removeAttribute('style')
document.title = ''
})
it('asks for a username and password under the application branding, without signing in automatically', async () => {
withAuth(tableBased)
render(<LoginPage />)
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByLabelText('Username')).toHaveAttribute('autocomplete', 'username')
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password')
expect(document.querySelector('[data-qqq-id="login-logo"]')).toHaveAttribute('src', '/samples-logo.png')
expect(screen.getByText('QQQ Sample')).toHaveAttribute('data-qqq-id', 'login-app-name')
await waitFor(() => expect(document.documentElement.style.getPropertyValue('--primary')).toBe('#1d4ed8'))
expect(document.querySelector<HTMLLinkElement>("link[rel='icon']")?.getAttribute('href')).toBe('/kr-icon.png')
expect(document.title).toBe('Sign in | QQQ Sample')
expect(authState.value.signIn).not.toHaveBeenCalled()
})
it('names the missing fields and sends nothing', () => {
withAuth(tableBased)
render(<LoginPage />)
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Enter your username.')).toBeInTheDocument()
expect(screen.getByText('Enter your password.')).toBeInTheDocument()
expect(screen.getByLabelText('Username')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByLabelText('Username')).toHaveAccessibleDescription('Enter your username.')
expect(screen.getByLabelText('Username')).toHaveFocus()
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'a:b' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'x' } })
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('A username cannot contain a colon.')).toBeInTheDocument()
expect(authState.value.signInWithPassword).not.toHaveBeenCalled()
})
it('signs in with the typed credentials and clears the password afterwards', async () => {
withAuth(tableBased)
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: ' tess.table ' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'table:pass-2026' } })
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
await waitFor(() => expect(authState.value.signInWithPassword).toHaveBeenCalledWith('tess.table', 'table:pass-2026'))
await waitFor(() => expect(screen.getByLabelText('Password')).toHaveValue(''))
expect(screen.getByLabelText('Username')).toHaveValue(' tess.table ')
})
it('shows a refused sign-in in the form and after logout says so', () => {
withAuth(tableBased, { authError: 'Sign-in was denied: Incorrect username or password.' })
const { unmount } = render(<LoginPage />)
expect(screen.getByRole('alert')).toHaveTextContent('Sign-in was denied: Incorrect username or password.')
expect(screen.getByLabelText('Password')).toBeInTheDocument()
unmount()
withAuth(tableBased, { isSignedOut: true })
render(<LoginPage />)
expect(screen.getByRole('heading', { name: 'You have signed out' })).toBeInTheDocument()
expect(screen.getByLabelText('Username')).toBeInTheDocument()
})
it('has no accessibility violations', async () => {
withAuth(tableBased)
const { container } = render(<LoginPage />)
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(await axe(container)).toHaveNoViolations()
})
it('brands the other sign-in states too and drops an unsafe logo', () => {
withAuth({ name: 'mock', type: 'MOCK', branding: { appName: 'QQQ Sample', logo: 'javascript:alert(1)' } }, { isSignedOut: true })
render(<LoginPage />)
expect(screen.getByRole('heading', { name: 'You have signed out' })).toBeInTheDocument()
expect(screen.getByText('QQQ Sample')).toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="login-logo"]')).toBeNull()
})
it('renders without branding when the backend declares none', () => {
withAuth({ name: 'mock', type: 'MOCK' }, { isSignedOut: true })
render(<LoginPage />)
expect(document.querySelector('[data-qqq-id="login-branding"]')).toBeNull()
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
})
})
+290 -72
View File
@@ -1,97 +1,315 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Login page — signs the user in and returns them to the page they asked for.
*/
'use client'
// Login page — authenticates user and redirects to dashboard
// Wrapped in Suspense because it uses useSearchParams()
// States:
// signing in — first visit or an expired session: sign in automatically (anonymous
// types create a session; OAUTH2 / AUTH_0 redirect to the provider).
// password — TABLE_BASED: ask for a username and password (first visit, after an
// expired session and after logout); a refused sign-in is shown in the form.
// signed out — after an explicit logout: stay here until the user chooses Sign in.
// failed — sign-in was denied or the provider callback failed: explain why and
// offer Try again; never fall back to an anonymous session.
// Every state shows the application's pre-sign-in branding (logo, app name, accent) from
// GET /metaData/authentication (QRun-IO/qqq#703).
// Wrapped in Suspense because it uses useSearchParams().
import React, { Suspense, useEffect } from 'react'
import React, { Suspense, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import type { QLoginBranding } from '@/types'
import { useAuth } from '@/lib/auth/use-auth'
import { recordReauthAttempt, resetReauthAttempts } from '@/lib/auth/auth-storage'
import { safeReturnTo } from '@/lib/auth/return-to'
import { callbackErrorMessage, consumeProviderError } from '@/lib/auth/callback-errors'
import { useDocumentTitle } from '@/lib/hooks/use-document-title'
import { applyBrandingTheme, isSafeImageSource } from '@/lib/theme/apply-branding'
function LoginContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { isAuthenticated, isLoading, authMetadata } = useAuth()
const BUTTON_CLASS = 'inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60'
const INPUT_CLASS = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring aria-[invalid=true]:border-destructive'
const returnTo = searchParams.get('returnTo') ?? '/'
useEffect(() => {
if (!isLoading && isAuthenticated) {
// Decode and validate the returnTo URL
try {
const decoded = decodeURIComponent(returnTo)
// Only redirect to relative paths (security: prevent open redirect)
if (decoded.startsWith('/')) {
router.replace(decoded)
} else {
router.replace('/')
}
} catch {
router.replace('/')
}
}
}, [isAuthenticated, isLoading, router, returnTo])
if (isLoading) {
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Authenticating...
</h1>
<p className="text-sm text-muted-foreground">
{authMetadata
? `Setting up ${authMetadata.type} session...`
: 'Checking authentication...'}
</p>
</div>
</div>
)
}
// If not loading and not authenticated, show login state
/**
* The application's logo and name, shown before sign-in.
*
* @param props - The branding.
* @param props.branding - Pre-sign-in branding from the authentication metadata.
* @returns The branding block, or null when the backend declares none.
*/
function LoginBrand({ branding }: { branding?: QLoginBranding }) {
const appName = branding?.appName?.trim()
const logo = isSafeImageSource(branding?.logo) ? branding?.logo : undefined
if (!appName && !logo) return null
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-2" data-qqq-id="login-branding">
{logo && (
// eslint-disable-next-line @next/next/no-img-element -- branding logos are arbitrary backend assets
<img src={logo} alt={appName ? '' : branding?.companyName?.trim() || 'Application logo'} className="max-h-16 max-w-full object-contain" data-qqq-id="login-logo" />
)}
{appName && <p className="text-lg font-semibold text-foreground" data-qqq-id="login-app-name">{appName}</p>}
</div>
)
}
/**
* Card wrapper shared by every login state.
*
* @param props - The card contents.
* @param props.branding - Pre-sign-in branding shown at the top of the card.
* @param props.children - Card body.
* @returns The card.
*/
function LoginCard({ branding, children }: { branding?: QLoginBranding; children: React.ReactNode }) {
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm" data-qqq-id="login-card">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground"
aria-hidden="true"
>
Q
</div>
<h1 className="text-xl font-semibold text-foreground">QQQ Admin</h1>
<p className="text-sm text-muted-foreground">
{authMetadata?.type === 'FULLY_ANONYMOUS' || authMetadata?.type === 'MOCK'
? 'Setting up anonymous session...'
: 'Redirecting to authentication provider...'}
</p>
<div
className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"
aria-hidden="true"
/>
<LoginBrand branding={branding} />
{children}
</div>
</div>
)
}
/** Props for {@link PasswordSignIn}. */
interface PasswordSignInProps {
/** Card heading. */
heading: string
/** Optional line under the heading. */
message?: string
/** Why the last sign-in was refused, if it was. */
error: string | null
/** Signs in; failures come back through `error`. */
onSubmit: (username: string, password: string) => Promise<void>
}
/**
* Username and password form for TABLE_BASED authentication. The password is read from
* the form on submit and cleared after every attempt; it is never kept in state.
*
* @param props - See {@link PasswordSignInProps}.
* @returns The form.
*/
function PasswordSignIn({ heading, message, error, onSubmit }: PasswordSignInProps) {
const [submitting, setSubmitting] = useState(false)
const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({})
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const username = String(data.get('username') ?? '').trim()
const password = String(data.get('password') ?? '')
const errors = {
username: !username ? 'Enter your username.' : username.includes(':') ? 'A username cannot contain a colon.' : undefined,
password: !password ? 'Enter your password.' : undefined,
}
setFieldErrors(errors)
if (errors.username || errors.password) {
const first = form.elements.namedItem(errors.username ? 'username' : 'password')
if (first instanceof HTMLInputElement) first.focus()
return
}
setSubmitting(true)
try {
await onSubmit(username, password)
} finally {
const passwordInput = form.elements.namedItem('password')
if (passwordInput instanceof HTMLInputElement) passwordInput.value = ''
setSubmitting(false)
}
}
return (
<>
<h1 className="text-xl font-semibold text-foreground">{heading}</h1>
{message && <p className="text-sm text-muted-foreground">{message}</p>}
<form className="flex w-full flex-col gap-4 text-left" onSubmit={handleSubmit} noValidate aria-label="Sign in" data-qqq-id="login-form">
{error && <p role="alert" className="text-sm text-destructive" data-qqq-id="login-error">{error}</p>}
<div className="flex flex-col gap-1.5">
<label htmlFor="login-username" className="text-sm font-medium text-foreground">Username</label>
<input
id="login-username"
name="username"
type="text"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={Boolean(fieldErrors.username)}
aria-describedby={fieldErrors.username ? 'login-username-error' : undefined}
className={INPUT_CLASS}
data-qqq-id="input-login-username"
/>
{fieldErrors.username && <p id="login-username-error" className="text-sm text-destructive">{fieldErrors.username}</p>}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="login-password" className="text-sm font-medium text-foreground">Password</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
aria-required="true"
aria-invalid={Boolean(fieldErrors.password)}
aria-describedby={fieldErrors.password ? 'login-password-error' : undefined}
className={INPUT_CLASS}
data-qqq-id="input-login-password"
/>
{fieldErrors.password && <p id="login-password-error" className="text-sm text-destructive">{fieldErrors.password}</p>}
</div>
<button type="submit" disabled={submitting} className={BUTTON_CLASS} data-qqq-id="button-sign-in">
{submitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
</>
)
}
/**
* Inner login content — reacts to the auth state and the `returnTo` / `error` params.
*
* @returns The login card for the current state.
*/
function LoginContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { isAuthenticated, isLoading, authMetadata, authError, isSignedOut, signIn, signInWithPassword } = useAuth()
const autoSignInStarted = useRef(false)
const [loopStopped, setLoopStopped] = React.useState(false)
const [providerError, setProviderError] = useState<string | null>(null)
const returnTo = safeReturnTo(searchParams.get('returnTo'))
const callbackCode = searchParams.get('error')
const callbackError = callbackErrorMessage(callbackCode)
const errorMessage = authError ?? providerError ?? callbackError ?? (loopStopped ? 'Your session could not be re-established. Sign in again to continue.' : null)
const passwordSignIn = authMetadata?.type === 'TABLE_BASED'
const branding = authMetadata?.branding
const appName = branding?.appName?.trim()
useEffect(() => {
setProviderError(consumeProviderError(callbackCode))
}, [callbackCode])
// Pre-sign-in branding: accent colors and favicon, and the app name in the title.
useEffect(() => {
applyBrandingTheme(branding)
}, [branding])
useDocumentTitle(appName ? `Sign in | ${appName}` : undefined)
// Signed in: go where the user was headed.
useEffect(() => {
if (!isLoading && isAuthenticated) router.replace(returnTo)
}, [isAuthenticated, isLoading, router, returnTo])
// Sign in automatically unless the user signed out, a failure needs attention, or
// the user has to type credentials (TABLE_BASED).
useEffect(() => {
if (isLoading || isAuthenticated || isSignedOut || errorMessage || !authMetadata || passwordSignIn) return
if (autoSignInStarted.current) return
autoSignInStarted.current = true
if (recordReauthAttempt()) {
setLoopStopped(true)
return
}
void signIn(returnTo)
}, [authMetadata, errorMessage, isAuthenticated, isLoading, isSignedOut, passwordSignIn, returnTo, signIn])
const signInAgain = () => {
resetReauthAttempts()
setLoopStopped(false)
void signIn(returnTo)
}
if (!isLoading && !isAuthenticated && passwordSignIn) {
return (
<LoginCard branding={branding}>
<PasswordSignIn
heading={isSignedOut ? 'You have signed out' : 'Sign in'}
message={isSignedOut ? 'Sign in again to continue.' : undefined}
error={errorMessage}
onSubmit={signInWithPassword}
/>
</LoginCard>
)
}
if (!isLoading && !isAuthenticated && isSignedOut) {
return (
<LoginCard branding={branding}>
<h1 className="text-xl font-semibold text-foreground">You have signed out</h1>
<p className="text-sm text-muted-foreground">Sign in again to continue.</p>
<button type="button" onClick={signInAgain} className={BUTTON_CLASS} data-qqq-id="button-sign-in">
Sign in
</button>
</LoginCard>
)
}
if (!isLoading && !isAuthenticated && errorMessage) {
return (
<LoginCard branding={branding}>
<h1 className="text-xl font-semibold text-foreground">Unable to sign in</h1>
<p role="alert" className="text-sm text-destructive" data-qqq-id="login-error">{errorMessage}</p>
<button type="button" onClick={signInAgain} className={BUTTON_CLASS} data-qqq-id="button-sign-in-retry">
Try again
</button>
</LoginCard>
)
}
const redirecting = authMetadata?.type === 'OAUTH2' || authMetadata?.type === 'AUTH_0'
return (
<LoginCard branding={branding}>
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">Signing in...</h1>
<p className="text-sm text-muted-foreground" role="status">
{redirecting ? 'Redirecting to your identity provider...' : 'Setting up your session...'}
</p>
</LoginCard>
)
}
/**
* Login page exported as the Next.js default for `/login`.
*
* Wraps `LoginContent` in a `<Suspense>` boundary because `useSearchParams()`
* requires Suspense in the App Router.
*
* @returns The login page wrapped in a full-screen centered container.
*/
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
<Suspense
fallback={
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
</div>
<LoginCard>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" aria-hidden="true" />
<p className="text-sm text-muted-foreground" role="status">Loading...</p>
</LoginCard>
}
>
<LoginContent />
</Suspense>
</div>
</main>
)
}
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Token page — the OAuth2 / Auth0 redirect URI (`{origin}/token`), matching the
* Material dashboard so existing identity-provider registrations keep working.
*/
'use client'
import React, { Suspense } from 'react'
import CallbackContent from '../callback/CallbackContent'
/**
* `/token` — completes the provider redirect.
*
* @returns The callback handler in a full-screen container.
*/
export default function TokenPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
<Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading...</p>}>
<CallbackContent />
</Suspense>
</main>
)
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Prerenders this dynamic segment once for the Javalin-served static export.
* The server returns that page for every concrete value; see use-route-params.
*/
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
* Static export placeholder params for this segment.
*
* @returns One placeholder entry.
*/
export function generateStaticParams() {
return [{ action: EXPORT_PLACEHOLDER }]
}
/**
* Pass-through layout that exists to carry generateStaticParams.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
}
@@ -0,0 +1,67 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Record-scoped process route — `/app/{table}/{id}/{process}`, the Material Dashboard
* URL that runs one of the table's processes for a single record.
*/
'use client'
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { processRunHref, tableProcessForSegment } from '@/lib/utils/material-links'
import { launchTableName } from '@/lib/utils/process-utils'
import { NotFoundState } from '@/components/layout/NotFoundState'
import { RouteRedirect } from '@/components/layout/RouteRedirect'
/**
* Opens the named process for the record, returning to the record afterwards, or shows the
* in-shell not-found state when the segment is not one of the table's processes.
*
* @returns A redirect, a loading state or the not-found state.
*/
export default function RecordProcessPage() {
const { slug, recordId, action } = useRouteParams<{ slug: string; recordId: string; action: string }>()
const { data: metaData, isError } = useQuery({ queryKey: queryKeys.metadataAll(), queryFn: loadMetaData, staleTime: 1000 * 60 * 30 })
if (isError) {
return <div role="alert" className="py-12 text-center text-destructive">Failed to load application metadata.</div>
}
if (!metaData) {
return (
<div role="status" aria-label="Loading" className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
const processName = metaData.tables?.[slug] ? tableProcessForSegment(metaData, slug, action, true) : null
if (!processName) return <NotFoundState name={action} />
const search = typeof window === 'undefined' ? '' : window.location.search
const href = processRunHref(processName, {
recordId,
search,
returnTo: `/app/${encodeURIComponent(slug)}/${encodeURIComponent(recordId)}`,
tableName: launchTableName(metaData.processes[processName], slug),
})
return <RouteRedirect href={href} label={metaData.processes[processName]?.label ?? processName} />
}
@@ -1,37 +1,126 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file EntityCopy page — copies a record by pre-populating the create form with existing values.
*/
'use client'
// EntityCopy — copy a record (pre-populates form with existing values)
// Package 3: full implementation using EntityForm with isCopy=true
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { EntityForm } from '@/components/forms/EntityForm'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import type { QTableMetaData } from '@/types'
import type { CopyNode } from '@/lib/utils/copy-tree'
import { copyTableNames, prepareCopyTree } from '@/lib/utils/copy-tree'
import { getErrorStatusCode, recordLoadFailure } from '@/lib/utils/error-utils'
import { canInsertRecords, canReadRecords, hasCapability } from '@/lib/auth/permissions'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import type { EntityFormProps } from '@/components/forms/EntityForm'
const EntityForm = lazy(() => import('@/components/forms/EntityForm').then((module) => ({ default: module.EntityForm })))
const FullCopyDraft = lazy(() => import('@/components/forms/FullCopyDraft').then((module) => ({ default: module.FullCopyDraft })))
/**
* Renders the entity copy form for the record identified by `slug` and `recordId`.
*
* Fetches full table metadata and the base source record, then renders `<EntityForm>`
* in copy mode (`isCopy=true`) so field values are pre-populated but the form
* will create a new record on submit. Shows a loading spinner while data is
* pending, a permission error when the user lacks `insertPermission`, and a
* fetch-error panel when the source record cannot be loaded.
*
* @returns A composed page that renders one of:
* - A full-screen spinner while metadata or record data is loading
* - A permission-error banner when the user lacks `insertPermission`
* - A destructive error panel when the source record cannot be fetched
* - `<EntityForm>` in copy mode (`isCopy=true`, pre-populated with source values)
* wrapped in a centered `max-w-6xl` container; submitting creates a new record
*/
export default function EntityCopyPage() {
const params = useParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const params = useRouteParams<{ slug: string; recordId: string }>()
return <CopyPageContent key={JSON.stringify(params)} slug={params.slug} recordId={params.recordId} />
}
const { data: metaData } = useQuery({
/**
* Own metadata/source loading while rendering components receive ready metadata.
* @param props - Exact route identifiers.
* @returns Base form and explicit full-copy draft flow.
*/
function CopyPageContent({ slug, recordId }: { slug: string; recordId: string }) {
const { setPageHeader, setTableMetaData } = useQContext()
const [mode, setMode] = useState<'base' | 'full'>('base')
const [tree, setTree] = useState<CopyNode>()
const [copyState, setCopyState] = useState<NonNullable<EntityFormProps['copyAssociations']>>()
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
const tableVariant = storedRecordVariantJson(tableMetaData)
const { record, isLoading, isError, error } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
enabled: canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
includeAssociations: false,
tableVariant,
})
const expanded = useRecord({ tableName: slug, primaryKey: recordId, includeAssociations: true,
tableVariant,
enabled: mode === 'full' && !tree && canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
})
const sourceTables = useMemo(() => {
if (!expanded.record) return { names: [] as string[] }
try { return { names: copyTableNames(expanded.record).filter(name => name !== slug) } }
catch (failure) { return { names: [] as string[], error: failure instanceof Error ? failure.message : 'Full copy source is invalid.' } }
}, [expanded.record, slug])
const targetQueries = useQueries({ queries: sourceTables.names.map(name => ({
queryKey: queryKeys.tableMetadata(name), queryFn: () => loadTableMetaData(name), staleTime: 1000 * 60 * 30,
enabled: mode === 'full' && !tree,
})) })
let fullError = sourceTables.error
if (expanded.isError || targetQueries.some(query => query.isError)) {
const accessDenied = (expanded.isError && getErrorStatusCode(expanded.error) === 403) ||
targetQueries.some(query => query.isError && getErrorStatusCode(query.error) === 403)
fullError = accessDenied
? 'You do not have access to all the information needed for Full Copy. Choose Base Copy to copy this record only.'
: 'Full Copy could not load all required information. Reload to try again, or choose Base Copy.'
}
let prepared: CopyNode | undefined
if (!tree && !fullError && expanded.record && record && tableMetaData && targetQueries.every(query => Boolean(query.data))) {
const tables: Record<string, QTableMetaData> = { [slug]: tableMetaData }
targetQueries.forEach((query, index) => { if (query.data) tables[sourceTables.names[index]] = query.data })
try { prepared = prepareCopyTree(tableMetaData, { ...expanded.record, values: record.values }, tables) }
catch (failure) { fullError = failure instanceof Error ? failure.message : 'Full copy source is invalid.' }
}
useEffect(() => { if (prepared && !tree) setTree(prepared) }, [prepared, tree])
const unavailable = fullError ?? (!tree || !copyState ? 'Loading full copy source and metadata…' : undefined)
const fullState = unavailable ? { getRecords: () => { throw new Error(unavailable) }, error: unavailable } : copyState
useEffect(() => {
setPageHeader(`Copy ${tableMetaData?.label ?? slug} #${recordId}`)
if (tableMetaData) {
@@ -39,6 +128,10 @@ export default function EntityCopyPage() {
}
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData || isLoading) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
@@ -47,14 +140,21 @@ export default function EntityCopyPage() {
)
}
if (!tableMetaData.insertPermission) {
if (!tableMetaData.readPermission) {
return <div role="alert" data-qqq-id="permission-denied" className="py-12 text-center text-destructive">You do not have permission to read the source {tableMetaData.label} record.</div>
}
if (!canInsertRecords(tableMetaData)) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
data-qqq-id="permission-denied"
>
<p className="text-sm text-yellow-700">
You do not have permission to create {tableMetaData.label} records.
{!hasCapability(tableMetaData, 'TABLE_INSERT')
? `${tableMetaData.label} records cannot be created.`
: `You do not have permission to create ${tableMetaData.label} records.`}
</p>
</div>
)
@@ -67,7 +167,7 @@ export default function EntityCopyPage() {
role="alert"
>
<p className="text-sm text-destructive">
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
{recordLoadFailure(tableMetaData.label, recordId, error)}
</p>
</div>
)
@@ -75,11 +175,25 @@ export default function EntityCopyPage() {
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-copy-${slug}-${recordId}`}>
<EntityForm
<fieldset className="mb-4 flex flex-wrap gap-4">
<legend className="mb-2 font-medium">Copy scope</legend>
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'base'} onChange={() => setMode('base')} data-qqq-id="copy-mode-base" />Base copy</label>
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'full'} onChange={() => setMode('full')} data-qqq-id="copy-mode-full" />Full copy</label>
</fieldset>
<p className="mb-4 text-sm text-muted-foreground">{mode === 'base' ? 'Copy this record’s editable fields. Associated records are not copied.' : 'Copy editable fields and every loaded named association. A record reached through two named paths is copied twice. Normal insert defaults and validation apply. Limited to 64 association levels and 1000 associated records in this form.'}</p>
<Suspense fallback={<div role="status" aria-label="Loading form" className="py-12 text-center">Loading form…</div>}><EntityForm
tableMetaData={tableMetaData}
widgets={metaData?.widgets}
record={record}
isCopy={true}
/>
copyAssociations={mode === 'full' ? fullState : undefined}
>
{tree && <fieldset hidden={mode !== 'full'} disabled={mode !== 'full'} className="min-w-0">
<Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading full copy draft…</p>}>
<FullCopyDraft tree={tree} onChange={setCopyState} />
</Suspense>
</fieldset>}
</EntityForm></Suspense>
</div>
)
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Prerenders this dynamic segment once for the Javalin-served static export.
* The server returns that page for every concrete value; see use-route-params.
*/
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
* Static export placeholder params for this segment.
*
* @returns One placeholder entry.
*/
export function generateStaticParams() {
return [{ childTable: EXPORT_PLACEHOLDER }]
}
/**
* Pass-through layout that exists to carry generateStaticParams.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
}
@@ -0,0 +1,39 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Create-child route — `/app/{table}/{id}/createChild/{childTable}`, the Material Dashboard
* path that opens a create form for a child record over the parent record. It opens the record
* view with the equivalent `#/createChild={childTable}` link, which shows that dialog.
*/
'use client'
import React from 'react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { createChildLinkHref } from '@/lib/utils/material-links'
import { RouteRedirect } from '@/components/layout/RouteRedirect'
/**
* Replaces the path form of a create-child link with the record view and its hash form.
*
* @returns A redirect status while the record view opens.
*/
export default function CreateChildPathPage() {
const { slug, recordId, childTable } = useRouteParams<{ slug: string; recordId: string; childTable: string }>()
return <RouteRedirect href={createChildLinkHref(slug, recordId, childTable)} label="the create form" />
}
@@ -0,0 +1,171 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the record developer view (raw values and associated scripts)
import { render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { QInstance } from '@/types'
import { QContextProvider, useQContext } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import RecordDeveloperViewPage from './page'
vi.mock('next/navigation', () => ({
useParams: () => ({ slug: 'person', recordId: '1' }),
usePathname: () => '/app/person/1/dev',
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/lib/api/tables', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/api/tables')>(),
getRecord: vi.fn(),
}))
vi.mock('@/lib/api/developer', () => ({
SCRIPT_LOG_TABLE: 'scriptLog',
scriptLogsFilter: (id: number) => ({ criteria: [{ fieldName: 'scriptRevisionId', operator: 'EQUALS', values: [id] }] }),
loadScriptTestDetails: vi.fn(),
queryScriptLogs: vi.fn(async () => []),
getRecordDeveloperData: vi.fn(),
getAssociatedScriptLogs: vi.fn(),
queryScriptRevisionFiles: vi.fn(async () => []),
queryScriptRevisions: vi.fn(async () => []),
queryScriptTypeFileSchemas: vi.fn(async () => []),
storeRecordAssociatedScript: vi.fn(),
storeScriptRevision: vi.fn(),
testScript: vi.fn(),
}))
import { getRecordDeveloperData } from '@/lib/api/developer'
import { getRecord } from '@/lib/api/tables'
const record = { tableName: 'person', recordLabel: 'Ada Lovelace', values: { id: 1, firstName: 'Ada', email: null } }
/**
* Shows the page header set through the Q context.
* @returns The header text.
*/
function PageHeader() {
const { pageHeader } = useQContext()
return <p data-testid="page-header">{String(pageHeader)}</p>
}
/**
* Serve instance metadata with the given script processes.
* @param processNames - Script processes the session may run.
* @returns Reports whether the metadata was requested.
*/
function serveInstance(processNames: string[]): () => boolean {
let served = false
const processes: QInstance['processes'] = { ...qInstance.processes }
for (const name of processNames) {
processes[name] = { name, label: name, tableName: '', isHidden: true, iconName: '', frontendSteps: [], hasPermission: true } as unknown as QInstance['processes'][string]
}
server.use(http.get('/qqq/v1/metaData', () => {
served = true
return HttpResponse.json({ ...qInstance, processes })
}))
return () => served
}
/**
* Render the page with fresh query and Q contexts.
* @returns The render result.
*/
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={client}>
<QContextProvider>
<PageHeader />
<RecordDeveloperViewPage />
</QContextProvider>
</QueryClientProvider>
)
}
describe('RecordDeveloperViewPage', () => {
beforeEach(() => {
vi.mocked(getRecord).mockResolvedValue(record)
vi.mocked(getRecordDeveloperData).mockResolvedValue({
record: { ...record, values: { ...record.values, firstName: 101 } },
associatedScripts: [{
associatedScript: { fieldName: 'firstName', scriptTypeId: 101 },
scriptType: { tableName: 'scriptType', values: { id: 101, fileMode: 1 } },
script: { tableName: 'script', values: { id: 101, name: 'Alpha Greeting', currentScriptRevisionId: 102 } },
}],
})
})
it('shows the raw values and a card per associated script, headed by the field label', async () => {
serveInstance(['storeScriptRevision', 'testScript'])
renderPage()
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' })).toBeInTheDocument()
const card = await waitFor(() => {
const element = document.querySelector('[data-qqq-id="associated-script-firstName"]')
if (!element) throw new Error('no card')
return element as HTMLElement
})
expect(within(card).getByRole('heading', { name: 'First Name' })).toBeInTheDocument()
await waitFor(() => expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).not.toBeNull())
expect(within(card).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Code', 'Logs', 'Test', 'Docs'])
expect(screen.getByTestId('page-header')).toHaveTextContent('Ada Lovelace Developer Mode')
expect(getRecordDeveloperData).toHaveBeenCalledWith('person', '1')
})
it('shows the raw values of the developer-mode record, not a separate record read (Material getRecordDeveloperMode)', async () => {
serveInstance([])
renderPage()
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' })).toBeInTheDocument()
const json = document.querySelector('[data-qqq-id="json-output"]') as HTMLElement
expect(JSON.parse(json.textContent ?? '')).toEqual({ id: 1, firstName: 101, email: null })
const table = document.querySelector('[data-qqq-id="record-dev-field-table"]') as HTMLElement
expect(within(table).getByText('101')).toBeInTheDocument()
expect(getRecord).not.toHaveBeenCalled()
})
it('gates editing and testing on the script processes in the instance metadata', async () => {
const served = serveInstance([])
renderPage()
await waitFor(() => expect(served()).toBe(true))
// let the metadata response settle into the query cache before asserting its absence
await new Promise((resolve) => setTimeout(resolve, 50))
const card = await waitFor(() => {
const element = document.querySelector('[data-qqq-id="associated-script-firstName"]')
if (!element) throw new Error('no card')
return element as HTMLElement
})
await waitFor(() => expect(within(card).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Code', 'Logs', 'Docs']))
expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).toBeNull()
})
it('reports a failure to load the developer-mode record', async () => {
serveInstance([])
vi.mocked(getRecordDeveloperData).mockRejectedValue(new Error('Permission denied.'))
renderPage()
expect(await screen.findByText('Failed to load record: Permission denied.')).toHaveAttribute('role', 'alert')
expect(screen.queryByRole('heading', { name: 'Record Raw Values as JSON' })).not.toBeInTheDocument()
})
})
@@ -1,32 +1,56 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file RecordDeveloperView page — raw record values and table metadata as JSON, plus the
* record's associated scripts (versions, code, editing, logs, testing, docs).
*/
'use client'
// RecordDeveloperView — shows raw record data and table metadata as formatted JSON
// Useful for debugging field values, types, and backend responses
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import React, { useCallback, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { useQContext } from '@/lib/context/q-context'
import { getRecord } from '@/lib/api/tables'
import { SCRIPT_LOG_TABLE, getRecordDeveloperData } from '@/lib/api/developer'
import { loadTableMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { AssociatedScriptViewer } from '@/components/records/AssociatedScriptViewer'
import { scriptLogsQueryHref } from '@/components/scripts/script-utils'
/**
* Renders a developer debug view for a single record, showing all field values
* in a table and the raw record and table metadata as collapsible JSON blocks.
*
* @returns A composed page that assembles:
* - A field-values summary table (field name, type, raw value for every field in the record)
* - A `<JsonBlock>` for the raw record values (expanded by default)
* - An `<AssociatedScriptViewer>` card per associated script declared on the table
* - A `<JsonBlock>` for the full table metadata (collapsed by default)
* - A loading spinner while either query is in-flight, and an error panel if the record fetch fails
*/
export default function RecordDeveloperViewPage() {
const params = useParams<{ slug: string; recordId: string }>()
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader } = useQContext()
const { slug, recordId } = params
useEffect(() => {
setPageHeader(`Dev: ${slug} #${recordId}`)
}, [slug, recordId, setPageHeader])
const { data: record, isLoading: recordLoading, error: recordError } = useQuery({
queryKey: queryKeys.tableRecord(slug, recordId),
queryFn: () => getRecord(slug, recordId),
staleTime: 1000 * 60 * 5,
})
const queryClient = useQueryClient()
const { data: metaData, isLoading: metaLoading } = useQuery({
queryKey: queryKeys.tableMetadata(slug),
@@ -34,7 +58,32 @@ export default function RecordDeveloperViewPage() {
staleTime: 1000 * 60 * 30,
})
const { data: instance } = useMetaData()
// Material's RecordDeveloperView reads the record from the developer-mode route: the same
// response carries the raw values and the associated scripts.
const { data: developerData, isLoading: recordLoading, error: recordError } = useQuery({
queryKey: queryKeys.recordDeveloper(slug, recordId),
queryFn: () => getRecordDeveloperData(slug, recordId),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const record = developerData?.record
// "View All" logs link, when the session can read the script log table
const logsViewAllHref = instance?.tables?.[SCRIPT_LOG_TABLE] ? scriptLogsQueryHref : undefined
const headerLabel = record?.recordLabel || (metaData ? `${metaData.label} ${recordId}` : `${slug} #${recordId}`)
useEffect(() => {
setPageHeader(`${headerLabel} Developer Mode`)
}, [headerLabel, setPageHeader])
const reloadRecord = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(slug, recordId) }),
[queryClient, slug, recordId]
)
const isLoading = recordLoading || metaLoading
const canEditScripts = Boolean(instance?.processes?.storeScriptRevision)
const canTestScripts = Boolean(instance?.processes?.testScript)
return (
<div className="space-y-6" data-qqq-id={`record-dev-${slug}-${recordId}`}>
@@ -64,7 +113,7 @@ export default function RecordDeveloperViewPage() {
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
role="alert"
>
Failed to load record: {recordError instanceof Error ? recordError.message : 'Unknown error'}
Failed to load record: {getErrorMessage(recordError)}
</div>
)}
@@ -114,7 +163,28 @@ export default function RecordDeveloperViewPage() {
</div>
)}
<JsonBlock label="Raw Record" value={record} />
<JsonBlock label="Record Raw Values as JSON" value={record.values} />
{metaData && developerData?.associatedScripts.map((associated) => {
const fieldName = associated.associatedScript.fieldName
const scriptId = record.values[fieldName]
return (
<AssociatedScriptViewer
key={fieldName}
tableName={slug}
recordId={recordId}
fieldLabel={metaData.fields[fieldName]?.label ?? fieldName}
data={associated}
scriptId={typeof scriptId === 'string' || typeof scriptId === 'number' ? scriptId : null}
canCreate={Boolean(metaData.editPermission)}
canEdit={canEditScripts}
canTest={canTestScripts}
logsViewAllHref={logsViewAllHref}
onChanged={reloadRecord}
/>
)
})}
{metaData && <JsonBlock label="Table Metadata" value={metaData} defaultOpen={false} />}
</div>
)}
@@ -122,6 +192,15 @@ export default function RecordDeveloperViewPage() {
)
}
/**
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
*
* @param props - Component props.
* @param props.label - Heading displayed in the toggle button.
* @param props.value - The value to serialize and display as JSON.
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
* @returns A collapsible JSON inspector card.
*/
function JsonBlock({
label,
value,
@@ -135,23 +214,25 @@ function JsonBlock({
return (
<div className="overflow-hidden rounded-xl border border-border">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
aria-expanded={open}
data-qqq-id="button-json-block-toggle"
>
<span className="flex items-center gap-2">
<Code className="h-4 w-4" aria-hidden="true" />
{label}
</span>
{open ? (
<ChevronUp className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
)}
</button>
<h3 className="m-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
aria-expanded={open}
data-qqq-id="button-json-block-toggle"
>
<span className="flex items-center gap-2">
<Code className="h-4 w-4" aria-hidden="true" />
{label}
</span>
{open ? (
<ChevronUp className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
)}
</button>
</h3>
{open && (
<pre
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
@@ -1,35 +1,85 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the edit form.
*/
'use client'
// EntityEdit — edit an existing record
// Package 3: full implementation using EntityForm + useRecord
/**
* EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the
* edit form for an existing QQQ record.
*
* Route params:
* - `slug` — the QQQ table name.
* - `recordId` — the primary-key value of the record to edit.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import React, { lazy, Suspense, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { EntityForm } from '@/components/forms/EntityForm'
import { recordLoadFailure } from '@/lib/utils/error-utils'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { canEditRecords, hasCapability } from '@/lib/auth/permissions'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
const EntityForm = lazy(() => import('@/components/forms/EntityForm').then((module) => ({ default: module.EntityForm })))
/**
* Renders the record-edit form for the record identified by `slug` and `recordId`.
*
* Fetches both application metadata and the existing record data via `useRecord`.
* Shows a loading spinner while either is pending. Renders a permission error
* when the user lacks `editPermission` on the table, and a fetch-error panel
* when the record cannot be loaded. On success renders `<EntityForm>` in edit
* mode (pre-populated with the existing record values).
*
* @returns A composed page that renders one of:
* - A full-screen spinner while metadata or record data is loading
* - A permission-error banner when the user lacks `editPermission`
* - A destructive error panel when the record cannot be fetched
* - `<EntityForm>` in edit mode (pre-populated with the existing record values)
* wrapped in a centered `max-w-6xl` container
*/
export default function EntityEditPage() {
const params = useParams<{ slug: string; recordId: string }>()
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
const tableVariant = storedRecordVariantJson(tableMetaData)
const { record, isLoading, isError, error } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
enabled: canEditRecords(tableMetaData),
includeAssociations: false,
tableVariant,
})
useEffect(() => {
@@ -39,6 +89,10 @@ export default function EntityEditPage() {
}
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData || isLoading) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
@@ -47,14 +101,17 @@ export default function EntityEditPage() {
)
}
if (!tableMetaData.editPermission) {
if (!canEditRecords(tableMetaData)) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
data-qqq-id="permission-denied"
>
<p className="text-sm text-yellow-700">
You do not have permission to edit {tableMetaData.label} records.
{!hasCapability(tableMetaData, 'TABLE_UPDATE')
? `${tableMetaData.label} records cannot be edited.`
: `You do not have permission to edit ${tableMetaData.label} records.`}
</p>
</div>
)
@@ -67,15 +124,15 @@ export default function EntityEditPage() {
role="alert"
>
<p className="text-sm text-destructive">
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
{recordLoadFailure(tableMetaData.label, recordId, error)}
</p>
</div>
)
}
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
<EntityForm tableMetaData={tableMetaData} record={record} />
<div className="mx-auto max-w-6xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
<Suspense fallback={<div role="status" aria-label="Loading form" className="py-12 text-center">Loading form…</div>}><EntityForm tableMetaData={tableMetaData} record={record} widgets={metaData?.widgets} /></Suspense>
</div>
)
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Prerenders this dynamic segment once for the Javalin-served static export.
* The server returns that page for every concrete value; see use-route-params.
*/
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
* Static export placeholder params for this segment.
*
* @returns One placeholder entry.
*/
export function generateStaticParams() {
return [{ recordId: EXPORT_PLACEHOLDER }]
}
/**
* Pass-through layout that exists to carry generateStaticParams.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
}
@@ -0,0 +1,296 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { deserializeFilter } from '@/lib/utils/filter-utils'
import apiClient from '@/lib/api/client'
import { QContextProvider } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import { recordGet } from '@/mocks/v1-record'
import RecordViewPage from './page'
const navigation = vi.hoisted(() => ({ search: 'tab=related' }))
vi.mock('next/navigation', () => ({
useParams: () => ({ slug: 'person', recordId: '1' }),
usePathname: () => '/app/person/1',
useSearchParams: () => new URLSearchParams(navigation.search),
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
}))
const baseRecord = { tableName: 'person', values: { id: 1, firstName: 'Avery' }, recordLabel: 'Avery Sample' }
function fixture(childRead = false) {
const person = structuredClone(qInstance.tables.person)
const pet = { ...structuredClone(qInstance.tables.company), name: 'pet', label: 'Pet', readPermission: childRead, insertPermission: true }
pet.fields.personId = { ...person.fields.id, name: 'personId', label: 'Person', isEditable: true, isHidden: false }
person.exposedJoins = []
person.associations = [{ name: 'pets', associatedTableName: 'pet', join: {
name: 'personJoinPet', type: 'ONE_TO_MANY', leftTable: 'person', rightTable: 'pet',
joinOns: [{ leftField: 'id', rightField: 'personId' }],
} }]
const registry = { ...qInstance, widgets: { ...qInstance.widgets }, tables: { person: { name: 'person', label: 'Person', readPermission: true }, pet: { name: 'pet', readPermission: childRead, insertPermission: true } } }
server.use(
http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)),
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(person)),
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json(pet)),
)
return { person, pet, registry }
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}><QContextProvider><RecordViewPage /></QContextProvider></QueryClientProvider>)
}
describe('RecordViewPage independent association loading', () => {
beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); navigation.search = 'tab=related' })
it('keeps the base and child Add, and requests no expansion, when the child table is listed without read permission (QRun-IO/qqq#671)', async () => {
fixture()
const requests: string[] = []
server.use(recordGet('/table/person/1', ({ request }) => {
requests.push(new URL(request.url).searchParams.get('includeAssociations') ?? '')
return HttpResponse.json(baseRecord)
}))
renderPage()
expect(await screen.findByText('Avery Sample', {}, { timeout: 5000 })).toBeVisible()
expect(await screen.findByText('Related records are unavailable.')).toBeVisible()
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
expect(requests).toEqual(['false'])
})
it('keeps the readable base and child Add when whole association expansion is denied', async () => {
const { person: metadata, pet } = fixture(true)
pet.readPermission = false
const before = structuredClone(metadata)
const requests: string[] = []
server.use(recordGet('/table/person/1', ({ request }) => {
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
requests.push(mode)
return mode === 'true'
? HttpResponse.json({ error: 'Pet read denied' }, { status: 403 })
: HttpResponse.json(baseRecord)
}))
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
expect(await screen.findByText('Related records could not be loaded.')).toBeVisible()
const add = screen.getByRole('button', { name: '+ Add Pet' })
expect(add).toBeEnabled()
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
fireEvent.click(add)
expect(await screen.findByRole('dialog', { name: 'Add Pet' })).toBeVisible()
expect(requests).toEqual(['false', 'true'])
expect(metadata).toEqual(before)
})
it('neither requests nor shows an association whose table is hidden from the user (QRun-IO/qqq#671)', async () => {
const { registry } = fixture()
const { pet: _hidden, ...visibleTables } = registry.tables
void _hidden
const requests: string[] = []
server.use(
http.get('/qqq/v1/metaData', () => HttpResponse.json({ ...registry, tables: visibleTables })),
http.get('/qqq/v1/metaData/table/pet', () => { requests.push('pet metadata'); return HttpResponse.json({ error: 'not found' }, { status: 404 }) }),
recordGet('/table/person/1', ({ request }) => {
requests.push(`includeAssociations=${new URL(request.url).searchParams.get('includeAssociations')}`)
return HttpResponse.json(baseRecord)
}),
)
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
await waitFor(() => expect(requests).toContain('includeAssociations=false'))
expect(requests).toEqual(['includeAssociations=false'])
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
expect(screen.queryByText('Related records could not be loaded.')).not.toBeInTheDocument()
})
it('does not rename association keys or present an unmatched association as empty', async () => {
fixture(true)
server.use(recordGet('/table/person/1', ({ request }) => HttpResponse.json({
...baseRecord,
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
associatedRecords: { pet: [{ tableName: 'pet', values: { id: 2, name: 'Actual pet' }, recordLabel: 'Actual pet' }] },
} : {}),
})))
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
await waitFor(() => expect(screen.queryByText('Loading related records...')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
})
it('retains explicitly keyed related records when the response binding is available', async () => {
fixture(true)
server.use(recordGet('/table/person/1', ({ request }) => HttpResponse.json({
...baseRecord,
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
associatedRecords: { pets: [{ tableName: 'pet', values: { id: 2, name: 'Mapped pet' }, recordLabel: 'Mapped pet' }] },
} : {}),
})))
renderPage()
expect(await screen.findByText('Mapped pet')).toBeVisible()
expect(screen.queryByText('Related records are unavailable.')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
})
it('keeps arbitrary aliases distinct and deduplicates target metadata loads', async () => {
const { person, pet } = fixture(true)
person.associations = ['care / primary', 'scheduled reviews'].map((name) => ({ ...person.associations![0], name }))
const metadataCalls: string[] = []
let releaseMetadata!: () => void
const ready = new Promise<void>((resolve) => { releaseMetadata = resolve })
server.use(
http.get('/qqq/v1/metaData/table/pet', async () => { metadataCalls.push('pet'); await ready; return HttpResponse.json(pet) }),
recordGet('/table/person/1', ({ request }) => HttpResponse.json({ ...baseRecord,
...(new URL(request.url).searchParams.get('includeAssociations') === 'true'
? { associatedRecords: { 'care / primary': [], 'scheduled reviews': [{ tableName: 'pet', values: { id: 4, name: 'Review pet' } }] } } : {}),
})),
)
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
await waitFor(() => expect(screen.getAllByText('Loading related metadata...')).toHaveLength(2))
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
releaseMetadata()
expect(await screen.findByText('No care / primary records')).toBeVisible()
expect(await screen.findByText('Review pet')).toBeVisible()
expect(metadataCalls).toEqual(['pet'])
expect(screen.getAllByRole('button', { name: '+ Add Pet' })).toHaveLength(2)
})
it.each(['tab=overview', 'view=list'])('places explicitly bound groups in their sections (%s)', async (view) => {
navigation.search = view
const { person, registry } = fixture(true)
person.associations = [{ ...person.associations![0], name: 'care / primary' }, { ...person.associations![0], name: 'scheduled reviews' }]
person.sections = [
{ name: 'fieldGuide', label: 'Companions', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'companionPanel' },
{ name: 'reviewSchedule', label: 'Reviews', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'reviewEditor' },
]
registry.widgets.companionPanel = { name: 'companionPanel', label: 'Different widget title', type: 'childRecordList', hasPermission: true, defaultValues: { manageAssociationName: 'care / primary' } }
registry.widgets.reviewEditor = { name: 'reviewEditor', label: 'Another title', type: 'rowBuilder', hasPermission: true, defaultValues: { associationName: 'scheduled reviews' } }
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { 'care / primary': [], 'scheduled reviews': [] } })))
const { container } = renderPage()
const byId = (id: string) => Array.from(container.querySelectorAll('[data-qqq-id]')).find((element) => element.getAttribute('data-qqq-id') === id)
await waitFor(() => expect(byId('button-create-association-care%20%2F%20primary')).toBeEnabled())
expect(byId('associated-records-care%20%2F%20primary')).toHaveTextContent('Companions')
expect(byId('associated-records-scheduled%20reviews')).toHaveTextContent('Reviews')
expect(screen.queryByRole('tab', { name: 'Related' })).not.toBeInTheDocument()
})
it('localizes target metadata failure without hiding the base or claiming an empty group', async () => {
fixture(true)
server.use(
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json({ error: 'Unavailable' }, { status: 500 })),
recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })),
)
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
expect(await screen.findByText('Related table metadata is unavailable.')).toBeVisible()
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
})
it('submits every composite non-PK relationship field despite their exclusion from editable inputs', async () => {
const { person, pet } = fixture(false)
person.fields.schedule = { ...person.fields.id, name: 'schedule' }
person.fields.visitDate = { ...person.fields.firstName, name: 'visitDate', type: 'DATE' }
pet.fields.birthDate = { ...person.fields.visitDate, name: 'birthDate', isEditable: true }
pet.fields.personId.possibleValueSourceName = undefined
person.associations![0].join.joinOns = [{ leftField: 'schedule', rightField: 'personId' }, { leftField: 'visitDate', rightField: 'birthDate' }]
server.use(recordGet('/table/person/1', ({ request }) => new URL(request.url).searchParams.get('includeAssociations') === 'true'
? HttpResponse.json({ error: 'Denied' }, { status: 403 })
: HttpResponse.json({ ...baseRecord, values: { ...baseRecord.values, schedule: 2, visitDate: '1990-01-15' } })))
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'pet', values: { id: 8, personId: 2, birthDate: '1990-01-15' } } })
const before = structuredClone({ person, pet })
renderPage()
fireEvent.click(await screen.findByRole('button', { name: '+ Add Pet' }))
const dialog = await screen.findByRole('dialog', { name: 'Add Pet' })
expect(within(dialog).getAllByRole('heading', { name: 'Add Pet' })).toHaveLength(1)
expect(within(dialog).queryByLabelText('Person')).not.toBeInTheDocument()
fireEvent.change(within(dialog).getByLabelText(/Name/), { target: { value: 'Created child' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
await waitFor(() => expect(post).toHaveBeenCalled())
expect(post.mock.calls[0][0]).toBe('/table/pet')
const values = post.mock.calls[0][1] as FormData
expect(values.get('personId')).toBe('2')
expect(values.get('birthDate')).toBe('1990-01-15')
expect(values.get('name')).toBe('Created child')
expect(values.has('associations')).toBe(false)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect({ person, pet }).toEqual(before)
})
it('round-trips Unicode and plus-bearing base64 View All filters through URLSearchParams', async () => {
const { person, pet } = fixture(true)
person.associations![0].join.joinOns = [{ leftField: 'firstName', rightField: 'personId' }]
pet.fields.personId.type = 'STRING'
const value = '>>>漢'
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, values: { id: 1, firstName: value }, associatedRecords: { pets: [] } })))
renderPage()
const link = await screen.findByRole('link', { name: 'View All' })
const url = new URL(link.getAttribute('href')!, 'http://localhost')
expect(url.search).toContain('%2B')
expect(deserializeFilter(url.searchParams.get('filter')!)).toMatchObject({ criteria: [{ fieldName: 'personId', operator: 'EQUALS', values: [value] }], booleanOperator: 'AND' })
})
it('allows correctly oriented direct Add for reverse joins without claiming expanded absence', async () => {
const { person } = fixture(true)
person.associations![0].join = { ...person.associations![0].join, leftTable: 'pet', rightTable: 'person', type: 'MANY_TO_ONE', joinOns: [{ leftField: 'personId', rightField: 'id' }] }
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })))
renderPage()
expect(await screen.findByText('Related record loading for reverse associations is not supported.')).toBeVisible()
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
})
it('retains a panel with no visible columns while denying an unavailable relationship write', async () => {
const { person, pet } = fixture(false)
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
delete person.fields.id
server.use(recordGet('/table/person/1', () => HttpResponse.json(baseRecord)))
renderPage()
expect(await screen.findByText('Relationship values are unavailable; this record cannot be linked.')).toBeVisible()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
})
it('keeps permitted Add available when there are no visible child columns', async () => {
const { pet } = fixture(false)
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
server.use(recordGet('/table/person/1', () => HttpResponse.json(baseRecord)))
renderPage()
expect(await screen.findByRole('button', { name: '+ Add Pet' })).toBeEnabled()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
})
it('keeps readable data and View All when INSERT alone is denied', async () => {
const { pet } = fixture(true)
pet.insertPermission = false
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [{ tableName: 'pet', values: { id: 'one/two?', name: 'Readable pet' } }] } })))
renderPage()
const recordLinks = await screen.findAllByRole('link', { name: 'View Pet record one/two?' })
for (const link of recordLinks) expect(link.getAttribute('href')).toContain('/app/pet/one%2Ftwo%3F?')
expect(screen.getByRole('link', { name: 'View All' })).toBeVisible()
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
})
})
@@ -1,40 +1,134 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file RecordView page — serves the route `/app/[slug]/[recordId]` and renders the record detail view.
*/
'use client'
// RecordView page — view a single record
// Package 3: full implementation using RecordView component + useRecord hook
/**
* RecordView page — serves the route `/app/[slug]/[recordId]` and renders a
* detailed view of a single QQQ record.
*
* Route params:
* - `slug` — the QQQ table name.
* - `recordId` — the primary-key value of the record to display.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import React, { lazy, Suspense, useEffect } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { addRecentRecord } from '@/lib/utils/recent-records'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { addRecentRecord, removeRecentRecord } from '@/lib/utils/recent-records'
import { recordAnalytics } from '@/lib/analytics'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
import { auditSource } from '@/lib/api/audits'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { RecordView } from '@/components/records/RecordView'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import type { AssociationTableState } from '@/lib/utils/association-utils'
import { canReadRecords } from '@/lib/auth/permissions'
import { processRunHref, tableProcessForSegment, tableReportForSegment } from '@/lib/utils/material-links'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { recordSectionQueryJoins } from '@/lib/utils/record-layout-utils'
import { RouteRedirect } from '@/components/layout/RouteRedirect'
const RecordView = lazy(() => import('@/components/records/RecordView').then((module) => ({ default: module.RecordView })))
/**
* Renders the detail view for a single record identified by `slug` (table name)
* and `recordId` (primary key).
*
* Loads the base record and optional whole-association expansion independently. Once
* the record loads it is registered in the recently-viewed history (via
* `addRecentRecord`) so it surfaces in the GlobalSearch and SearchDialog.
* The page header in QContext is updated to the record label.
*
* @returns A composed view that assembles:
* - A full-screen spinner while table metadata resolves
* - `<RecordView>` wired with the fetched record, loading state, error state,
* a refetch callback, the list of available processes, and all table definitions
* (needed to render association sub-tables)
*/
export default function RecordViewPage() {
const params = useParams<{ slug: string; recordId: string }>()
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
const tableVariant = storedRecordVariantJson(tableMetaData)
const joins = tableMetaData && metaData?.tables ? recordSectionQueryJoins(tableMetaData, metaData.tables) : []
const queryJoins = joins.length ? JSON.stringify(joins) : undefined
// Material URL shapes: /app/{table}/{process} runs the process, /app/{table}/{report} runs the report.
const scopedProcess = metaData?.tables?.[slug] ? tableProcessForSegment(metaData, slug, recordId) : null
const scopedReport = metaData?.tables?.[slug] && !scopedProcess ? tableReportForSegment(metaData, slug, recordId) : null
const { record, isLoading, isError, error, refetch } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
includeAssociations: true,
enabled: canReadRecords(tableMetaData) && Boolean(metaData) && !scopedProcess && !scopedReport,
includeAssociations: false,
tableVariant,
queryJoins,
})
const targetNames = [...new Set(tableMetaData?.associations?.map((association) => association.associatedTableName) ?? [])]
// Associated tables hidden from the user (absent from their metadata) are neither requested nor shown;
// disabled ones (listed, readPermission false) keep their panel so permitted actions such as Add remain.
const deniedTargets = new Set(targetNames.filter((name) => metaData?.tables && !metaData.tables[name]))
const readableTargets = targetNames.filter((name) => !deniedTargets.has(name))
const targetQueries = useQueries({ queries: readableTargets.map((name) => ({
queryKey: queryKeys.tableMetadata(name),
queryFn: () => loadTableMetaData(name),
staleTime: 1000 * 60 * 30,
})) })
const associationTables: Record<string, AssociationTableState> = Object.fromEntries(targetNames.map((name) => {
if (deniedTargets.has(name)) return [name, { isLoading: false, isError: false, isDenied: true }]
const target = targetQueries[readableTargets.indexOf(name)]
return [name, { table: target.data, isLoading: target.isLoading, isError: target.isError }]
}))
// The legacy endpoint expands every association (all or nothing), so it is not requested
// when an associated table is hidden from the user or listed without read permission
// (DenyBehavior.DISABLED; its panel says the records are unavailable). A denial must not
// hide the base record.
const unreadableTargets = readableTargets.filter((name) => metaData?.tables?.[name]?.readPermission === false)
const associations = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(record) && Boolean(tableMetaData?.associations?.length) && Boolean(metaData)
&& deniedTargets.size === 0 && unreadableTargets.length === 0,
includeAssociations: true,
tableVariant,
})
const displayRecord = record && associations.record && !associations.isError
? { ...record, associatedRecords: associations.record.associatedRecords }
: record
useEffect(() => {
if (record?.recordLabel) {
setPageHeader(record.recordLabel)
@@ -49,16 +143,38 @@ export default function RecordViewPage() {
// Track recently viewed records for global search
useEffect(() => {
if (record && tableMetaData) {
// the record label is sent only when the application allows record data (QRun-IO/qqq#730)
recordAnalytics({ category: 'tableEvents', action: 'view', label: tableMetaData.label, recordLabel: record.recordLabel })
addRecentRecord({
tableName: tableMetaData.name,
tableLabel: tableMetaData.label,
recordId: String(record.values[tableMetaData.primaryKeyField]),
recordLabel: record.recordLabel || `${tableMetaData.label} #${record.values[tableMetaData.primaryKeyField]}`,
path: `/app/${tableMetaData.name}/${record.values[tableMetaData.primaryKeyField]}`,
tableIcon: tableMetaData.icon,
})
}
}, [record, tableMetaData])
// A record that no longer exists (404) or may no longer be viewed (403) leaves the recents (Material HistoryUtils)
const recordStatus = isError ? getErrorStatusCode(error) : undefined
useEffect(() => {
if (recordStatus === 404 || recordStatus === 403) removeRecentRecord(slug, recordId)
}, [recordStatus, slug, recordId])
if (metaData && scopedProcess) {
const search = typeof window === 'undefined' ? '' : window.location.search
const href = processRunHref(scopedProcess, { search, returnTo: `/app/${encodeURIComponent(slug)}` })
return <RouteRedirect href={href} label={metaData.processes[scopedProcess]?.label ?? scopedProcess} />
}
if (metaData && scopedReport) {
return <RouteRedirect href={`/app/${encodeURIComponent(scopedReport)}`} label={metaData.reports[scopedReport]?.label ?? scopedReport} />
}
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
@@ -67,18 +183,40 @@ export default function RecordViewPage() {
)
}
if (!canReadRecords(tableMetaData)) {
return (
<div className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center" role="alert" data-qqq-id="permission-denied">
<p className="text-sm text-yellow-700">You do not have permission to view {tableMetaData.label} records.</p>
</div>
)
}
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
return (
<RecordView
tableMetaData={tableMetaData}
record={record}
isLoading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
processes={tableProcesses}
allTables={metaData?.tables}
/>
<>
{record && associations.isLoading && <p role="status">Loading related records...</p>}
{record && associations.isError && (
<p role="alert" className="mb-4 text-destructive">Related records could not be loaded.</p>
)}
<Suspense fallback={<div role="status" aria-label="Loading record view" className="py-12 text-center">Loading record…</div>}><RecordView
instance={metaData}
tableMetaData={tableMetaData}
record={displayRecord}
isLoading={isLoading}
isError={isError}
error={error}
onRefetch={() => {
refetch()
if (tableMetaData.associations?.length) associations.refetch()
}}
processes={tableProcesses}
allProcesses={metaData?.processes}
allTables={metaData?.tables}
widgetMetaDataMap={metaData?.widgets}
associationTables={associationTables}
auditSource={auditSource(metaData)}
/></Suspense>
</>
)
}
+74 -13
View File
@@ -1,29 +1,77 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file EntityCreate page — serves the route `/app/[slug]/create` and renders the record-creation form.
*/
'use client'
// EntityCreate — create a new record
// Package 3: full implementation using EntityForm component
/**
* EntityCreate page — serves the route `/app/[slug]/create` and renders the
* record-creation form for the table identified by `slug`.
*
* Route params:
* - `slug` — the QQQ table name for which a new record should be created.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import React, { lazy, Suspense, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { EntityForm } from '@/components/forms/EntityForm'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { canInsertRecords, hasCapability } from '@/lib/auth/permissions'
import { formPresetsFromHash, lockedPresetValues, type HashFormPresets } from '@/lib/utils/material-links'
const EntityForm = lazy(() => import('@/components/forms/EntityForm').then((module) => ({ default: module.EntityForm })))
/**
* Renders the record-creation form for the table identified by `slug`.
*
* Fetches application metadata to resolve the table definition, then renders
* `<EntityForm>` with no initial record (create mode). Shows a permission
* error banner when the user lacks `insertPermission` on the table, and a
* loading spinner while metadata is being fetched.
*
* @returns A composed page that renders one of:
* - A full-screen spinner while metadata resolves
* - A permission-error banner when the user lacks `insertPermission`
* - `<EntityForm>` in create mode (no `record` prop) wrapped in a centered `max-w-6xl` container
*/
export default function EntityCreatePage() {
const params = useParams<{ slug: string }>()
const params = useRouteParams<{ slug: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const slug = params.slug
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
// Material links preset fields: /create#/defaultValues={json}/disabledFields={json}
const [presets, setPresets] = useState<HashFormPresets | null>(null)
useEffect(() => {
setPresets(formPresetsFromHash(window.location.hash))
}, [])
useEffect(() => {
setPageHeader(`Create ${tableMetaData?.label ?? slug}`)
@@ -32,7 +80,11 @@ export default function EntityCreatePage() {
}
}, [tableMetaData?.label, slug, tableMetaData, setPageHeader, setTableMetaData])
if (!tableMetaData) {
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData || !presets) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
@@ -40,22 +92,31 @@ export default function EntityCreatePage() {
)
}
if (!tableMetaData.insertPermission) {
if (!canInsertRecords(tableMetaData)) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
data-qqq-id="permission-denied"
>
<p className="text-sm text-yellow-700">
You do not have permission to create {tableMetaData.label} records.
{!hasCapability(tableMetaData, 'TABLE_INSERT')
? `${tableMetaData.label} records cannot be created.`
: `You do not have permission to create ${tableMetaData.label} records.`}
</p>
</div>
)
}
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-create-${slug}`}>
<EntityForm tableMetaData={tableMetaData} />
<div className="mx-auto max-w-6xl" data-qqq-id={`entity-create-${slug}`}>
<Suspense fallback={<div role="status" aria-label="Loading form" className="py-12 text-center">Loading form…</div>}><EntityForm
tableMetaData={tableMetaData}
widgets={metaData?.widgets}
defaultValues={presets.defaultValues}
disabledFieldNames={presets.disabledFields}
fixedValues={lockedPresetValues(tableMetaData, presets)}
/></Suspense>
</div>
)
}
@@ -0,0 +1,154 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the table Developer view's ESB section wiring
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QContextProvider } from '@/lib/context/q-context'
import { queryClient, queryKeys } from '@/lib/query-client'
import { server } from '@/mocks/node'
import TableDeveloperViewPage from './page'
const { params } = vi.hoisted(() => ({ params: { slug: 'order' } }))
vi.mock('next/navigation', () => ({
useParams: () => params,
usePathname: () => `/app/${params.slug}/dev`,
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('sonner', async (importOriginal) => ({
...(await importOriginal<typeof import('sonner')>()),
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
}))
// The page's API Docs section is covered by page.test.tsx; keep it empty here.
vi.mock('@/lib/api/developer', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/api/developer')>()),
getTableApis: vi.fn(async () => []),
}))
/**
* Renders the page with a query client.
*
* @param client - The query client; a fresh one without retries by default.
* @returns The query client, for inspecting query state.
*/
function renderPage(
client: QueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
): QueryClient {
render(
<QueryClientProvider client={client}>
<QContextProvider>
<TableDeveloperViewPage />
</QContextProvider>
</QueryClientProvider>
)
return client
}
/**
* Waits until the table's metadata has rendered and its ESB request has settled.
*
* @param client - The page's query client.
* @param tableName - The table shown.
*/
async function settled(client: QueryClient, tableName: string): Promise<void> {
await screen.findByText('Fields')
await waitFor(() =>
expect(client.getQueryState(queryKeys.esbTable(tableName))?.status).toBe('success')
)
}
describe('TableDeveloperViewPage ESB section', () => {
beforeEach(() => {
params.slug = 'order'
vi.clearAllMocks()
})
afterEach(() => {
queryClient.clear()
})
it('shows the ESB section returned for the table', async () => {
renderPage()
expect(
await screen.findByRole('heading', { name: 'Enterprise Service Bus' })
).toBeInTheDocument()
expect(screen.getByRole('table', { name: 'Publications' })).toBeInTheDocument()
expect(screen.getByRole('table', { name: 'Subscribers' })).toBeInTheDocument()
})
it('omits the section when the user may not read the table', async () => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
HttpResponse.json({ error: 'Permission denied.' }, { status: 403 })
)
)
const client = renderPage()
await settled(client, 'order')
expect(
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
).not.toBeInTheDocument()
})
it('omits the section when the table has no ESB metadata', async () => {
params.slug = 'person'
const client = renderPage()
await settled(client, 'person')
expect(
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
).not.toBeInTheDocument()
})
it('shows the process Developer view with its ESB triggers for a process slug', async () => {
params.slug = 'fulfillOrder'
renderPage()
expect(
await screen.findByRole('heading', { name: 'Process Developer View: fulfillOrder' })
).toBeInTheDocument()
expect(
await screen.findByRole('heading', { name: 'Enterprise Service Bus' })
).toBeInTheDocument()
expect(screen.getByRole('table', { name: 'Triggers' })).toBeInTheDocument()
expect(screen.queryByText('Fields')).not.toBeInTheDocument()
})
it('keeps the table Developer view for a table slug', async () => {
renderPage()
expect(
await screen.findByRole('heading', { name: 'Table Developer View: Orders' })
).toBeInTheDocument()
})
it('omits the section without a toast when a backend without the ESB module answers with its SPA page', async () => {
server.use(
http.get('/qqq/v1/esb/table/order', () =>
HttpResponse.html('<!doctype html><html><body>SPA</body></html>')
)
)
const client = renderPage(queryClient)
await settled(client, 'order')
expect(
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
).not.toBeInTheDocument()
expect(toast.error).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,192 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the table developer view's API Docs & Playground
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { QContextProvider, useQContext } from '@/lib/context/q-context'
import TableDeveloperViewPage from './page'
const { rapidocLoaded } = vi.hoisted(() => ({ rapidocLoaded: vi.fn() }))
vi.mock('next/navigation', () => ({
useParams: () => ({ slug: 'person' }),
usePathname: () => '/app/person/dev',
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('rapidoc', () => {
rapidocLoaded()
return {}
})
vi.mock('@/lib/api/developer', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/api/developer')>(),
getTableApis: vi.fn(),
getApiVersions: vi.fn(),
}))
import { getApiVersions, getTableApis } from '@/lib/api/developer'
const getTableApisMock = vi.mocked(getTableApis)
const getApiVersionsMock = vi.mocked(getApiVersions)
const apis = [
{ name: 'person-api', path: '/person-api/', label: 'Person API' },
{ name: 'admin-api', path: '/admin-api/', label: 'Admin API' },
]
const versions: Record<string, { supportedVersions: string[]; currentVersion: string }> = {
'/person-api/': { supportedVersions: ['2026.Q1', '2026.Q2'], currentVersion: '2026.Q2' },
'/admin-api/': { supportedVersions: ['v1', 'v2'], currentVersion: 'v1' },
}
/**
* Shows the page header set through the Q context.
* @returns The header text.
*/
function PageHeader() {
const { pageHeader } = useQContext()
return <p data-testid="page-header">{String(pageHeader)}</p>
}
/**
* Render the page with fresh query and Q contexts.
* @returns The render result.
*/
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={client}>
<QContextProvider>
<PageHeader />
<TableDeveloperViewPage />
</QContextProvider>
</QueryClientProvider>
)
}
describe('TableDeveloperViewPage API Docs & Playground', () => {
beforeEach(() => {
window.localStorage.clear()
getTableApisMock.mockReset()
getApiVersionsMock.mockReset()
getApiVersionsMock.mockImplementation(async (path: string) => versions[path])
})
it('selects the first API and its current version and embeds RapiDoc for the table spec', async () => {
getTableApisMock.mockResolvedValue(apis)
const { container } = renderPage()
const section = await screen.findByRole('region', { name: 'API Docs & Playground' })
expect(section).toHaveAttribute('data-qqq-id', 'table-dev-api-docs')
const apiSelect = await within(section).findByLabelText('API')
expect(apiSelect).toHaveAttribute('id', 'table-dev-api-select')
expect(apiSelect).toHaveAttribute('data-qqq-id', 'select-api')
expect(apiSelect).toHaveValue('person-api')
expect(within(apiSelect).getAllByRole('option').map((option) => option.textContent)).toEqual(['Person API', 'Admin API'])
const versionSelect = await within(section).findByLabelText('Version')
expect(versionSelect).toHaveAttribute('data-qqq-id', 'select-api-version')
expect(versionSelect).toHaveValue('2026.Q2')
await waitFor(() => expect(container.querySelector('rapi-doc')).not.toBeNull())
const rapiDoc = container.querySelector('[data-qqq-id="table-dev-api-reference"] rapi-doc')!
expect(rapiDoc).toHaveAttribute('spec-url', '/person-api/2026.Q2/person/openapi.json')
expect(rapiDoc).toHaveAttribute('primary-color', '#6366f1')
expect(rapiDoc).toHaveAttribute('show-header', 'false')
expect(rapiDoc).toHaveAttribute('render-style', 'view')
expect(rapiDoc).toHaveAttribute('allow-server-selection', 'false')
expect(rapidocLoaded).toHaveBeenCalled()
expect(getTableApisMock).toHaveBeenCalledWith('person')
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('person-api')
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiVersion')).toBe('2026.Q2')
expect(screen.getByTestId('page-header')).toHaveTextContent('People Developer Mode')
})
it('restores the remembered API and version when they are still offered', async () => {
window.localStorage.setItem('qqq.tableDeveloperView.lastApiName', 'admin-api')
window.localStorage.setItem('qqq.tableDeveloperView.lastApiVersion', 'v2')
getTableApisMock.mockResolvedValue(apis)
const { container } = renderPage()
expect(await screen.findByLabelText('API')).toHaveValue('admin-api')
expect(await screen.findByLabelText('Version')).toHaveValue('v2')
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/admin-api/v2/person/openapi.json'))
})
it('falls back to the first API and the current version when the remembered ones are gone', async () => {
window.localStorage.setItem('qqq.tableDeveloperView.lastApiName', 'retired-api')
window.localStorage.setItem('qqq.tableDeveloperView.lastApiVersion', 'v9')
getTableApisMock.mockResolvedValue(apis)
renderPage()
expect(await screen.findByLabelText('API')).toHaveValue('person-api')
expect(await screen.findByLabelText('Version')).toHaveValue('2026.Q2')
await waitFor(() => expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('person-api'))
})
it('switches API and version from the keyboard-operable native selects and remembers them', async () => {
const user = userEvent.setup()
getTableApisMock.mockResolvedValue(apis)
const { container } = renderPage()
await user.selectOptions(await screen.findByLabelText('API'), 'admin-api')
expect(await screen.findByLabelText('Version')).toHaveValue('v1')
await user.selectOptions(screen.getByLabelText('Version'), 'v2')
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/admin-api/v2/person/openapi.json'))
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('admin-api')
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiVersion')).toBe('v2')
})
it('shows the empty state without selectors when the table is in no API', async () => {
getTableApisMock.mockResolvedValue([])
const { container } = renderPage()
const empty = await screen.findByText('This table is not available in any APIs.')
expect(empty).toHaveAttribute('data-qqq-id', 'table-dev-no-apis')
expect(screen.queryByLabelText('API')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Version')).not.toBeInTheDocument()
expect(container.querySelector('[data-qqq-id="table-dev-api-reference"]')).toBeNull()
expect(getApiVersionsMock).not.toHaveBeenCalled()
})
it('keeps working when localStorage throws', async () => {
const storage = window.localStorage
const blocked = () => { throw new Error('blocked') }
// The test setup installs a writable localStorage stand-in; swap it for one that refuses access.
;(window as { localStorage: unknown }).localStorage = { getItem: blocked, setItem: blocked, removeItem: blocked, clear: blocked }
try {
getTableApisMock.mockResolvedValue(apis)
renderPage()
expect(await screen.findByLabelText('Version')).toHaveValue('2026.Q2')
} finally {
;(window as { localStorage: unknown }).localStorage = storage
}
})
it('reports a failure to list APIs', async () => {
getTableApisMock.mockRejectedValue(new Error('Server exploded'))
renderPage()
const section = await screen.findByRole('region', { name: 'API Docs & Playground' })
expect(await within(section).findByRole('alert')).toHaveTextContent('Failed to load APIs: Server exploded')
})
})
+158 -33
View File
@@ -1,60 +1,128 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Developer view page — resolves a slug to a table or process. It shows metadata and ESB
* details for either, plus API Docs & Playground for tables exposed by application APIs.
*/
'use client'
// TableDeveloperView — shows raw table metadata as formatted JSON
// Useful for debugging field definitions, sections, and permissions
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { useMetaData, useProcessMetaData } from '@/lib/hooks/use-metadata'
import { loadTableMetaData } from '@/lib/api/metadata'
import { getEsbForProcess, getEsbForTable } from '@/lib/api/esb'
import { queryKeys } from '@/lib/query-client'
import { TableApiDocs } from '@/components/records/TableApiDocs'
import { EsbSection } from '@/components/esb/EsbSection'
import { ProcessDeveloperView } from '@/components/esb/ProcessDeveloperView'
export default function TableDeveloperViewPage() {
const params = useParams<{ slug: string }>()
const { setPageHeader } = useQContext()
/**
* Renders the Developer view for the table or process named by the slug. A slug naming both
* resolves to the table, as it does on the slug page; a slug naming neither (or instance metadata
* that fails to load) falls through to the table view, which reports the load failure.
*
* @returns A spinner while instance metadata loads, then `<ProcessDeveloperPage>` for a process
* slug or `<TableDeveloperView>` otherwise.
*/
export default function DeveloperViewPage() {
const params = useRouteParams<{ slug: string }>()
const slug = params.slug
const { data: metaData, isError: metaDataError } = useMetaData()
if (!metaData && !metaDataError) return <LoadingSpinner />
const isProcess = Boolean(metaData && !metaData.tables?.[slug] && metaData.processes?.[slug])
return isProcess ? <ProcessDeveloperPage processName={slug} /> : <TableDeveloperView tableName={slug} />
}
/**
* Loads a process's metadata and ESB data and renders its Developer view.
*
* @param props - Component props.
* @param props.processName - The process to show.
* @returns A spinner while metadata loads, a destructive error panel on failure, then the
* `<ProcessDeveloperView>` (without an ESB section on 403, 404, or a backend without the ESB module).
*/
function ProcessDeveloperPage({ processName }: { processName: string }) {
const { data, error } = useProcessMetaData(processName)
const { setPageHeader } = useQContext()
const processLabel = data?.label || processName
useEffect(() => {
setPageHeader(`${processLabel} Developer Mode`)
}, [processLabel, setPageHeader])
const { data: esb } = useQuery({
queryKey: queryKeys.esbProcess(processName),
queryFn: () => getEsbForProcess(processName),
})
if (error) return <LoadError error={error} />
if (!data) return <LoadingSpinner />
return <ProcessDeveloperView process={data} esb={esb ?? null} />
}
/**
* Renders a developer debug view for a QQQ table, showing summary statistics
* and the full table metadata as a collapsible JSON block.
*
* @param props - Component props.
* @param props.tableName - The table to show.
* @returns A composed view that assembles:
* - A 4-column stats grid (`<MetaStat>` cards: field count, section count, permissions, primary key)
* - An `<EsbSection>` with the table's ESB publications and subscribers, omitted when the
* table has none to show (403, 404, or a backend without the ESB module)
* - The `<TableApiDocs>` section (API and version selectors with the embedded RapiDoc reference)
* - A collapsible `<JsonBlock>` panel rendering the full table metadata as formatted JSON
* - A loading spinner while metadata is fetching, and a destructive error panel on failure
*/
function TableDeveloperView({ tableName }: { tableName: string }) {
const { setPageHeader } = useQContext()
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.tableMetadata(tableName),
queryFn: () => loadTableMetaData(tableName),
staleTime: 1000 * 60 * 5,
})
const { data: instance } = useMetaData()
const tableLabel = data?.label || tableName
useEffect(() => {
setPageHeader(`Developer View: ${slug}`)
}, [slug, setPageHeader])
setPageHeader(`${tableLabel} Developer Mode`)
}, [tableLabel, setPageHeader])
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.tableMetadata(slug),
queryFn: () => loadTableMetaData(slug),
staleTime: 1000 * 60 * 5,
const { data: esb } = useQuery({
queryKey: queryKeys.esbTable(tableName),
queryFn: () => getEsbForTable(tableName),
})
return (
<div className="space-y-6" data-qqq-id={`table-dev-${slug}`}>
<div className="space-y-6" data-qqq-id={`table-dev-${tableName}`}>
<div className="flex items-center gap-3">
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<h2 className="text-2xl font-semibold text-foreground">
Table Developer View: <span className="font-mono text-primary">{slug}</span>
Table Developer View: <span className="text-primary">{tableLabel}</span>
</h2>
</div>
{isLoading && (
<div
className="flex items-center justify-center py-12"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)}
{isLoading && <LoadingSpinner />}
{error && (
<div
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
role="alert"
>
Failed to load metadata: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
{error && <LoadError error={error} />}
{data && (
<div className="space-y-4">
@@ -75,6 +143,10 @@ export default function TableDeveloperViewPage() {
<MetaStat label="Primary Key" value={data.primaryKeyField ?? '—'} />
</div>
<EsbSection data={esb ?? null} />
<TableApiDocs tableName={data.name || tableName} primaryColor={instance?.branding?.accentColor} />
{/* Raw JSON */}
<JsonBlock label="Full Table Metadata" value={data} defaultOpen={false} />
</div>
@@ -83,6 +155,50 @@ export default function TableDeveloperViewPage() {
)
}
/**
* A centered spinner announced as busy.
*
* @returns The loading indicator.
*/
function LoadingSpinner() {
return (
<div
className="flex items-center justify-center py-12"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
/**
* A destructive panel reporting a metadata load failure.
*
* @param props - Component props.
* @param props.error - The load error.
* @returns The error panel.
*/
function LoadError({ error }: { error: unknown }) {
return (
<div
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
role="alert"
>
Failed to load metadata: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)
}
/**
* Renders a single labeled statistic card.
*
* @param props - Component props.
* @param props.label - The stat label displayed above the value.
* @param props.value - The numeric or string value to display prominently.
* @returns A bordered card showing the label and value.
*/
function MetaStat({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-xl border border-border bg-card p-4">
@@ -96,6 +212,15 @@ function MetaStat({ label, value }: { label: string; value: string | number }) {
)
}
/**
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
*
* @param props - Component props.
* @param props.label - Heading displayed in the toggle button.
* @param props.value - The value to serialize and display as JSON.
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
* @returns A collapsible JSON inspector card.
*/
function JsonBlock({
label,
value,
+87 -12
View File
@@ -1,26 +1,101 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Record-by-key page — `/app/{table}/key?{field}={value}...` opens the one record matching the given field values.
*/
'use client'
// RecordViewByUniqueKey — view a record by unique key
// Full implementation in Package 3
import React, { useEffect, useMemo } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { useQContext } from '@/lib/context/q-context'
import { queryRecords } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
/**
* Looks up a record by field values from the query string, as Material
* Dashboard's `RecordViewByUniqueKey` does (typically a unique key, e.g.
* `/app/person/key?email=avery@example.invalid`).
*
* Each parameter becomes an `EQUALS` criterion. Exactly one match replaces the
* URL with the record's view page; no match, several matches, an unknown field
* or no parameters show an error.
*
* @returns A loading state, or an alert explaining why no single record matched.
*/
export default function RecordViewByKeyPage() {
const params = useParams<{ slug: string }>()
const { slug } = useRouteParams<{ slug: string }>()
const searchParams = useSearchParams()
const router = useRouter()
const { setPageHeader } = useQContext()
const slug = params.slug
const { data: table, isError: tableError } = useTableMetaData(slug)
const criteria = useMemo(() => [...searchParams.entries()], [searchParams])
const unknownField = table ? criteria.find(([fieldName]) => !table.fields?.[fieldName])?.[0] : undefined
const lookup = useQuery({
queryKey: [...queryKeys.tableRecords(slug), 'byKey', searchParams.toString()],
queryFn: () => queryRecords(slug, {
filter: {
criteria: criteria.map(([fieldName, value]) => ({ fieldName, operator: 'EQUALS' as const, values: [value] })),
booleanOperator: 'AND',
limit: 2,
},
}),
enabled: Boolean(table) && criteria.length > 0 && !unknownField,
})
useEffect(() => {
setPageHeader(`View ${slug} by Key`)
}, [slug, setPageHeader])
setPageHeader(`View ${table?.label ?? slug} by Key`)
}, [slug, table?.label, setPageHeader])
const records = lookup.data?.records
const match = records?.length === 1 ? records[0] : undefined
const primaryKey = match && table ? match.values[table.primaryKeyField] : undefined
useEffect(() => {
if (primaryKey !== undefined && primaryKey !== null) {
router.replace(`/app/${encodeURIComponent(slug)}/${encodeURIComponent(String(primaryKey))}`)
}
}, [primaryKey, router, slug])
let error: string | undefined
if (tableError) error = `Could not load the ${slug} table.`
else if (table && criteria.length === 0) error = `Add field values to the address to look up a ${table.label} record, for example ?${table.primaryKeyField}=1.`
else if (table && unknownField) error = `Query-string parameter [${unknownField}] is not a defined field on the ${table.label} table.`
else if (lookup.isError) error = lookup.error instanceof Error ? lookup.error.message : 'Unexpected error running query'
else if (table && records?.length === 0) error = `No ${table.label} record was found matching the given values.`
else if (table && records && records.length > 1) error = `More than one ${table.label} record was found matching the given values.`
if (error) {
return (
<div role="alert" className="rounded-xl border border-destructive/40 bg-destructive/10 p-6 text-sm text-destructive" data-qqq-id={`record-view-key-error-${slug}`}>
{error}
</div>
)
}
return (
<div data-qqq-id={`record-view-key-${slug}`}>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">Record view by key -- implemented in Package 3</p>
</div>
<div role="status" aria-label="Looking up record" className="flex items-center justify-center py-12" data-qqq-id={`record-view-key-${slug}`}>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Prerenders this dynamic segment once for the Javalin-served static export.
* The server returns that page for every concrete value; see use-route-params.
*/
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
* Static export placeholder params for this segment.
*
* @returns One placeholder entry.
*/
export function generateStaticParams() {
return [{ slug: EXPORT_PLACEHOLDER }]
}
/**
* Pass-through layout that exists to carry generateStaticParams.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
}
@@ -0,0 +1,198 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { QContextProvider } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import * as processesApi from '@/lib/api/processes'
import SlugPage from './page'
const { recordQuery, params, location } = vi.hoisted(() => ({
recordQuery: vi.fn(() => <div data-testid="record-query" />),
params: { slug: 'person' },
location: { search: '' },
}))
vi.mock('next/navigation', () => ({
useParams: () => params,
usePathname: () => '/app/' + params.slug,
useSearchParams: () => new URLSearchParams(location.search),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/components/query/RecordQuery', () => ({ RecordQuery: recordQuery }))
vi.mock('@/components/widgets/AppHome', () => ({ AppHome: () => <div>App home</div> }))
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={client}>
<QContextProvider><SlugPage /></QContextProvider>
</QueryClientProvider>
)
}
// The V1 registry deliberately omits fields, sections, primary keys and joins.
const registry = {
...qInstance,
tables: Object.fromEntries(Object.entries(qInstance.tables).map(([name, table]) => [name, {
name, label: table.label, readPermission: table.readPermission,
insertPermission: table.insertPermission,
}])),
}
describe('SlugPage table metadata readiness', () => {
beforeEach(() => {
params.slug = 'person'
location.search = ''
recordQuery.mockClear()
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)))
})
it('waits for full active metadata and passes the light permission registry separately', async () => {
let releaseTable!: () => void
const tableReady = new Promise<void>((resolve) => { releaseTable = resolve })
let requested = false
server.use(http.get('/qqq/v1/metaData/table/person', async () => {
requested = true
await tableReady
return HttpResponse.json(qInstance.tables.person)
}))
renderPage()
try {
await waitFor(() => expect(requested).toBe(true))
expect(screen.queryByTestId('record-query')).not.toBeInTheDocument()
expect(screen.getByRole('status', { name: 'Loading table metadata' })).toBeInTheDocument()
} finally {
await act(async () => { releaseTable() })
}
await screen.findByTestId('record-query')
expect(recordQuery).toHaveBeenLastCalledWith(expect.objectContaining({
tableMetaData: qInstance.tables.person, allTables: registry.tables,
}), undefined)
expect(registry.tables.person).not.toHaveProperty('fields')
})
it('shows full-table failure without rendering a query from registry metadata', async () => {
server.use(http.get('/qqq/v1/metaData/table/person', () =>
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })))
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load table metadata')
expect(recordQuery).not.toHaveBeenCalled()
})
it('does not fetch full table metadata when an app takes priority over a same-name table', async () => {
const metadata = { ...registry, apps: { person: { name: 'person', label: 'People app' } } }
let requested = false
server.use(
http.get('/qqq/v1/metaData', () => HttpResponse.json(metadata)),
http.get('/qqq/v1/metaData/table/person', () => {
requested = true
return HttpResponse.json(qInstance.tables.person)
}),
)
renderPage()
await screen.findByText('App home')
expect(requested).toBe(false)
expect(recordQuery).not.toHaveBeenCalled()
})
})
const greeting = {
name: 'greetInteractive', label: 'Greet Interactive', hasPermission: true,
tableName: 'person', minInputRecords: 1, maxInputRecords: 2,
frontendSteps: [{
name: 'setup', label: 'Setup', components: [{ type: 'EDIT_FORM' }],
formFields: [{ name: 'greetingPrefix', label: 'Greeting Prefix', type: 'STRING', isEditable: true, isHidden: false, isRequired: false }],
}],
}
describe('SlugPage process initialization', () => {
beforeAll(async () => {
// Keep cold screen compilation outside the metadata-readiness assertion deadline.
await import('@/components/process/ProcessRun')
})
beforeEach(() => {
vi.restoreAllMocks()
params.slug = greeting.name
location.search = 'recordsParam=recordIds&recordIds=1,3'
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json({
...registry, processes: { [greeting.name]: { name: greeting.name, label: greeting.label } },
})))
})
it.each([
['recordIds', 'recordIds', '1,3', 'recordIds'],
['filterJSON', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
['queryFilter', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
])('loads full metadata before initializing with %s selection', async (selector, field, value, expectedSelector) => {
location.search = new URLSearchParams({ recordsParam: selector, [field]: value }).toString()
let releaseMetadata!: () => void
const metadataReady = new Promise<void>((resolve) => { releaseMetadata = resolve })
const initialize = vi.spyOn(processesApi, 'processInit').mockResolvedValue({
type: 'COMPLETE', processUUID: 'selected-run', nextStep: 'setup', values: {},
})
server.use(
http.get('/qqq/v1/metaData/process/greetInteractive', async () => {
await metadataReady
return HttpResponse.json(greeting)
}),
)
renderPage()
await screen.findByRole('status', { name: 'Loading process metadata' })
expect(initialize).not.toHaveBeenCalled()
await act(async () => { releaseMetadata() })
await screen.findByRole('textbox', { name: 'Greeting Prefix' })
expect(initialize).toHaveBeenCalledWith(greeting.name, {
recordsParam: expectedSelector, [field]: value, tableName: 'person',
})
})
it('does not initialize when full process metadata is denied', async () => {
let initialized = false
server.use(
http.get('/qqq/v1/metaData/process/greetInteractive', () =>
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })),
http.post('/qqq/v1/processes/greetInteractive/init', () => {
initialized = true
return HttpResponse.json({ processUUID: 'unexpected', values: {} })
}),
)
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load process metadata')
expect(initialized).toBe(false)
})
})
describe('SlugPage unknown names', () => {
beforeEach(() => {
location.search = ''
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)))
})
it('shows the not-found state with a link to the dashboard (regression: bare "Unknown resource")', async () => {
params.slug = 'noSuchThing'
renderPage()
expect(await screen.findByRole('heading', { level: 1, name: 'Page not found' })).toBeInTheDocument()
expect(screen.getByText('noSuchThing')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Go to the dashboard' })).toHaveAttribute('href', '/app')
expect(screen.queryByText(/Unknown resource/)).not.toBeInTheDocument()
})
})
+179 -52
View File
@@ -1,27 +1,118 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Unified slug page — dispatches to AppHome, RecordQuery, ProcessRun, or ReportRun based on the slug.
*/
'use client'
// Unified slug page — routes to AppHome, RecordQuery, or ProcessRun
// based on what the slug resolves to in the app metadata
/**
* Unified slug page — serves the route `/app/[slug]` and dispatches to
* `AppHome`, `RecordQuery`, or `ProcessRun` based on how the slug resolves
* against the QQQ application metadata.
*
* Route params:
* - `slug` — a QQQ app name, table name, process name, or report name.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import React, { lazy, Suspense, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { FileBarChart } from 'lucide-react'
import type { QInstance } from '@/types'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import type { ProcessInitRequest } from '@/lib/api/processes'
import { queryKeys } from '@/lib/query-client'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { RecordQuery } from '@/components/query'
import { ProcessRun } from '@/components/process'
import { AppHome } from '@/components/widgets'
import { canAccessProcess, canReadRecords } from '@/lib/auth/permissions'
import { safeReturnTo } from '@/lib/auth/return-to'
import { NotFoundState } from '@/components/layout/NotFoundState'
// Metadata selects one screen. Keep the other three screens out of this route's first load.
const RecordQuery = lazy(() => import('@/components/query/RecordQuery').then((module) => ({ default: module.RecordQuery })))
const ProcessRun = lazy(() => import('@/components/process/ProcessRun').then((module) => ({ default: module.ProcessRun })))
const AppHome = lazy(() => import('@/components/widgets/AppHome').then((module) => ({ default: module.AppHome })))
const ReportRun = lazy(() => import('@/components/reports/ReportRun').then((module) => ({ default: module.ReportRun })))
const screenFallback = <div role="status" aria-label="Loading screen" className="flex items-center justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>
/**
* Resolves a URL slug to its QQQ resource type and name.
*
* Checks the slug against apps, tables, processes, and reports in priority order.
* Returns a discriminated union describing the match, or `null` when the slug is
* not found in any category.
*
* Extracted as a named function for testability — the caller (`SlugPage`) passes
* the already-fetched metadata so this function remains a pure mapping with no
* side effects or data fetching.
*
* @param slug - The URL path segment to resolve (e.g. `"orders"` or `"importData"`).
* @param metaData - The full QQQ instance metadata fetched from the backend.
* @returns A `{ type, name }` object describing the resolved resource, or `null`.
*/
function resolveSlugTarget(
slug: string,
metaData: QInstance
): { type: 'app' | 'table' | 'process' | 'report'; name: string } | null {
if (metaData.apps?.[slug]) {
return { type: 'app', name: slug }
}
if (metaData.tables?.[slug]) {
return { type: 'table', name: slug }
}
if (metaData.processes?.[slug]) {
return { type: 'process', name: slug }
}
if (metaData.reports?.[slug]) {
return { type: 'report', name: slug }
}
return null
}
/**
* Renders the appropriate page component for a given `slug` URL segment.
*
* Resolution priority:
* 1. If the slug matches a QQQ **app** → renders `<AppHome>` (dashboard widgets).
* 2. If the slug matches a **table** → renders `<RecordQuery>` (data grid + filters).
* 3. If the slug matches a **process** → renders `<ProcessRun>` (step wizard).
* 4. If the slug matches a **report** → renders `<ReportRun>` (format selector + download).
* 5. Otherwise → renders a not-found state (the backend omits objects the user may not access).
*
* The page header in QContext is updated whenever the resolution changes.
*
* @returns A composed page selected by slug resolution:
* - `<AppHome>` (dashboard widgets) when the slug matches a QQQ app
* - `<RecordQuery>` (data grid + filters + pagination) when the slug matches a table
* - `<ProcessRun>` (step wizard) when the slug matches a process
* - `<ReportRun>` (format selector + download) when the slug matches a report
* - A full-screen spinner while metadata is loading or the resource is resolving
* - A not-found state when the slug matches nothing the user can access
*/
export default function SlugPage() {
const params = useParams<{ slug: string }>()
const params = useRouteParams<{ slug: string }>()
const searchParams = useSearchParams()
const { setPageHeader, setTableMetaData } = useQContext()
const slug = params.slug
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
@@ -34,8 +125,12 @@ export default function SlugPage() {
const isReport = Boolean(metaData?.reports?.[slug])
const app = metaData?.apps?.[slug]
const table = metaData?.tables?.[slug]
const process = metaData?.processes?.[slug]
// Objects the user may not use (DenyBehavior.DISABLED) are listed in metadata with
// their permission flag false: show why, and do not load or start them.
const tableDenied = isTable && !isApp && !canReadRecords(metaData?.tables?.[slug])
const processDenied = isProcess && !isApp && !isTable && !canAccessProcess(metaData?.processes?.[slug])
const { data: table, isError: tableError } = useTableMetaData(isTable && !isApp && !tableDenied ? slug : undefined)
const { data: process, isError: processError } = useProcessMetaData(isProcess && !isApp && !isTable && !processDenied ? slug : undefined)
const report = metaData?.reports?.[slug]
useEffect(() => {
@@ -47,11 +142,30 @@ export default function SlugPage() {
} else if (isProcess) {
setPageHeader(process?.label ?? slug)
} else if (isReport) {
setPageHeader((report as { label?: string })?.label ?? slug)
} else {
setPageHeader(slug)
setPageHeader(report?.label ?? slug)
} else if (metaData) {
setPageHeader('Not Found')
}
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, setPageHeader, setTableMetaData])
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, metaData, setPageHeader, setTableMetaData])
if (tableDenied || processDenied) {
const label = (tableDenied ? metaData?.tables?.[slug]?.label : metaData?.processes?.[slug]?.label) ?? slug
return (
<div className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center" role="alert" data-qqq-id="permission-denied">
<p className="text-sm text-yellow-700">
{tableDenied ? `You do not have permission to view ${label} records.` : `You do not have permission to run ${label}.`}
</p>
</div>
)
}
if (metadataError || (isTable && !isApp && tableError) || (isProcess && !isApp && !isTable && processError)) {
return (
<div role="alert" className="py-12 text-center text-destructive">
Failed to load {metadataError ? 'application' : isTable ? 'table' : 'process'} metadata.
</div>
)
}
if (!metaData) {
return (
@@ -64,23 +178,24 @@ export default function SlugPage() {
// App dashboard — Package 5 implementation
if (isApp && app) {
return (
<AppHome
<Suspense fallback={screenFallback}><AppHome
appMetaData={app}
instance={metaData}
widgetRegistry={metaData.widgets ?? {}}
/>
/></Suspense>
)
}
// Table record query — Package 2 implementation
if (isTable && table) {
const tableProcesses = getProcessesForTable(metaData, slug)
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
return <Suspense fallback={screenFallback}><RecordQuery key={slug} tableName={slug} tableMetaData={table} allTables={metaData.tables} processes={tableProcesses} metaData={metaData} /></Suspense>
}
// Table loading state (table found but metadata not yet available)
if (isTable && !table) {
return (
<div className="flex items-center justify-center py-12">
<div role="status" aria-label="Loading table metadata" className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
@@ -88,11 +203,51 @@ export default function SlugPage() {
// Process run — Package 4 implementation
if (isProcess && process) {
return <ProcessRun processName={slug} processMetaData={process} />
const initialRequest: ProcessInitRequest = {}
const selection = searchParams.get('recordsParam')
if (selection === 'recordIds' || (!selection && searchParams.get('recordIds'))) {
initialRequest.recordsParam = 'recordIds'
initialRequest.recordIds = searchParams.get('recordIds') ?? ''
} else if (selection === 'filterJSON' || selection === 'queryFilter' || (!selection && searchParams.get('filterJSON'))) {
initialRequest.recordsParam = 'filterJSON'
initialRequest.filterJSON = searchParams.get('filterJSON') ?? ''
}
// a process added to every screen (no table of its own) runs over the launching table
const launchTable = searchParams.get('tableName')
if (!process.tableName && launchTable && metaData.tables?.[launchTable]) initialRequest.tableName = launchTable
////////////////////////////////////////////////////////////////////////
// links may preset process inputs, as in the Material dashboard: //
// ?defaultProcessValues={"name":"value"} //
////////////////////////////////////////////////////////////////////////
let initialValues: Record<string, unknown> | undefined
try {
const parsed: unknown = JSON.parse(searchParams.get('defaultProcessValues') ?? 'null')
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) initialValues = parsed as Record<string, unknown>
} catch {
initialValues = undefined
}
// a launch from a record or query screen returns there (Material closes its modal over that screen)
const rawReturnTo = searchParams.get('returnTo')
const returnTo = rawReturnTo ? safeReturnTo(rawReturnTo, undefined, '') || undefined : undefined
return <Suspense fallback={screenFallback}><ProcessRun key={`${slug}?${searchParams}`} processName={slug} processMetaData={process} initialRequest={initialRequest} initialValues={initialValues} returnTo={returnTo} /></Suspense>
}
// Process loading state (process found but metadata not yet available)
if (isProcess && !process) {
return (
<div role="status" aria-label="Loading process metadata" className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
// Report run
if (isReport && report) {
return <Suspense fallback={screenFallback}><ReportRun reportName={slug} reportMetaData={report} /></Suspense>
}
// Report loading state (report found but metadata not yet available)
if (isReport && !report) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
@@ -100,34 +255,6 @@ export default function SlugPage() {
)
}
// Report run
if (isReport) {
return (
<div className="space-y-6" data-qqq-id={`report-run-${slug}`}>
<div className="flex items-center gap-3">
<FileBarChart className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<h2 className="text-2xl font-semibold text-foreground">
{(report as { label?: string })?.label ?? slug}
</h2>
</div>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="mt-4 text-muted-foreground">
Report — implemented in a future package
</p>
</div>
</div>
)
}
// Unknown slug
return (
<div className="space-y-4" data-qqq-id={`unknown-slug-${slug}`}>
<h2 className="text-2xl font-semibold text-foreground">{slug}</h2>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">
Unknown resource: <code className="font-mono">{slug}</code>
</p>
</div>
</div>
)
// Unknown, hidden-and-denied, or unpermitted slug: the backend exposes nothing by this name
return <NotFoundState name={slug} />
}
Loaded 100 of 819 files, more files were not shown because too many files have changed in this diff. Show more