diff --git a/README.md b/README.md index 165251b..12a274c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Default QDN identity: Current scope: +- Public English **Developers** reference at `?view=developers`, available offline and without write authority. + - Read node status through `GET_NODE_STATUS`. - Read Core metadata and peer lists through read-only `FETCH_NODE_API` calls. - Show chain vs QDN/data peers, IP vs I2P, inbound vs outbound breakdowns, @@ -28,6 +30,21 @@ Current scope: - Detect language from Home/query display settings first, then Core `localeLang` from `/admin/settings`, then English. +Developers and compatibility: + +- Node remains on Qortium QDN only, with no Qortal app integration. +- `?view=developer` and `?view=reference` normalize to `?view=developers`, + preserving an existing `?page=settings`, host/query parameters and fragment. + Section links use `section`; Home Back/Forward restores the workspace. +- Switching tabs preserves the unsaved settings draft. A successful save or + explicit Refresh resets it, as before. The reference documents this boundary. +- The body stays English/LTR and follows Classic/Modern/Fun, theme, accent and + text size. UI messages still use the existing English-fallback i18n mechanism. +- Copy controls announce success or manual-copy fallback and never run examples. +- The reference shares the app's action list, editable keys, unit constants and + enum options. Home/Core remain authoritative for route trust, approval, + writable metadata, save/apply semantics and restart effects. + Planned later scope: - Add translated catalogs by reusing existing Core/Home/Qortium app wording where practical. @@ -44,7 +61,7 @@ npm run dev Versioning: - Node follows the Qortium app versioning standard (QAVS): the current app - version is 1.4.2, where the `1.4` prefix declares the minimum Qortium + version is 1.4.5, where the `1.4` prefix declares the minimum Qortium platform level the app is built against and the last number is the app's own release counter. - The build emits a `qortium-app.json` manifest (see `vite.config.ts`) that diff --git a/package-lock.json b/package-lock.json index 7581c09..9d0f089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qortium-node", - "version": "1.4.4", + "version": "1.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qortium-node", - "version": "1.4.4", + "version": "1.4.5", "license": "0BSD", "dependencies": { "@vitejs/plugin-react": "^6.1.1", @@ -19,9 +19,232 @@ "@types/node": "^26.4.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", + "jsdom": "^29.1.1", "vitest": "^4.1.11" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -836,6 +1059,16 @@ "node": ">=12" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -853,6 +1086,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -860,6 +1107,27 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -869,6 +1137,19 @@ "node": ">=8" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", @@ -927,6 +1208,67 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1188,6 +1530,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1198,6 +1550,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1230,6 +1589,19 @@ "node": ">=12.20.0" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1283,6 +1655,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1304,6 +1686,16 @@ "react": "^19.2.8" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", @@ -1337,6 +1729,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1373,6 +1778,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1416,6 +1828,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -1450,6 +1908,16 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -1624,6 +2092,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1640,6 +2156,23 @@ "engines": { "node": ">=8" } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 163dd67..fdc1f7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-node", - "version": "1.4.4", + "version": "1.4.5", "private": true, "license": "0BSD", "description": "A QDN app for inspecting a Qortium node.", @@ -23,6 +23,7 @@ "@types/node": "^26.4.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", + "jsdom": "^29.1.1", "vitest": "^4.1.11" } } diff --git a/src/App.tsx b/src/App.tsx index cea8a2e..ba66063 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; +import { Reference } from './Reference'; +import { SETTINGS_BRIDGE_ACTIONS } from './nodeContract'; import { APP_VERSION } from './appVersion'; import nodeIconUrl from './assets/brand/qortium-node-protoicon-black-transparent.png'; import { @@ -64,7 +66,6 @@ import type { PeerTransport, } from './types'; -const SETTINGS_BRIDGE_ACTIONS = ['GET_NODE_SETTINGS_METADATA', 'UPDATE_NODE_SETTINGS', 'RESTART_NODE']; type PeerColumnDefinition = { column: PeerSortColumn; @@ -866,7 +867,8 @@ export function App() { ); function navigateToPage(next: AppPage) { - window.history.pushState({}, '', getNodeRouteUrl(window.location.href, { page: next })); + const url = getNodeRouteUrl(window.location.href, { page: next }); + if (url.href !== window.location.href) window.history.pushState(window.history.state, '', url); setPage(next); } @@ -1012,7 +1014,7 @@ export function App() { const canonicalUrl = getNodeRouteUrl(window.location.href, readNodeRoute(window.location.href)); if (canonicalUrl.href !== window.location.href) { - window.history.replaceState({}, '', canonicalUrl); + window.history.replaceState(window.history.state, '', canonicalUrl); } const onPopState = () => { @@ -1083,8 +1085,12 @@ export function App() { > {t('label.coreSettings')} + - + } - {error ?
{error}
: null} - {settingsMessage ?
{settingsMessage}
: null} - {isLoading && !hasLoadedData ?
{t('message.loadingNodeData')}
: null} + {page !== 'developers' && error ?
{error}
: null} + {page !== 'developers' && settingsMessage ?
{settingsMessage}
: null} + {page !== 'developers' && isLoading && !hasLoadedData ?
{t('message.loadingNodeData')}
: null} - {page === 'overview' ? ( + {page === 'developers' ? : page === 'overview' ? ( <>
{}).constructor; +describe('developer reference', () => { + it('renders public English/LTR documentation and accessible copy results', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('lang="en" dir="ltr"'); expect(html).toContain('aria-live="polite"'); + for (const key of PHASE_1_EDITABLE_SETTING_KEYS) expect(html).toContain(key); + for (const key of Object.keys(REFERENCE_SNIPPETS)) expect(html).toContain(`aria-label="Copy ${key} example"`); + }); + it('uses retained settings/query/fragment for section links', () => { + expect(referenceSectionUrl('/render/APP/Node/Node?page=settings&x=a&x=b#retained','writes')).toBe('/render/APP/Node/Node?page=settings&x=a&x=b&view=developers§ion=writes#retained'); + }); + it('discovers capabilities without executing settings writes', async () => { + const request = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? ['UPDATE_NODE_SETTINGS'] : true); + await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.capabilities)(request); + expect(request.mock.calls.map(([r]) => r.action)).toEqual(['SHOW_ACTIONS', 'IS_USING_PUBLIC_NODE']); + }); + it('rejects a failed read envelope rather than treating an error as peer data', async () => { + const request = vi.fn(async ({ action }) => action === 'GET_NODE_STATUS' ? { height: 1 } : { ok: false, status: 403 }); + await expect(new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.reads)(request)).rejects.toThrow('403'); + }); + it('keeps write and restart examples inert until called and does not retry rejection', async () => { + const request = vi.fn(async () => { throw new Error('Denied'); }); + const save = await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.save + '\nreturn saveReviewedPatch;')(request); + const restart = await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.restart + '\nreturn requestRestart;')(request); + expect(request).not.toHaveBeenCalled(); + await expect(save({ apiDocumentationEnabled: false })).rejects.toThrow('Denied'); + expect(request).toHaveBeenCalledExactlyOnceWith({ action: 'UPDATE_NODE_SETTINGS', settings: { apiDocumentationEnabled: false } }); + await expect(restart()).rejects.toThrow('Denied');expect(request).toHaveBeenCalledTimes(2); + }); + it('matches documented writable metadata shapes to the app normalizer', () => { + const value = { type: 'boolean', restartRequired: true }; + const entry = { key: 'apiDocumentationEnabled', value }; + expect(normalizeSettingsMetadata({ writable: { apiDocumentationEnabled: value } })?.writable).toEqual({ apiDocumentationEnabled: value }); + expect(normalizeSettingsMetadata({ writable: { entry: [entry] } })?.writable).toEqual({ apiDocumentationEnabled: value }); + expect(normalizeSettingsMetadata({ writable: [entry] })?.writable).toEqual({}); + }); + it('pins documented unit examples and boundaries to the real parsers', async () => { + const result = await new AsyncFunction(REFERENCE_SNIPPETS.units + '\nreturn [storageBytes, retentionMilliseconds];')(); + expect(result).toEqual([parseGigabytesToBytes('2.5'), parseHoursToMilliseconds('24')]); + expect(parseGigabytesToBytes('0.5')).toBeNull();expect(parseHoursToMilliseconds('1.0001')).toBeNull(); + }); +}); diff --git a/src/Reference.tsx b/src/Reference.tsx new file mode 100644 index 0000000..7a270b8 --- /dev/null +++ b/src/Reference.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { copyTextToClipboard } from './clipboard'; +import { ReferenceNavigation } from './ReferenceNavigation'; +import { NODE_READ_PATHS, SETTINGS_BRIDGE_ACTIONS } from './nodeContract'; +import { AUTO_UPDATE_MODE_OPTIONS, CHAT_RETENTION_HOUR_MS, PHASE_1_EDITABLE_SETTING_KEYS, STORAGE_CAPACITY_GIGABYTE_BYTES, STORAGE_POLICY_OPTIONS, TRANSPORT_SELECTION_OPTIONS } from './settingsEditor'; + +export const REFERENCE_SNIPPETS = { + capabilities: `const actions = await qdnRequest({ action: 'SHOW_ACTIONS' }); +const available = new Set(Array.isArray(actions) ? actions : []); +const required = ${JSON.stringify(SETTINGS_BRIDGE_ACTIONS)}; +const missing = required.filter(action => !available.has(action)); +const isPublic = await qdnRequest({ action: 'IS_USING_PUBLIC_NODE' }); +// Missing actions or public mode mean read-only. Availability is not approval. +// Do not infer write authority from a URL, a settings field, or WHICH_UI.`, + reads: `const status = await qdnRequest({ action: 'GET_NODE_STATUS' }); +const response = await qdnRequest({ + action: 'FETCH_NODE_API', path: '/peers', maxBytes: 1024 * 1024, +}); +// FETCH_NODE_API returns an HTTP envelope; check it before reading data. +if (!response.ok) throw new Error('Peer lookup failed: HTTP ' + response.status); +const peers = Array.isArray(response.data) ? response.data : []; +// Optional diagnostics can be unavailable; an empty fallback is not proof +// that Core has no peers or that every transport is healthy.`, + metadata: `const response = await qdnRequest({ action: 'GET_NODE_SETTINGS_METADATA' }); +const metadata = response && typeof response === 'object' && 'data' in response + ? response.data : response; +// Normalize writable: either { key: { type, restartRequired } }, +// { entry: [{ key, value: { type, restartRequired } }] }. +// Node's normalizeSettingsMetadata() handles these shapes. +// Intersect with the app's editable allowlist before rendering inputs. +const appEditableKeys = ${JSON.stringify(PHASE_1_EDITABLE_SETTING_KEYS)};`, + units: `const bytesPerGB = ${STORAGE_CAPACITY_GIGABYTE_BYTES}; // decimal GB, not GiB +const millisecondsPerHour = ${CHAT_RETENTION_HOUR_MS}; +const storageBytes = 2.5 * bytesPerGB; +const retentionMilliseconds = 24 * millisecondsPerHour; +// Node uses parseGigabytesToBytes / parseHoursToMilliseconds: +// require at least 1 displayed unit and at most 3 decimal places. +// Conversion rounds to an integer and rejects unsafe values.`, + save: `// Define the operation; copying this example does not call it. +async function saveReviewedPatch(reviewedPatch) { + // Use only changed, validated, metadata-writable and app-allowed keys. + // Home presents its own current/proposed values and selected node approval. + const result = await qdnRequest({ + action: 'UPDATE_NODE_SETTINGS', settings: reviewedPatch, + }); + // result: { saved, applied, updated, removed, restartRequired } + // Re-read settings and metadata. A saved restart-required value can still + // differ from what restart-dependent services use. No automatic retry on ambiguous failure. + return result; +} +// This is not a revision/CAS store: do not add expectedRevision.`, + restart: `// A separate explicit user action, never part of copying or saving. +async function requestRestart() { + return qdnRequest({ action: 'RESTART_NODE' }); +} +// Home asks to restart the selected Core. { accepted: true } acknowledges +// the request, not a completed restart or a healthy/synchronized node. +// Expect temporary disconnection; refresh status/metadata after it returns.`, +} as const; + +export function Reference() { + const [copied, setCopied] = useState(''); + async function copy(key: string, value: string, button: HTMLButtonElement) { + setCopied(await copyTextToClipboard(value) ? key : 'unavailable'); + button.focus({ preventScroll: true }); + } + return
+

Developers

+

Qortium Core inspection and settings through Qortium Home.

+ +

{copied === 'unavailable' ? 'Clipboard unavailable. Select the code and copy it manually.' : copied ? `Copied ${copied} example.` : 'Code examples can be selected for manual copying.'}

+
+
+

Host and authority

+

Node is a Qortium-only app published as APP/Node/Node. It inspects the active Qortium Core route selected in Home; it is not a Qortal node manager. The public app bundle and this reference contain no live node settings, credentials or wallet data.

+

Use SHOW_ACTIONS and IS_USING_PUBLIC_NODE to discover the current host. Node’s write UI requires an update action, a node not reported as public, and writable metadata for each app-allowed field. An unknown public-node flag is not authority: Home independently requires a trusted local or authenticated route and a per-request approval. Switching node or API-key trust while approving invalidates the write.

+

Home owns the connection and credentials. Apps must not ask users to paste API keys or private keys, embed them in QDN assets, or use a direct browser request to bypass Home approval. Viewing Developers or copying examples performs no settings mutation or restart.

+

The standalone development adapter reads http://127.0.0.1:24891 (configurable at build time with VITE_QORTIUM_NODE_API_URL). It permits GET/HEAD and does not advertise update/restart actions. The reference remains available when Core is offline. A developer browser fallback is not an authenticated admin route.

+
+

Status and peers

+

GET_NODE_STATUS returns a direct status object, with optional sync phase/percent, height/target/remaining blocks, minting possibility, connection counts and reachability fields. Missing fields stay unknown. FETCH_NODE_API returns an envelope with ok, HTTP status, contentType, body and parsed data. Node accepts direct objects from older adapters too; independent consumers should check response success and validate data before use.

+
{NODE_READ_PATHS.map(([path, meaning]) =>
{path}
{meaning}
)}
+

Chain peers and QDN/data peers are separate connections, with IP/I2P and inbound/outbound breakdowns. Node IDs can overlap between the two networks: a connection count is not a unique-node count. Diagnostic availability, backoff, connectability and an I2P session being up describe different conditions; none alone proves end-to-end QDN transfer. Peer addresses, IDs and settings may identify a device: inspect before sharing a diagnostic export.

+

Refresh loads a new snapshot. Optional reads can fail independently and fall back to empty/unknown displays; that is not proof the corresponding node data is empty. Node does not publish these reads to QDN. /admin/settings describes the current loaded settings object. Core reloads this object on save; its values do not prove restart-dependent services have adopted the change. Metadata can report differences between the file and that object.

+
+

Settings and units

+

GET_NODE_SETTINGS_METADATA is preferred; Node also attempts the read-only metadata path when unavailable. normalizeSettingsMetadata supports a plain writable map or JAXB writable.entry records. A top-level writable array is not supported by Node’s current normalizer. Missing or malformed writable metadata leaves fields read-only. Metadata includes per-field type/restartRequired, and may include pendingRestart, fileChanged, fileDiffersFromRuntime and fileComparisonError. Do not depend on a local settings-file path being exposed.

+

Current app allowlist ({PHASE_1_EDITABLE_SETTING_KEYS.length} fields, in the existing settings order):

+
    {PHASE_1_EDITABLE_SETTING_KEYS.map(key =>
  • {key}
  • )}
+

Core adding a writable key does not automatically expose it in Node. Conversely, being in this allowlist does not make a field writable on every node. Keep editor defaults, validation and ordering consistent when intentionally adding a field.

+
+
Storage / retention
maxStorageCapacity displays decimal GB ({STORAGE_CAPACITY_GIGABYTE_BYTES.toLocaleString('en-US')} bytes/GB); chatMessageRetentionPeriod displays hours ({CHAT_RETENTION_HOUR_MS.toLocaleString('en-US')} ms/hour). The current editor accepts at least 1 GB or 1 hour, with up to three decimal places; conversion rounds to safe integer native units. Invalid input does not enter the patch.
+
Numeric / version fields
Ports are integers from 1 to 65535. Peer limits are positive integers except minOutboundPeers, which permits zero. The minimum version uses three numeric components, each from 0 to 32767. Core remains the final validator.
+
Enums
Storage: {STORAGE_POLICY_OPTIONS.join(', ')}. Auto update: {AUTO_UPDATE_MODE_OPTIONS.join(', ')}. Transport selections: {TRANSPORT_SELECTION_OPTIONS.map(x => x.label).join(', ')}. Transport order is not a distinct UI choice; legacy reversed arrays normalize to the combined option.
+
+

relayModeEnabled is intentionally absent: Qortium relays QDN whenever QDN is enabled. Retention policy, capacity, public/private data and push-on-publish remain separate controls. The reference does not add new settings permissions.

+
+

Save and restart

+

Discover {SETTINGS_BRIDGE_ACTIONS.map((action, i) => {i ? ', ' : ''}{action})} independently. Node sends only changed, valid fields as {'{ action: "UPDATE_NODE_SETTINGS", settings: patch }'}. Home 2 also accepts patch/payload aliases, validates the writable keys before prompting and shows the selected node with current/proposed values. A single request is limited to 64 settings, with key lengths up to 120 characters and bounded display values.

+

Approval belongs to Home and each request. The response exposes saved and key lists applied, updated, removed, restartRequired; Home 2 removes filesystem paths from the update result. Save is distinct from applying a restart-required value. Re-read both settings and metadata; file comparisons can fail independently.

+

Current Core retains a pendingRestart marker after a saved value is reverted to its original value. Check fileChanged and fileDiffersFromRuntime separately: these compare the file with the loaded settings object, not every service’s effective configuration. Neither a marker nor an empty file comparison alone proves whether a service needs restart. Node builds patches against its last settings snapshot; Refresh explicitly reloads that snapshot and clears the draft. Do not restart automatically.

+

There is no expectedRevision contract here. Do not silently retry writes after timeout/disconnection; the change may already have reached Core. Refresh and review the actual state. Denial keeps the draft available. A successful save or explicit Refresh resets Node’s draft; switching tabs and Back/Forward preserves it.

+

RESTART_NODE is a separate explicit request with its own approval. It acknowledges a restart request, not health or completion. Node displays “restart requested”; subsequent status/metadata reads establish whether the node returned and saved values became effective. Restarting can interrupt node activity; saving does not automatically request it.

+

Canonical reference route: qdn://APP/Node/Node?view=developers; developer/reference aliases normalize to developers. ?page=settings is preserved while viewing the reference. Returning to Node Status or Core Settings removes reference view/section parameters. Section links retain host parameters, repeated unknown parameters and fragments; Home Back/Forward restores the workspace.

+
+

Bridge examples

Examples illustrate the contract, using no live device data. Copying does not execute them. Keep settings changes and restart calls behind an explicit user decision.

+ {Object.entries(REFERENCE_SNIPPETS).map(([key, value]) =>

{key}

{value}
)} +
+
+
; +} diff --git a/src/ReferenceNavigation.tsx b/src/ReferenceNavigation.tsx new file mode 100644 index 0000000..74dde44 --- /dev/null +++ b/src/ReferenceNavigation.tsx @@ -0,0 +1,53 @@ +import { useEffect, type MouseEvent } from 'react'; + +export const REFERENCE_SECTIONS = [ + ['contract', 'Host and authority'], ['reads', 'Status and peers'], + ['settings', 'Settings and units'], ['writes', 'Save and restart'], + ['examples', 'Bridge examples'], +] as const; +type SectionId = typeof REFERENCE_SECTIONS[number][0]; + +/** Keep overview/settings selection and existing fragments under Core's injected base. */ +export function referenceSectionUrl(input: string, id: SectionId) { + const url = new URL(input, 'http://localhost'); + url.searchParams.set('view', 'developers'); + url.searchParams.set('section', id); + return `${url.pathname}${url.search}${url.hash}`; +} + +function scrollSection() { + const id = new URL(window.location.href).searchParams.get('section'); + if (!REFERENCE_SECTIONS.some(([section]) => section === id)) return; + const section = document.getElementById(`reference-${id}`); + const container = section?.closest('.reference-scroll'); + if (section && container) { + container.scrollTop += section.getBoundingClientRect().top - container.getBoundingClientRect().top + - (Number.parseFloat(getComputedStyle(section).scrollMarginTop) || 0); + // The reference header can exceed a phone viewport at Huge text size. + // Reveal the reading pane inside Node without scrolling Home's document. + const topbarHeight = document.querySelector('.topbar')?.getBoundingClientRect().height ?? 0; + // This is Node's own document inside Home's app view, never its parent. + document.scrollingElement?.scrollBy?.({ top: container.getBoundingClientRect().top - topbarHeight - 8 }); + section.focus({ preventScroll: true }); + } +} + +export function ReferenceNavigation() { + useEffect(() => { + scrollSection(); + window.addEventListener('popstate', scrollSection); + return () => window.removeEventListener('popstate', scrollSection); + }, []); + function visit(event: MouseEvent, id: SectionId) { + if (event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + const next = referenceSectionUrl(window.location.href, id); + if (new URL(window.location.href).searchParams.get('section') !== id) window.history.pushState(window.history.state, '', next); + scrollSection(); + } + return ; +} diff --git a/src/clipboard.test.ts b/src/clipboard.test.ts new file mode 100644 index 0000000..1f4db0e --- /dev/null +++ b/src/clipboard.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { copyTextToClipboard, type ClipboardDependencies } from './clipboard'; + +function mockDocument(execCommandResult: boolean) { + const textarea = { + value: '', + style: {} as Record, + setAttribute: vi.fn(), + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + }; + const documentRef = { + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + createElement: vi.fn(() => textarea), + execCommand: vi.fn(() => execCommandResult), + }; + + return { documentRef, textarea }; +} + +describe('copyTextToClipboard', () => { + it('uses navigator.clipboard when the QDN view permits it', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const dependencies: ClipboardDependencies = { navigator: { clipboard: { writeText } } }; + + await expect(copyTextToClipboard('qdn://APP/Node/Node?view=developers', dependencies)).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith('qdn://APP/Node/Node?view=developers'); + }); + + it('falls back to a selected textarea when navigator.clipboard is rejected', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('NotAllowedError')); + const { documentRef, textarea } = mockDocument(true); + const dependencies: ClipboardDependencies = { + document: documentRef as unknown as ClipboardDependencies['document'], + navigator: { clipboard: { writeText } }, + }; + + await expect(copyTextToClipboard('fallback link', dependencies)).resolves.toBe(true); + expect(textarea.value).toBe('fallback link'); + expect(textarea.select).toHaveBeenCalledTimes(1); + expect(documentRef.execCommand).toHaveBeenCalledWith('copy'); + expect(documentRef.body.removeChild).toHaveBeenCalledTimes(1); + }); + + it('returns false when neither clipboard path is available', async () => { + await expect(copyTextToClipboard('unavailable', {})).resolves.toBe(false); + }); +}); diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 0000000..c4712bf --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,53 @@ +export interface ClipboardDependencies { + document?: Pick; + navigator?: { + clipboard?: { + writeText?: (text: string) => Promise | void; + }; + }; +} + +export async function copyTextToClipboard( + text: string, + dependencies: ClipboardDependencies = globalThis as ClipboardDependencies, +): Promise { + const writeText = dependencies.navigator?.clipboard?.writeText; + + if (writeText) { + try { + await writeText.call(dependencies.navigator?.clipboard, text); + return true; + } catch { + // Sandboxed QDN views can reject the modern Clipboard API. Fall through + // to the selection-based copy path while the button click is active. + } + } + + return copyTextWithTextarea(text, dependencies.document); +} + +function copyTextWithTextarea(text: string, documentRef: ClipboardDependencies['document']): boolean { + if (!documentRef?.body || !documentRef.createElement || !documentRef.execCommand) { + return false; + } + + const textarea = documentRef.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + documentRef.body.appendChild(textarea); + + try { + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + return documentRef.execCommand('copy'); + } catch { + return false; + } finally { + documentRef.body.removeChild(textarea); + } +} diff --git a/src/displaySettings.test.ts b/src/displaySettings.test.ts index d52296a..9417d50 100644 --- a/src/displaySettings.test.ts +++ b/src/displaySettings.test.ts @@ -30,6 +30,7 @@ describe('display settings', () => { expect(normalizeTextSize('extra-large')).toBe('extra-large'); expect(normalizeTheme('dark')).toBe('dark'); expect(normalizeUiStyle(' MODERN ')).toBe('modern'); + expect(normalizeUiStyle('fun')).toBe('fun'); expect(normalizeAccent('neon')).toBeNull(); expect(normalizeTextSize('extra-huge')).toBeNull(); expect(normalizeTheme('sepia')).toBeNull(); diff --git a/src/displaySettings.ts b/src/displaySettings.ts index f532c62..40834e7 100644 --- a/src/displaySettings.ts +++ b/src/displaySettings.ts @@ -3,7 +3,7 @@ import { isRtlLanguage, normalizeLanguage } from './i18n'; const TEXT_SIZE_VALUES = ['extra-small', 'small', 'medium', 'large', 'extra-large', 'huge'] as const; const ACCENT_VALUES = ['green', 'blue', 'orange', 'purple', 'red', 'teal', 'cyan', 'pink', 'yellow'] as const; -const UI_STYLE_VALUES = ['classic', 'modern'] as const; +const UI_STYLE_VALUES = ['classic', 'modern', 'fun'] as const; const DEFAULT_DISPLAY_SETTINGS: DisplaySettings = { accent: 'green', diff --git a/src/i18n.ts b/src/i18n.ts index 5fada32..b486971 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -2,6 +2,7 @@ export type MessageValues = Record; export const EN_STRINGS = { 'app.title': 'Node', + 'label.developers': 'Developers', 'common.blocked': 'blocked', 'common.down': 'down', 'common.editable': 'editable', diff --git a/src/nodeContract.ts b/src/nodeContract.ts new file mode 100644 index 0000000..4e5f5ca --- /dev/null +++ b/src/nodeContract.ts @@ -0,0 +1,11 @@ +/** Existing Node/Home contract shared by the UI and public reference. */ +export const SETTINGS_BRIDGE_ACTIONS = ['GET_NODE_SETTINGS_METADATA', 'UPDATE_NODE_SETTINGS', 'RESTART_NODE'] as const; +export const NODE_READ_PATHS = [ + ['/admin/info', 'Core build, uptime and node identity'], + ['/admin/settings', 'Effective Core settings'], + ['/admin/settings/metadata', 'Writable settings and restart/file differences'], + ['/peers', 'Connected chain peers'], + ['/peers/data', 'Connected QDN/data peers'], + ['/peers/known/diagnostics', 'Known chain-peer diagnostics'], + ['/peers/data/known/diagnostics', 'Known data-peer diagnostics'], +] as const; diff --git a/src/nodeRoute.test.ts b/src/nodeRoute.test.ts index e091b43..6c80d31 100644 --- a/src/nodeRoute.test.ts +++ b/src/nodeRoute.test.ts @@ -50,3 +50,20 @@ describe('Node routes', () => { } }); }); + + +describe('Developers workspace', () => { + it.each(['developers', 'developer', 'reference'])('recognizes %s ahead of the settings page', view => { + expect(readNodeRoute(`https://example.test/?page=settings&view=${view}`)).toEqual({ page: 'developers' }); + }); + it('canonicalizes without discarding the retained page, section or host history query', () => { + const url = getNodeRouteUrl('https://example.test/render/APP/Node/Node?page=settings&view=reference§ion=settings&future=a&future=b#retained', { page: 'developers' }); + expect(url.searchParams.get('page')).toBe('settings'); + expect(url.searchParams.get('view')).toBe('developers'); + expect(url.searchParams.get('section')).toBe('settings'); + expect(url.searchParams.getAll('future')).toEqual(['a', 'b']); expect(url.hash).toBe('#retained'); + const back = getNodeRouteUrl(url, { page: 'settings' }); + expect(back.searchParams.has('view')).toBe(false); expect(back.searchParams.has('section')).toBe(false); + expect(back.searchParams.get('page')).toBe('settings'); + }); +}); diff --git a/src/nodeRoute.ts b/src/nodeRoute.ts index dc66687..ce02a59 100644 --- a/src/nodeRoute.ts +++ b/src/nodeRoute.ts @@ -4,13 +4,15 @@ export interface NodeRoute { page: AppPage; } -const NODE_ROUTE_KEYS = ['page'] as const; +const NODE_ROUTE_KEYS = ['page', 'view', 'section'] as const; +const DEVELOPER_VIEWS = ['developers', 'developer', 'reference']; const DEFAULT_PAGE: AppPage = 'overview'; export function readNodeRoute(input: string | URL): NodeRoute { const url = input instanceof URL ? input : new URL(input, 'http://localhost'); const requestedPage = url.searchParams.get('page'); - const page: AppPage = requestedPage === 'settings' ? 'settings' : DEFAULT_PAGE; + const page: AppPage = DEVELOPER_VIEWS.includes(url.searchParams.get('view') ?? '') + ? 'developers' : requestedPage === 'settings' ? 'settings' : DEFAULT_PAGE; return { page }; } @@ -18,6 +20,11 @@ export function readNodeRoute(input: string | URL): NodeRoute { export function getNodeRouteUrl(input: string | URL, route: NodeRoute): URL { const url = input instanceof URL ? new URL(input.href) : new URL(input, 'http://localhost'); + if (route.page === 'developers') { + url.searchParams.set('view', 'developers'); + return url; + } + for (const key of NODE_ROUTE_KEYS) { url.searchParams.delete(key); } diff --git a/src/styles.css b/src/styles.css index 0f5a26e..6ec487a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -979,3 +979,27 @@ td { transform: rotate(360deg); } } + +/* Fun follows Home's playful shape language while retaining the app's bundled font. */ +:root[data-ui='fun'] { --qn-radius: 20px; --qn-radius-sm: 12px; --qn-shadow: 0 5px 0 var(--qn-border); } +:root[data-ui='fun'] .tab-button { border-radius: var(--qn-radius-pill); } +.tabs { flex-wrap: wrap; } +.reference { min-width: 0; border: 1px solid var(--qn-border); border-radius: var(--qn-radius); background: var(--qn-surface); } +.reference-header { padding: 18px; } +.reference-header h2 { margin-top: 0; } +.reference-toc { display: flex; flex-wrap: wrap; gap: 8px 18px; } +.reference-toc a { color: var(--qn-accent); overflow-wrap: anywhere; } +.reference-scroll { max-height: 65dvh; min-height: 220px; overflow: auto; overscroll-behavior: contain; padding: 18px; border-top: 1px solid var(--qn-border); } +.reference section { scroll-margin-top: 12px; overflow-wrap: anywhere; padding-bottom: 18px; } +.reference section:focus-visible { outline: 2px solid var(--qn-accent); outline-offset: -2px; } +.reference h3 { font-size: var(--qn-text-heading); margin-top: 0; } +.reference dd { margin: 4px 0 16px; } +.reference dt { font-weight: 600; } +.reference-keys { padding-inline-start: 24px; } +.reference pre { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; padding: 12px; background: var(--qn-surface-alt); border: 1px solid var(--qn-border); border-radius: var(--qn-radius-sm); } +.reference code { font-family: 'Illinois Mono', monospace; overflow-wrap: anywhere; } +.reference-example { margin-bottom: 22px; } +.reference-example h4 { margin-bottom: 8px; } +.reference-example button { margin-bottom: 8px; } +.copy-status { font-size: var(--qn-text-small); color: var(--qn-text-secondary); min-height: 1.5em; margin-bottom: 0; } +@media(max-width:600px) { .reference-header, .reference-scroll { padding: 14px; } } diff --git a/src/types.ts b/src/types.ts index 08fc5d5..5f9ca0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type AppPage = 'overview' | 'settings'; +export type AppPage = 'overview' | 'settings' | 'developers'; export type BridgeState = { actions: string[]; @@ -187,5 +187,5 @@ export type DisplaySettings = { languageSource: 'core' | 'default' | 'home' | 'query'; textSize: 'extra-large' | 'extra-small' | 'huge' | 'large' | 'medium' | 'small'; theme: 'dark' | 'light'; - uiStyle: 'classic' | 'modern'; + uiStyle: 'classic' | 'modern' | 'fun'; }; diff --git a/src/workspace.test.tsx b/src/workspace.test.tsx new file mode 100644 index 0000000..2ceeb29 --- /dev/null +++ b/src/workspace.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { App } from './App'; +let root: Root;let container: HTMLDivElement; +const tab = (index: number) => container.querySelectorAll('.tab-button')[index]; +async function click(e: HTMLElement) { await act(async () => e.click()); } +async function mount() { await act(async () => root.render()); } +async function traverse(direction: 'back' | 'forward') { await act(async () => new Promise(resolve => { window.addEventListener('popstate', () => resolve(), { once: true });window.history[direction](); })); } +function bridge(publicNode = false) { + const request = vi.fn(async ({ action, path }: {action: string;path?: string}) => { + if(action==='SHOW_ACTIONS')return ['FETCH_NODE_API','GET_NODE_STATUS','GET_NODE_SETTINGS_METADATA','UPDATE_NODE_SETTINGS','RESTART_NODE']; + if(action==='WHICH_UI')return 'QORTIUM_HOME'; + if(action==='IS_USING_PUBLIC_NODE')return publicNode; + if(action==='GET_NODE_STATUS')return {height:123,syncPercent:100}; + if(action==='GET_NODE_SETTINGS_METADATA')return {writable:{apiDocumentationEnabled:{type:'boolean',restartRequired:false}}}; + if(action==='FETCH_NODE_API')return {ok:true,data:path==='/admin/settings'?{apiDocumentationEnabled:true}:[]}; + throw new Error('Unexpected '+action); + });window.qdnRequest=request as NonNullable;return request; +} +beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);window.history.replaceState({host:7},'', '/?page=settings&future=a&future=b#retained');container=document.createElement('div');document.body.append(container);root=createRoot(container); }); +afterEach(async () => {await act(async () => root.unmount());container.remove();delete window.qdnRequest;vi.unstubAllGlobals();vi.restoreAllMocks();window.history.replaceState(null,'','/');}); +describe('workspace integration', () => { + it('keeps Developers usable when the host is unavailable', async () => { + window.qdnRequest=vi.fn(async () => {throw new Error('Offline');}) as NonNullable; + await mount();await click(tab(2));expect(container.querySelector('.reference')).not.toBeNull();expect(container.querySelector('.notice')).toBeNull(); + }); + it('retains an unsaved settings draft through tabs and real Back/Forward without writes', async () => { + const request=bridge();await mount();const input=container.querySelector('.setting-control')!; + await act(async () => {input.value='false';input.dispatchEvent(new Event('change',{bubbles:true}));}); + await click(tab(2));await traverse('back');expect(container.querySelector('.setting-control')?.value).toBe('false'); + await traverse('forward');expect(container.querySelector('.reference')).not.toBeNull();await click(tab(1));expect(container.querySelector('.setting-control')?.value).toBe('false'); + expect(new URLSearchParams(location.search).getAll('future')).toEqual(['a','b']);expect(location.hash).toBe('#retained');expect(window.history.state).toEqual({host:7}); + expect(request.mock.calls.some(([r])=>/UPDATE_NODE_SETTINGS|RESTART_NODE/.test(r.action))).toBe(false); + }); + it('keeps public-node settings read-only while reference and copying remain usable', async () => { + const request=bridge(true);await mount();expect(container.querySelector('.setting-control')?.disabled).toBe(true); + await click(tab(2));const writeText=vi.fn(async()=>{});Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText}}); + await click(container.querySelector('[aria-label="Copy save example"]')!);expect(container.querySelector('[role=status]')?.textContent).toContain('Copied save');expect(writeText).toHaveBeenCalledOnce(); + expect(request.mock.calls.some(([r])=>/UPDATE_NODE_SETTINGS|RESTART_NODE/.test(r.action))).toBe(false); + }); + it('does not replace Developers when an initial status response arrives late',async()=>{ + const request=bridge();let finish!: (v:unknown)=>void;const pending=new Promise(r=>{finish=r});const original=window.qdnRequest!; + window.qdnRequest=vi.fn((r:{action:string})=>r.action==='GET_NODE_STATUS'?pending:original(r)) as NonNullable; + await mount();await click(tab(2));await act(async()=>finish({height:1}));expect(container.querySelector('.reference')).not.toBeNull();expect(request.mock.calls.some(([r])=>r.action==='UPDATE_NODE_SETTINGS')).toBe(false); + }); +});