diff --git a/.gitignore b/.gitignore index c066ee1..0d57d11 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,8 @@ temp/ CLAUDE.md COMMIT.org PR.org + +# Personal context content — each contributor's own .org notes +# loaded by for live overlays. The rendering +# plumbing is tracked; the content is private. +@kyonax_on_tech/data/contexts/ diff --git a/@kyonax_on_tech/sources.js b/@kyonax_on_tech/sources.js index 141bda6..0dfce01 100644 --- a/@kyonax_on_tech/sources.js +++ b/@kyonax_on_tech/sources.js @@ -43,6 +43,37 @@ export default [ triggers: [], status: 'ready', }, + { + id: 'context-screen', + type: 'hud', + brand: '@kyonax_on_tech', + name: 'CONTEXT-SCREEN', + description: + 'News-style **lower-third HUD** for the **CONTEXT scene**. ' + + 'Surfaces the **video title**, a short **description**, ' + + 'a **talking-point marquee**, and a **toggleable right ' + + 'sidebar** that renders the **full parsed .org context** ' + + '(headlines, lists, **checklists**, **tables**, **code ' + + 'blocks**, **#+RESULTS evaluations**, links). Authored ' + + 'per video as a small **.org file** in the brand folder.', + use_cases: [ + 'video context overlay', + 'news-style lower third', + 'topic outline', + 'code walkthrough sidebar', + 'talking-points reel', + ], + path: '/@kyonax_on_tech/context-screen', + width: CANVAS_WIDTH, + height: CANVAS_HEIGHT, + fps: TARGET_FPS, + requires: [ + '.org files at @kyonax_on_tech/data/contexts/', + 'BroadcastChannel-capable browser (OBS Chromium 32+)', + ], + triggers: [], + status: 'ready', + }, { id: 'item-explain', type: 'animation', diff --git a/@kyonax_on_tech/sources/hud/context-screen.vue b/@kyonax_on_tech/sources/hud/context-screen.vue new file mode 100644 index 0000000..1d3d232 --- /dev/null +++ b/@kyonax_on_tech/sources/hud/context-screen.vue @@ -0,0 +1,644 @@ + + + + + + + + + diff --git a/NOTICE b/NOTICE index 3595c0a..4ab95c6 100644 --- a/NOTICE +++ b/NOTICE @@ -5,3 +5,16 @@ ORCID: https://orcid.org/0009-0006-4459-5538 This product includes software developed by Cristian D. Moreno (@Kyonax). Attribution to the original author must be preserved in all copies, modifications, and derivative works. + +Third-Party Fonts +================= + +SpaceMono Nerd Font — derived from Space Mono by Colophon Foundry +(SIL Open Font License 1.1) and the Nerd Fonts patcher project +(MIT License). See https://www.nerdfonts.com/ for full attribution. + +Geomanist — by Atipo Foundry (atipofoundry.com). Used in RECKIT under +the same licensee as the sibling project Kyonax/kyo-web-online, which +ships the same font under the same authorship. License-of-record: +verify directly with Atipo Foundry for any redistribution beyond the +Kyonax-owned project graph. diff --git a/package-lock.json b/package-lock.json index 0f07bb1..5201e6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "dayjs": "^1.11.20", "obs-websocket-js": "^5.0.6", + "shiki": "^4.0.2", + "uniorg-parse": "^3.2.1", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -1543,6 +1545,106 @@ "dev": true, "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1575,6 +1677,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1589,6 +1700,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -1606,6 +1726,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -1918,6 +2044,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -2426,6 +2558,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2592,6 +2734,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2619,6 +2771,26 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2694,6 +2866,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -2913,6 +3095,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2924,6 +3115,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3716,6 +3920,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4178,6 +4388,42 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -4185,6 +4431,16 @@ "dev": true, "license": "ISC" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4531,6 +4787,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4911,6 +5179,116 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5235,6 +5613,23 @@ ], "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5511,6 +5906,16 @@ "node": ">= 0.8.0" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -5675,6 +6080,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -5985,6 +6414,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -6090,6 +6538,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -6288,6 +6746,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -6434,6 +6906,26 @@ "node": ">=14.0.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6604,6 +7096,128 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/uniorg": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/uniorg/-/uniorg-1.4.0.tgz", + "integrity": "sha512-1QYkebPJCmSNyA3bZDAqxgEJb435XYz8fQE4Mc5gxQ86tyG7EV/pyUcIrSizCkmph/ioteXUErByIjLGho/zBA==", + "license": "GPL-3.0-or-later", + "dependencies": { + "@types/unist": "^3.0.0" + } + }, + "node_modules/uniorg-parse": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/uniorg-parse/-/uniorg-parse-3.2.1.tgz", + "integrity": "sha512-4HKJj+bv3JfWxqGDqc3xJ3WLwGNg34kQS58DRBDT5er/lWKqyf17gvxuSrrw1deXqtmevFwsSYCdHTv8iL/cxQ==", + "license": "GPL-3.0-or-later", + "dependencies": { + "unified": "^11.0.4", + "uniorg": "^1.3.0", + "unist-builder": "^4.0.0", + "vfile": "^6.0.1", + "vfile-location": "^5.0.2" + } + }, + "node_modules/unist-builder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-4.0.0.tgz", + "integrity": "sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6674,6 +7288,48 @@ "spdx-license-ids": "^3.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -7199,6 +7855,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 84acb83..1b0c129 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "dependencies": { "dayjs": "^1.11.20", "obs-websocket-js": "^5.0.6", + "shiki": "^4.0.2", + "uniorg-parse": "^3.2.1", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/src/app/fonts/Geomanist/GeomanistBold.ttf b/src/app/fonts/Geomanist/GeomanistBold.ttf new file mode 100644 index 0000000..7e85856 Binary files /dev/null and b/src/app/fonts/Geomanist/GeomanistBold.ttf differ diff --git a/src/app/fonts/Geomanist/GeomanistItalic.ttf b/src/app/fonts/Geomanist/GeomanistItalic.ttf new file mode 100644 index 0000000..1e75211 Binary files /dev/null and b/src/app/fonts/Geomanist/GeomanistItalic.ttf differ diff --git a/src/app/fonts/Geomanist/GeomanistRegular.ttf b/src/app/fonts/Geomanist/GeomanistRegular.ttf new file mode 100644 index 0000000..63d0aa6 Binary files /dev/null and b/src/app/fonts/Geomanist/GeomanistRegular.ttf differ diff --git a/src/app/scss/abstracts/_mixins.scss b/src/app/scss/abstracts/_mixins.scss index cf8d41b..565b762 100644 --- a/src/app/scss/abstracts/_mixins.scss +++ b/src/app/scss/abstracts/_mixins.scss @@ -37,3 +37,133 @@ position: absolute; } +// Ad-hoc color helpers for cases where a dedicated --clr-X-XX token does +// not exist. Always prefer a named token (e.g. var(--clr-primary-100-06)) +// when available — these helpers are for one-off variants only. Each +// produces a runtime color-mix() expression compatible with CSS custom +// properties. +// +// Usage: +// background: alpha-tint(--clr-primary-100, 6%); +// color: darken(--clr-primary-100, 30%); +// border: 1px solid lighten(--clr-neutral-100, 20%); +@function alpha-tint($color-var, $percent) { + @return color-mix(in srgb, var(#{$color-var}) #{$percent}, transparent); +} + +@function darken($color-var, $by) { + @return color-mix(in srgb, var(#{$color-var}) #{100% - $by}, black); +} + +@function lighten($color-var, $by) { + @return color-mix(in srgb, var(#{$color-var}) #{100% - $by}, white); +} + +// Adds hard-edge corner squares to a container — a HUD/cyberpunk +// treatment that pairs with a 1px solid border. Expects the consumer +// to set its own background-color + border; this mixin only paints +// the corner markers via background-image. +// +// Usage: +// .container { +// background-color: var(--clr-neutral-500); +// border: 1px solid var(--clr-border-100); +// @include corner-dots(var(--clr-border-100)); // all 4 +// @include corner-dots(var(--clr-border-100), 4.5px, tl bl); // left only +// } +// +// Parameters: +// $color — solid CSS color (e.g. var(--clr-border-100)) +// $size — square edge length, default 4.5px +// $corners — space-separated list of corner keys to draw: +// tl top-left +// tr top-right +// bl bottom-left +// br bottom-right +// Default: tl tr bl br (all four). +// +// Implementation: stacked linear-gradient background-image layers, one +// per requested corner. background-origin: border-box places position +// 0 0 / 100% 100% at the OUTER border edge, so each square sits flush +// with the corner intersection rather than inset by the border width. +@mixin corner-dots($color, $size: 4.5px, $corners: tl tr bl br) { + $position-map: ( + tl: 0 0, + tr: 100% 0, + bl: 0 100%, + br: 100% 100%, + ); + $images: (); + $positions: (); + + @each $corner in $corners { + $pos: map.get($position-map, $corner); + @if $pos { + $images: append( + $images, + linear-gradient(#{$color}, #{$color}), + $separator: comma + ); + $positions: append($positions, $pos, $separator: comma); + } + } + + @if length($images) > 0 { + background-image: $images; + background-position: $positions; + background-size: #{$size} #{$size}; + background-repeat: no-repeat; + background-origin: border-box; + background-clip: border-box; + } +} + +// Pseudo-element corner squares — half outside, half inside the +// container so the square sits CENTERED on the corner intersection. +// +// IMPORTANT: the consumer must be a positioned element (any of +// `position: relative | absolute | fixed | sticky`). The mixin does +// NOT set `position: relative` itself — that would clobber existing +// `position: absolute` consumers (e.g. a sidebar pinned to the right +// edge). The consumer must also NOT use `contain: paint`, which would +// clip pseudo-elements rendered outside the box. +// +// Each mixin handles ONE corner via either ::before or ::after, so a +// surface needing two corners (e.g. strip = TL + TR) calls both +// `corner-square-tl` and `corner-square-tr`. +// +// Usage: +// .container { +// position: relative; // or absolute / fixed +// border: 1px solid var(--clr-border-100); +// @include corner-square-tl(var(--clr-neutral-50)); +// @include corner-square-tr(var(--clr-neutral-50)); +// } +@mixin corner-square-tl($color, $size: 4.5px) { + &::before { + content: ''; + position: absolute; + top: -#{$size * 0.5}; + left: -#{$size * 0.5}; + width: $size; + height: $size; + background: $color; + pointer-events: none; + z-index: 1; + } +} + +@mixin corner-square-tr($color, $size: 4.5px) { + &::after { + content: ''; + position: absolute; + top: -#{$size * 0.5}; + right: -#{$size * 0.5}; + width: $size; + height: $size; + background: $color; + pointer-events: none; + z-index: 1; + } +} + diff --git a/src/app/scss/abstracts/_theme.scss b/src/app/scss/abstracts/_theme.scss index 6ff67aa..0c36400 100644 --- a/src/app/scss/abstracts/_theme.scss +++ b/src/app/scss/abstracts/_theme.scss @@ -35,14 +35,32 @@ } --font-mono: "SpaceMono", "Roboto Mono", "Courier New", Courier, monospace; + --font-display: "Geomanist", "Inter", "Helvetica Neue", Arial, sans-serif; --canvas-width: 1920px; --canvas-height: 1080px; --hud-bar-offset: 24px; --hud-bar-gap: 24px; - --clr-primary-100-80: color-mix(in srgb, var(--clr-primary-100) 80%, transparent); + --surface-bg: linear-gradient(135deg, hsl(0 0% 2%) 0%, hsl(0 0% 7%) 50%, hsl(0 0% 14%) 100%); + + --motion-sidebar-ms: 280ms; + --motion-strip-ease: cubic-bezier(0.22, 1, 0.36, 1); + --motion-peek-pulse-s: 2.5s; + --motion-marquee-s: 90s; + + --clr-primary-100-02: color-mix(in srgb, var(--clr-primary-100) 2%, transparent); + --clr-primary-100-03: color-mix(in srgb, var(--clr-primary-100) 3%, transparent); + --clr-primary-100-04: color-mix(in srgb, var(--clr-primary-100) 4%, transparent); + --clr-primary-100-06: color-mix(in srgb, var(--clr-primary-100) 6%, transparent); + --clr-primary-100-10: color-mix(in srgb, var(--clr-primary-100) 10%, transparent); + --clr-primary-100-14: color-mix(in srgb, var(--clr-primary-100) 14%, transparent); --clr-primary-100-40: color-mix(in srgb, var(--clr-primary-100) 40%, transparent); + --clr-primary-100-80: color-mix(in srgb, var(--clr-primary-100) 80%, transparent); + + --clr-neutral-50-02: color-mix(in srgb, var(--clr-neutral-50) 2%, transparent); + --clr-neutral-50-04: color-mix(in srgb, var(--clr-neutral-50) 4%, transparent); + --clr-neutral-50-12: color-mix(in srgb, var(--clr-neutral-50) 12%, transparent); --hud-halo: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9)) diff --git a/src/app/scss/abstracts/_variables.scss b/src/app/scss/abstracts/_variables.scss index 4b9ff3a..5356b0c 100644 --- a/src/app/scss/abstracts/_variables.scss +++ b/src/app/scss/abstracts/_variables.scss @@ -28,6 +28,14 @@ $colors: ( 400: hsl(224, 74%, 34%), 500: hsl(224, 74%, 22%), ), + tertiary: ( + 50: hsl(266, 88%, 78%), + 100: hsl(266, 73%, 56%), + 200: hsl(266, 73%, 45%), + 300: hsl(266, 73%, 39%), + 400: hsl(266, 73%, 28%), + 500: hsl(266, 73%, 17%), + ), neutral: ( 50: hsl(0, 0%, 95%), 100: hsl(0, 0%, 85%), diff --git a/src/app/scss/base/_typography.scss b/src/app/scss/base/_typography.scss index d385e9f..226162b 100644 --- a/src/app/scss/base/_typography.scss +++ b/src/app/scss/base/_typography.scss @@ -10,3 +10,7 @@ @include mixins.font-face("SpaceMono", "SpaceMono/SpaceMonoNerdFont-Bold", 700, normal); @include mixins.font-face("SpaceMono", "SpaceMono/SpaceMonoNerdFont-Italic", 400, italic); @include mixins.font-face("SpaceMono", "SpaceMono/SpaceMonoNerdFont-BoldItalic", 700, italic); + +@include mixins.font-face("Geomanist", "Geomanist/GeomanistRegular", 400, normal); +@include mixins.font-face("Geomanist", "Geomanist/GeomanistBold", 700, normal); +@include mixins.font-face("Geomanist", "Geomanist/GeomanistItalic", 400, italic); diff --git a/src/shared/brand-loader.js b/src/shared/brand-loader.js index e93cf2e..44cfc3d 100644 --- a/src/shared/brand-loader.js +++ b/src/shared/brand-loader.js @@ -7,8 +7,19 @@ * root and merges their metadata + source registries into a * single runtime object. Uses import.meta.glob for static * analysis by Vite's bundler. + * + * Also discovers per-brand .org context files at /@/data/ + * contexts/.org, parses each via uniorg-parse at glob-time, + * and exposes them as a CONTEXTS map: { brand → slug → { raw, + * parsed } }. Cached AST means the sidebar renderer has zero + * parse cost per frame (Plan #context-screen, decision D3). */ +import { OrgSchemaError, parseOrg } from '@shared/utils/org.js'; + +const BRAND_HANDLE_PATTERN = /^\/(@[^/]+)\/data\/contexts\//; +const ORG_FILENAME_PATTERN = /([^/]+)\.org$/; + const brand_modules = import.meta.glob( '/@*/brand.js', { eager: true, import: 'default' }, @@ -25,8 +36,14 @@ const animation_components = import.meta.glob( ); const scene_components = import.meta.glob('/@*/sources/scene/*.vue'); +const context_modules = import.meta.glob( + '/@*/data/contexts/*.org', + { eager: true, query: '?raw', import: 'default' }, +); + export const BRANDS = Object.values(brand_modules); export const SOURCES = Object.values(source_modules).flat(); +export const CONTEXTS = buildContextsMap(context_modules); export function getBrand(handle) { return BRANDS.find((b) => b.handle === handle) || null; @@ -49,3 +66,43 @@ export function resolveComponent(source) { return components[key] || null; } + +export function getContexts(handle) { + return CONTEXTS[handle] || {}; +} + +function buildContextsMap(modules) { + const map = {}; + for (const path of Object.keys(modules)) { + const brand_match = path.match(BRAND_HANDLE_PATTERN); + const slug_match = path.match(ORG_FILENAME_PATTERN); + if (!brand_match || !slug_match) { + continue; + } + const brand_handle = brand_match[1]; + const slug = slug_match[1]; + const raw = modules[path]; + + let parsed = null; + let parse_error = null; + try { + parsed = parseOrg(raw); + } catch (error) { + if (error instanceof OrgSchemaError) { + parse_error = { + name: error.name, + message: error.message, + missing_keys: error.missing_keys, + }; + } else { + parse_error = { name: 'Error', message: String(error) }; + } + } + + if (!map[brand_handle]) { + map[brand_handle] = {}; + } + map[brand_handle][slug] = { raw, parsed, parse_error }; + } + return map; +} diff --git a/src/shared/brand-loader.test.js b/src/shared/brand-loader.test.js index 137df52..00920d7 100644 --- a/src/shared/brand-loader.test.js +++ b/src/shared/brand-loader.test.js @@ -7,7 +7,7 @@ * Validates brand metadata and web source schema. */ -import { BRANDS, SOURCES } from '@shared/brand-loader.js'; +import { BRANDS, CONTEXTS, getContexts, SOURCES } from '@shared/brand-loader.js'; import { describe, expect, it } from 'vitest'; const REQUIRED_SOURCE_FIELDS = [ @@ -155,3 +155,63 @@ describe('Brand loader — SOURCES', () => { }, ); }); + +describe('Brand loader — CONTEXTS', () => { + const BRAND_KOT = '@kyonax_on_tech'; + + it('exposes CONTEXTS as a brand-keyed object', () => { + expect(typeof CONTEXTS).toBe('object'); + expect(CONTEXTS).not.toBe(null); + }); + + it(`discovers at least one context under ${BRAND_KOT}`, () => { + const kot = CONTEXTS[BRAND_KOT] || {}; + expect(Object.keys(kot).length).toBeGreaterThan(0); + }); + + it('every context has { raw: string, parsed: object|null }', () => { + for (const brand of Object.keys(CONTEXTS)) { + for (const slug of Object.keys(CONTEXTS[brand])) { + const entry = CONTEXTS[brand][slug]; + expect(typeof entry.raw).toBe('string'); + expect(entry.raw.length).toBeGreaterThan(0); + expect( + entry.parsed === null || typeof entry.parsed === 'object', + ).toBe(true); + } + } + }); + + it('every parsed context (no parse_error) has the locked Q5 schema', () => { + for (const brand of Object.keys(CONTEXTS)) { + for (const slug of Object.keys(CONTEXTS[brand])) { + const entry = CONTEXTS[brand][slug]; + if (entry.parse_error) { + continue; + } + expect(typeof entry.parsed.title).toBe('string'); + expect(typeof entry.parsed.description).toBe('string'); + expect(Array.isArray(entry.parsed.tags)).toBe(true); + expect(Array.isArray(entry.parsed.marquee_items)).toBe(true); + expect(Array.isArray(entry.parsed.body_ast)).toBe(true); + } + } + }); + + it('slugs are unique within each brand', () => { + for (const brand of Object.keys(CONTEXTS)) { + const slugs = Object.keys(CONTEXTS[brand]); + expect(new Set(slugs).size).toBe(slugs.length); + } + }); + + it('getContexts(handle) returns the per-brand map', () => { + const kot = getContexts(BRAND_KOT); + expect(typeof kot).toBe('object'); + expect(Object.keys(kot).length).toBeGreaterThan(0); + }); + + it('getContexts(missing-handle) returns empty object', () => { + expect(getContexts('@no-such-brand')).toEqual({}); + }); +}); diff --git a/src/shared/components/ui/chip.vue b/src/shared/components/ui/chip.vue index a46b061..7a82fc7 100644 --- a/src/shared/components/ui/chip.vue +++ b/src/shared/components/ui/chip.vue @@ -16,10 +16,20 @@ variant — "solid" (default) | "overflow" (dashed border + dimmed — used for "+N more" indicators at the end of a list). + shape — "pill" (default) | "square". Controls border-radius. + Both currently map to 0 (the chip ships sharp by + default per Plan #context-screen D15); "pill" is + reserved for a future redesign that may introduce + a radius. Consumers under a sharp-corner mandate + (context-screen surfaces) should pass shape="square" + to lock 0-radius regardless of future chip CSS. --> @@ -33,9 +43,15 @@ const props = defineProps({ default: 'solid', validator: (v) => ['solid', 'overflow'].includes(v), }, + shape: { + type: String, + default: 'pill', + validator: (v) => ['pill', 'square'].includes(v), + }, }); const variant_class = computed(() => `ui-chip--${props.variant}`); +const shape_class = computed(() => `ui-chip--${props.shape}`); diff --git a/src/shared/components/ui/org-content.test.js b/src/shared/components/ui/org-content.test.js new file mode 100644 index 0000000..9a68904 --- /dev/null +++ b/src/shared/components/ui/org-content.test.js @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2026 Cristian D. Moreno — @Kyonax + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ + * + * Tests for — recursive AST renderer (Plan #context- + * screen D10 + D11). Mounts the parsed body AST from a fixture + * .org string and asserts each major node type produces the + * expected DOM. No v-html, no innerHTML — verified by template + * walk only. + */ + +import { collectBodyNodes } from '@shared/utils/org.js'; +import UiOrgContent from '@ui/org-content.vue'; +import { mount } from '@vue/test-utils'; +import { unified } from 'unified'; +import uniorgParse from 'uniorg-parse'; +import { describe, expect, it } from 'vitest'; + +const FIXTURE = `#+TITLE: Fixture +#+DESCRIPTION: Body fixture + +* Headline One + +A *bold* and /italic/ paragraph with =verbatim=. + +** Headline Two + +- plain item +- [ ] todo +- [X] done + +A separating paragraph between lists. + +1. ordered one +2. ordered two + +| col | val | +|-----+-----| +| a | 1 | + +#+begin_src js +const x = 1; +#+end_src + +#+RESULTS: +: 1 + +#+begin_quote +A quote +#+end_quote + +[[https://example.com][a link]] + +----- +`; + +const EXPECTED_HEADLINE_COUNT = 2; +const EXPECTED_LIST_ITEMS = 4; +const EXPECTED_TABLE_CELLS = 2; + +function buildBodyAst(raw_string) { + const ast = unified().use(uniorgParse).parse(raw_string); + return collectBodyNodes(ast); +} + +function mountFixture() { + return mount(UiOrgContent, { + props: { ast: buildBodyAst(FIXTURE) }, + }); +} + +describe('', () => { + it('renders without throwing on the empty default', () => { + const wrapper = mount(UiOrgContent); + expect(wrapper.find('.org-content').exists()).toBe(true); + }); + + it('renders headlines as h2/h3 with org-headline classes', () => { + const wrapper = mountFixture(); + const headlines = wrapper.findAll('.org-headline'); + expect(headlines.length).toBe(EXPECTED_HEADLINE_COUNT); + expect(headlines[0].element.tagName).toBe('H2'); + expect(headlines[0].classes()).toContain('org-headline--h1'); + expect(headlines[1].element.tagName).toBe('H3'); + expect(headlines[1].classes()).toContain('org-headline--h2'); + }); + + it('renders bold + italic + verbatim as semantic elements', () => { + const wrapper = mountFixture(); + expect(wrapper.find('strong.org-bold').exists()).toBe(true); + expect(wrapper.find('em.org-italic').exists()).toBe(true); + expect(wrapper.find('code.org-verbatim').exists()).toBe(true); + }); + + it('renders unordered + ordered lists with checkbox items', () => { + const wrapper = mountFixture(); + expect(wrapper.find('ul.org-list--unordered').exists()).toBe(true); + expect(wrapper.find('ol.org-list--ordered').exists()).toBe(true); + const items = wrapper.findAll('.org-list-item'); + expect(items.length).toBeGreaterThanOrEqual(EXPECTED_LIST_ITEMS); + expect(wrapper.find('.org-list-item--todo').exists()).toBe(true); + expect(wrapper.find('.org-list-item--done').exists()).toBe(true); + }); + + it('renders tables with cells', () => { + const wrapper = mountFixture(); + expect(wrapper.find('table.org-table').exists()).toBe(true); + const cells = wrapper.findAll('td.org-table__cell'); + expect(cells.length).toBeGreaterThanOrEqual(EXPECTED_TABLE_CELLS); + }); + + it('renders src-block as
 with lang label', () => {
+    const wrapper = mountFixture();
+    const src_block = wrapper.find('pre.org-src-block');
+    expect(src_block.exists()).toBe(true);
+    expect(src_block.find('code.org-src-block__code').exists()).toBe(true);
+    expect(src_block.find('.org-src-block__lang').text()).toBe('js');
+  });
+
+  it('renders #+RESULTS: as a fixed-width block with OUTPUT label', () => {
+    const wrapper = mountFixture();
+    const results = wrapper.find('pre.org-results');
+    expect(results.exists()).toBe(true);
+    expect(results.find('.org-results__label').text()).toBe('OUTPUT');
+  });
+
+  it('renders quote-block as ', () => {
+    const wrapper = mountFixture();
+    expect(wrapper.find('blockquote.org-quote').exists()).toBe(true);
+  });
+
+  it('renders external link as  with target=_blank + rel=noopener', () => {
+    const wrapper = mountFixture();
+    const link = wrapper.find('a.org-link');
+    expect(link.exists()).toBe(true);
+    expect(link.attributes('href')).toBe('https://example.com');
+    expect(link.attributes('target')).toBe('_blank');
+    expect(link.attributes('rel')).toContain('noopener');
+  });
+
+  it('renders horizontal rule as ', () => {
+    const wrapper = mountFixture();
+    expect(wrapper.find('hr.org-hr').exists()).toBe(true);
+  });
+
+  it('does not inject script tags through any node value', () => {
+    const wrapper = mountFixture();
+    expect(wrapper.html()).not.toContain('; code blocks via 
{{ value }}
+  
. + + Self-referential — uses defineOptions({ name }) so the recursive + template binding resolves. + + Props: + ast — Array of OrgNode. Default empty array. +--> + + + + + + diff --git a/src/shared/composables/composables.test.js b/src/shared/composables/composables.test.js index 7481fc7..d782813 100644 --- a/src/shared/composables/composables.test.js +++ b/src/shared/composables/composables.test.js @@ -10,9 +10,10 @@ */ import { useAudioAnalyzer } from '@composables/use-audio-analyzer.js'; +import { useContextChannel } from '@composables/use-context-channel.js'; import { useRecordingStatus } from '@composables/use-recording-status.js'; import { useSceneName } from '@composables/use-scene-name.js'; -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; vi.mock('@composables/use-obs-websocket.js', async () => { const vue = await vi.importActual('vue'); @@ -79,3 +80,73 @@ describe('useAudioAnalyzer (singleton)', () => { expect(a.tick).toBe(b.tick); }); }); + +const post_message_spy = vi.fn(); + +class MockBroadcastChannel { + constructor(name) { + this.name = name; + } + addEventListener() {} + removeEventListener() {} + postMessage(data) { + post_message_spy(data); + } + close() {} +} + +describe('useContextChannel (singleton)', () => { + beforeAll(() => { + vi.stubGlobal('BroadcastChannel', MockBroadcastChannel); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + it('returns the same instance on repeated calls', () => { + const a = useContextChannel(); + const b = useContextChannel(); + expect(a).toBe(b); + expect(a.active_slug).toBe(b.active_slug); + expect(a.sidebar_open).toBe(b.sidebar_open); + }); +}); + +describe('useContextChannel (initial state)', () => { + it('starts with active_slug=null and sidebar_open=false', () => { + const state = useContextChannel(); + expect(state.active_slug.value).toBe(null); + expect(state.sidebar_open.value).toBe(false); + }); + + it('exposes setActiveSlug, toggleSidebar, hideSidebar methods', () => { + const state = useContextChannel(); + expect(typeof state.setActiveSlug).toBe('function'); + expect(typeof state.toggleSidebar).toBe('function'); + expect(typeof state.hideSidebar).toBe('function'); + }); +}); + +describe('useContextChannel (BroadcastChannel)', () => { + it('postMessage fires when setActiveSlug runs', () => { + const state = useContextChannel(); + post_message_spy.mockClear(); + state.setActiveSlug('obs-browser-sources'); + expect(post_message_spy).toHaveBeenCalledTimes(1); + const payload = post_message_spy.mock.calls[0][0]; + expect(payload.active_slug).toBe('obs-browser-sources'); + expect(typeof payload.sidebar_open).toBe('boolean'); + }); + + it('toggleSidebar flips sidebar_open and broadcasts', () => { + const state = useContextChannel(); + const before = state.sidebar_open.value; + post_message_spy.mockClear(); + state.toggleSidebar(); + expect(state.sidebar_open.value).toBe(!before); + expect(post_message_spy).toHaveBeenCalledTimes(1); + state.toggleSidebar(); + expect(state.sidebar_open.value).toBe(before); + }); +}); diff --git a/src/shared/composables/use-context-channel.js b/src/shared/composables/use-context-channel.js new file mode 100644 index 0000000..40a8d95 --- /dev/null +++ b/src/shared/composables/use-context-channel.js @@ -0,0 +1,228 @@ +/** + * Copyright (c) 2026 Cristian D. Moreno — @Kyonax + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ + * + * useContextChannel — singleton composable for the context-screen + * cross-page control plane (Plan #context-screen, decisions D1 + + * D4). Owns two reactive refs (active_slug, sidebar_open) and + * three actions (setActiveSlug, toggleSidebar, hideSidebar). + * Synchronises across same-origin browser tabs via the native + * `BroadcastChannel` API, and persists state to `localStorage` so + * a page reload restores the last selection. Watches sidebar_open + * and toggles the `.context-sidebar-open` class on the document + * root — the lower-third strip + sidebar slide animation hang off + * that class flip (Plan R2a + R2c + R2f). + * + * Module-level singleton per session-file §1.14.5: every consumer + * shares one channel + one state object. No onUnmounted cleanup — + * the channel lives for the page lifetime. + * + * Mirrors the singleton shape of `use-audio-analyzer.js`. + */ + +import { effectScope, ref, watch } from 'vue'; + +const CHANNEL_NAME = 'reckit:context-screen'; +const LOCALSTORAGE_KEY = 'reckit:context-channel:state'; +const LOCALSTORAGE_DEBOUNCE_MS = 100; +const SIDEBAR_OPEN_CLASS = 'context-sidebar-open'; +const RELAY_ENDPOINT = '/__context_state'; +const RELAY_POLL_INTERVAL_MS = 300; + +let shared_state = null; + +export function useContextChannel() { + if (shared_state) { + return shared_state; + } + + const active_slug = ref(null); + const sidebar_open = ref(false); + + const persisted = readPersistedState(); + if (persisted) { + if (typeof persisted.active_slug !== 'undefined') { + active_slug.value = persisted.active_slug; + } + if (typeof persisted.sidebar_open === 'boolean') { + sidebar_open.value = persisted.sidebar_open; + } + } + + const channel = createChannel(); + function applyRemote(remote) { + if (!remote || typeof remote !== 'object') { + return; + } + if (typeof remote.active_slug !== 'undefined') { + active_slug.value = remote.active_slug; + } + if (typeof remote.sidebar_open === 'boolean') { + sidebar_open.value = remote.sidebar_open; + } + } + + channel.addEventListener('message', (event) => { + applyRemote(event && event.data); + }); + + // Cross-process bridge via HTTP. OBS browser source runs in its own + // Chromium (CEF) process — BroadcastChannel can't reach across that + // boundary. Every consumer polls the dev-server endpoint at a fixed + // interval; every local action pushes via POST. Latency ~300 ms p95. + // Suppress echo: when we POST our own snapshot, set last_pushed_hash + // so the very next poll skips applying it. + let last_pushed_hash = ''; + let is_pushing = false; + + async function pollState() { + if (is_pushing) { + return; + } + if (typeof fetch === 'undefined') { + return; + } + try { + const res = await fetch(RELAY_ENDPOINT, { cache: 'no-store' }); + if (!res.ok) { + return; + } + const data = await res.json(); + const hash = JSON.stringify(data); + if (hash !== last_pushed_hash) { + last_pushed_hash = hash; + applyRemote(data); + } + } catch { + // Network error — silently retry next interval. + } + } + + async function pushState(snapshot) { + if (typeof fetch === 'undefined') { + return; + } + is_pushing = true; + try { + last_pushed_hash = JSON.stringify(snapshot); + await fetch(RELAY_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot), + }); + } catch { + // Network error — local state still applied; remote may diverge + // until next successful push. + } finally { + is_pushing = false; + } + } + + pollState(); + setInterval(pollState, RELAY_POLL_INTERVAL_MS); + + let persist_timer = null; + function schedulePersist() { + if (persist_timer) { + clearTimeout(persist_timer); + } + persist_timer = setTimeout(persistNow, LOCALSTORAGE_DEBOUNCE_MS); + } + + function persistNow() { + persist_timer = null; + if (typeof localStorage === 'undefined') { + return; + } + try { + localStorage.setItem( + LOCALSTORAGE_KEY, + JSON.stringify({ + active_slug: active_slug.value, + sidebar_open: sidebar_open.value, + }), + ); + } catch { + // Storage quota / disabled — silently degrade. + } + } + + function broadcastSnapshot() { + const snapshot = { + active_slug: active_slug.value, + sidebar_open: sidebar_open.value, + }; + channel.postMessage(snapshot); + pushState(snapshot); + } + + function setActiveSlug(slug) { + active_slug.value = slug; + broadcastSnapshot(); + } + + function toggleSidebar() { + sidebar_open.value = !sidebar_open.value; + broadcastSnapshot(); + } + + function hideSidebar() { + sidebar_open.value = false; + broadcastSnapshot(); + } + + const scope = effectScope(true); + scope.run(() => { + watch([active_slug, sidebar_open], schedulePersist); + watch(sidebar_open, (open) => applyDocumentClass(open)); + }); + + applyDocumentClass(sidebar_open.value); + + shared_state = { + active_slug, + sidebar_open, + setActiveSlug, + toggleSidebar, + hideSidebar, + }; + return shared_state; +} + +function createChannel() { + if (typeof BroadcastChannel === 'undefined') { + return { postMessage: () => {}, onmessage: null, close: () => {} }; + } + return new BroadcastChannel(CHANNEL_NAME); +} + +function readPersistedState() { + if (typeof localStorage === 'undefined') { + return null; + } + try { + const raw = localStorage.getItem(LOCALSTORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + if (typeof parsed === 'object' && parsed !== null) { + return parsed; + } + return null; + } catch { + return null; + } +} + +function applyDocumentClass(open) { + if (typeof document === 'undefined') { + return; + } + const root = document.documentElement; + if (!root || !root.classList) { + return; + } + root.classList.toggle(SIDEBAR_OPEN_CLASS, open); +} diff --git a/src/shared/utils/highlight.js b/src/shared/utils/highlight.js new file mode 100644 index 0000000..3f857b0 --- /dev/null +++ b/src/shared/utils/highlight.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2026 Cristian D. Moreno — @Kyonax + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ + * + * highlight — async wrapper around Shiki for syntax-tokenizing code + * blocks rendered inside . Returns a 2-D token array + * ([line][token]) with `content` + `color` per token. Consumers + * render tokens through Vue templates as entries — + * never via v-html (D11). Cache keyed by `lang::code` so the same + * block isn't re-tokenized. + * + * Falls back to `null` when the language is not in the supported + * set OR when Shiki throws — consumers then render plain text. + */ + +import { codeToTokens } from 'shiki'; + +const SUPPORTED_LANGUAGES = new Set([ + 'js', + 'javascript', + 'ts', + 'typescript', + 'vue', + 'html', + 'css', + 'scss', + 'json', + 'bash', + 'sh', + 'shell', + 'python', + 'py', + 'markdown', + 'md', + 'yaml', + 'yml', + 'diff', +]); + +const SHIKI_THEME = 'tokyo-night'; + +const cache = new Map(); + +export async function highlightCode(code, language) { + if (typeof code !== 'string' || code.length === 0) { + return null; + } + const lang = (language || '').toLowerCase().trim(); + if (!SUPPORTED_LANGUAGES.has(lang)) { + return null; + } + const key = `${lang}::${code}`; + if (cache.has(key)) { + return cache.get(key); + } + try { + const result = await codeToTokens(code, { + lang, + theme: SHIKI_THEME, + }); + cache.set(key, result.tokens); + return result.tokens; + } catch { + cache.set(key, null); + return null; + } +} diff --git a/src/shared/utils/org.js b/src/shared/utils/org.js new file mode 100644 index 0000000..99edbb8 --- /dev/null +++ b/src/shared/utils/org.js @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2026 Cristian D. Moreno — @Kyonax + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ + * + * org — topic library (Rule J) for parsing RECKIT context .org files. + * Wraps uniorg-parse to produce a unified AST, then partitions the + * top-level nodes into metadata (title, subtitle, description, tags), + * a marquee items array (extracted from a `#+begin_marquee` special + * block), and a body AST (everything else, rendered in the sidebar + * via per D11 — never via v-html). + * + * Required keys: TITLE, DESCRIPTION. Missing either throws + * `OrgSchemaError` so a malformed .org surfaces a parser-error + * indicator on the corresponding card (D7). + * + * Schema lock: see plan node Plan #context-screen, decisions D7 + D11. + */ + +import { unified } from 'unified'; +import uniorgParse from 'uniorg-parse'; + +const MARQUEE_BLOCK_NAME = 'marquee'; +const REQUIRED_KEYS = ['TITLE', 'DESCRIPTION']; +const TAG_KEYS = ['FILETAGS', 'TAGS']; +const TAG_DELIMITER = ':'; + +const processor = unified().use(uniorgParse); + +export class OrgSchemaError extends Error { + constructor(message, missing_keys = []) { + super(message); + this.name = 'OrgSchemaError'; + this.missing_keys = missing_keys; + } +} + +export function parseOrg(raw_string) { + const ast = processor.parse(raw_string); + + const required_values = {}; + const missing = []; + for (const key of REQUIRED_KEYS) { + const value = extractMetaKey(ast, key); + if (value === null) { + missing.push(key); + } else { + required_values[key] = value; + } + } + if (missing.length > 0) { + throw new OrgSchemaError( + `Required org keyword(s) missing: ${missing.join(', ')}`, + missing, + ); + } + + return { + title: required_values.TITLE, + subtitle: extractMetaKey(ast, 'SUBTITLE'), + description: required_values.DESCRIPTION, + tags: extractFiletags(ast), + marquee_items: extractMarqueeBlock(ast), + body_ast: collectBodyNodes(ast), + }; +} + +export function extractMetaKey(ast, key) { + if (!ast || !Array.isArray(ast.children)) { + return null; + } + for (const node of ast.children) { + if (node.type === 'keyword' && node.key === key) { + return typeof node.value === 'string' ? node.value : null; + } + } + return null; +} + +export function extractFiletags(ast) { + if (!ast || !Array.isArray(ast.children)) { + return []; + } + for (const node of ast.children) { + if (node.type !== 'keyword') { + continue; + } + if (!TAG_KEYS.includes(node.key)) { + continue; + } + const raw_value = typeof node.value === 'string' ? node.value : ''; + return raw_value + .split(TAG_DELIMITER) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + } + return []; +} + +export function extractMarqueeBlock(ast) { + if (!ast || !Array.isArray(ast.children)) { + return []; + } + for (const node of ast.children) { + if ( + node.type === 'special-block' + && node.blockType === MARQUEE_BLOCK_NAME + ) { + return collectTextLines(node); + } + } + return []; +} + +export function collectBodyNodes(ast) { + if (!ast || !Array.isArray(ast.children)) { + return []; + } + const body = []; + for (const node of ast.children) { + if (node.type === 'keyword') { + continue; + } + if ( + node.type === 'special-block' + && node.blockType === MARQUEE_BLOCK_NAME + ) { + continue; + } + body.push(node); + } + return body; +} + +function collectTextLines(node) { + const buffer = []; + walkText(node, buffer); + return buffer + .join('') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function walkText(node, buffer) { + if (!node) { + return; + } + if (node.type === 'text' && typeof node.value === 'string') { + buffer.push(node.value); + return; + } + if (Array.isArray(node.children)) { + for (const child of node.children) { + walkText(child, buffer); + } + } +} diff --git a/src/shared/utils/org.test.js b/src/shared/utils/org.test.js new file mode 100644 index 0000000..0cac3d7 --- /dev/null +++ b/src/shared/utils/org.test.js @@ -0,0 +1,184 @@ +/** + * Copyright (c) 2026 Cristian D. Moreno — @Kyonax + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ + * + * Tests for the org topic library: required-field enforcement, + * metadata extraction, filetag parsing, marquee block extraction, + * body partitioning. Covers Plan #context-screen Q5 schema lock. + */ + +import { + collectBodyNodes, + extractFiletags, + extractMarqueeBlock, + extractMetaKey, + OrgSchemaError, + parseOrg, +} from '@shared/utils/org.js'; +import { unified } from 'unified'; +import uniorgParse from 'uniorg-parse'; +import { describe, expect, it } from 'vitest'; + +const FULL_FIXTURE = `#+TITLE: Hello +#+SUBTITLE: A second line +#+DESCRIPTION: Short description for the lower-third strip. +#+TAGS: :alpha:beta:gamma: + +#+begin_marquee +First item +Second item +Third item +#+end_marquee + +* Section A + +A paragraph with content. + +#+begin_src js +const x = 1; +#+end_src +`; + +const MIN_FIXTURE = `#+TITLE: Quick Note +#+DESCRIPTION: Minimal context — no marquee, no body. +`; + +const MISSING_DESCRIPTION_FIXTURE = `#+TITLE: Only Title +`; + +const MISSING_BOTH_FIXTURE = `#+TAGS: :nope: +`; + +const NO_MARQUEE_FIXTURE = `#+TITLE: A +#+DESCRIPTION: B +* Body section +Content paragraph. +`; + +const FILETAGS_FIXTURE = `#+TITLE: A +#+DESCRIPTION: B +#+FILETAGS: :tag1:tag2: +`; + +const EXPECTED_TAG_COUNT = 3; +const EXPECTED_MARQUEE_COUNT = 3; + +function parseRaw(raw_string) { + return unified().use(uniorgParse).parse(raw_string); +} + +describe('parseOrg — happy path', () => { + it('extracts every locked Q5 schema field from the full fixture', () => { + const result = parseOrg(FULL_FIXTURE); + expect(result.title).toBe('Hello'); + expect(result.subtitle).toBe('A second line'); + expect(result.description).toBe( + 'Short description for the lower-third strip.', + ); + expect(result.tags).toHaveLength(EXPECTED_TAG_COUNT); + expect(result.tags).toEqual(['alpha', 'beta', 'gamma']); + expect(result.marquee_items).toHaveLength(EXPECTED_MARQUEE_COUNT); + expect(result.marquee_items[0]).toBe('First item'); + expect(Array.isArray(result.body_ast)).toBe(true); + expect(result.body_ast.length).toBeGreaterThan(0); + }); + + it('returns null subtitle + empty arrays for the minimal fixture', () => { + const result = parseOrg(MIN_FIXTURE); + expect(result.title).toBe('Quick Note'); + expect(result.subtitle).toBe(null); + expect(result.tags).toEqual([]); + expect(result.marquee_items).toEqual([]); + }); +}); + +describe('parseOrg — error paths', () => { + it('throws OrgSchemaError when DESCRIPTION is missing', () => { + expect(() => parseOrg(MISSING_DESCRIPTION_FIXTURE)).toThrow( + OrgSchemaError, + ); + }); + + it('throws OrgSchemaError when both required keys are missing', () => { + let thrown = null; + try { + parseOrg(MISSING_BOTH_FIXTURE); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(OrgSchemaError); + expect(thrown.missing_keys).toEqual(['TITLE', 'DESCRIPTION']); + }); +}); + +describe('extractMetaKey', () => { + it('returns the keyword value when present', () => { + const ast = parseRaw('#+TITLE: Hello\n'); + expect(extractMetaKey(ast, 'TITLE')).toBe('Hello'); + }); + + it('returns null for an absent key', () => { + const ast = parseRaw('#+TITLE: Hello\n'); + expect(extractMetaKey(ast, 'SUBTITLE')).toBe(null); + }); + + it('handles a null AST gracefully', () => { + expect(extractMetaKey(null, 'TITLE')).toBe(null); + }); +}); + +describe('extractFiletags', () => { + it('parses the colon-delimited TAGS form', () => { + const ast = parseRaw('#+TAGS: :a:b:c:\n'); + expect(extractFiletags(ast)).toEqual(['a', 'b', 'c']); + }); + + it('also recognises FILETAGS', () => { + const ast = parseRaw(FILETAGS_FIXTURE); + expect(extractFiletags(ast)).toEqual(['tag1', 'tag2']); + }); + + it('returns an empty array when no tag keyword is present', () => { + const ast = parseRaw('#+TITLE: A\n'); + expect(extractFiletags(ast)).toEqual([]); + }); +}); + +describe('extractMarqueeBlock', () => { + it('returns the items split by newline, trimmed, no empties', () => { + const ast = parseRaw(FULL_FIXTURE); + const items = extractMarqueeBlock(ast); + expect(items).toEqual(['First item', 'Second item', 'Third item']); + }); + + it('returns an empty array when no marquee block is present', () => { + const ast = parseRaw(NO_MARQUEE_FIXTURE); + expect(extractMarqueeBlock(ast)).toEqual([]); + }); +}); + +describe('collectBodyNodes', () => { + it('filters out top-level keywords', () => { + const ast = parseRaw(FULL_FIXTURE); + const body = collectBodyNodes(ast); + expect(body.every((node) => node.type !== 'keyword')).toBe(true); + }); + + it('filters out the marquee special-block', () => { + const ast = parseRaw(FULL_FIXTURE); + const body = collectBodyNodes(ast); + expect( + body.every( + (node) => + !(node.type === 'special-block' && node.blockType === 'marquee'), + ), + ).toBe(true); + }); + + it('preserves headlines, sections, paragraphs, src-blocks', () => { + const ast = parseRaw(FULL_FIXTURE); + const body = collectBodyNodes(ast); + expect(body.length).toBeGreaterThan(0); + }); +}); diff --git a/src/views/components/elements/card.vue b/src/views/components/elements/card.vue index bbc36a7..b3c54b8 100644 --- a/src/views/components/elements/card.vue +++ b/src/views/components/elements/card.vue @@ -153,14 +153,26 @@ - +
+ + + +
+ + + + diff --git a/src/views/components/modals/preview.vue b/src/views/components/modals/preview.vue index 1a39856..09d994c 100644 --- a/src/views/components/modals/preview.vue +++ b/src/views/components/modals/preview.vue @@ -236,7 +236,7 @@ onUnmounted(() => { .modal-stage { padding: 1em; - background: rgba(255, 255, 255, 0.04); + background: var(--clr-neutral-50-04); } .stage-inner { @@ -297,7 +297,7 @@ onUnmounted(() => { .action-button:hover { color: var(--clr-primary-100); border-color: var(--clr-primary-100); - background: rgba(255, 215, 0, 0.04); + background: var(--clr-primary-100-04); } .action-icon { diff --git a/src/views/components/sections/setup.vue b/src/views/components/sections/setup.vue index c0da439..e2f8956 100644 --- a/src/views/components/sections/setup.vue +++ b/src/views/components/sections/setup.vue @@ -94,7 +94,7 @@ } .setup-flow li:hover { - background: rgba(255, 215, 0, 0.04); + background: var(--clr-primary-100-04); color: var(--clr-neutral-50); } diff --git a/src/views/components/sections/sources.vue b/src/views/components/sections/sources.vue index cc86ded..d5490d0 100644 --- a/src/views/components/sections/sources.vue +++ b/src/views/components/sections/sources.vue @@ -182,12 +182,12 @@ const filtered_overlays = computed(() => { .brand-tab:hover { color: var(--clr-neutral-50); - background: rgba(255, 215, 0, 0.03); + background: var(--clr-primary-100-03); } .brand-tab.active { color: var(--clr-primary-100); - background: rgba(255, 215, 0, 0.06); + background: var(--clr-primary-100-06); border-color: var(--clr-border-100); border-bottom: 1px solid var(--clr-neutral-500); } @@ -325,12 +325,12 @@ const filtered_overlays = computed(() => { .status-chip:hover { color: var(--clr-neutral-50); - background: rgba(255, 255, 255, 0.04); + background: var(--clr-neutral-50-04); } .status-chip.active { color: var(--clr-primary-100); - background: rgba(255, 215, 0, 0.06); + background: var(--clr-primary-100-06); } .chip-dot { diff --git a/vite.config.js b/vite.config.js index bb0a4e5..3f85694 100644 --- a/vite.config.js +++ b/vite.config.js @@ -51,6 +51,71 @@ const pkg = require('./package.json'); const ROOT = dirname(fileURLToPath(import.meta.url)); const DEV_SERVER_PORT = 5173; +// Cross-process bridge for the context-screen control plane. +// OBS browser source runs in its own embedded Chromium (CEF) process — +// BroadcastChannel can't reach across processes, and Vite HMR custom +// events proved unreliable in CEF (silent failure of the WebSocket +// connection or the import.meta.hot handoff). HTTP polling + push is +// universal: every browser process can fetch + POST the same endpoint. +// +// State is held in a closure on the dev server. GET returns current +// state; POST replaces it. Composable polls at POLL_INTERVAL_MS for +// freshness; pushes on every local action. ~300 ms p95 cross-process +// latency, debuggable with `curl http://localhost:5173/__context_state`. +const CONTEXT_STATE_PATH = '/__context_state'; +const context_relay_plugin = { + name: 'reckit-context-relay', + configureServer(server) { + let current_state = { active_slug: null, sidebar_open: false }; + + server.middlewares.use(CONTEXT_STATE_PATH, (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + if (req.method === 'GET') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(current_state)); + return; + } + + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed === 'object') { + current_state = parsed; + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 400; + res.end('invalid payload'); + } catch { + res.statusCode = 400; + res.end('invalid json'); + } + }); + return; + } + + res.statusCode = 405; + res.end(); + }); + }, +}; + export default defineConfig({ resolve: { alias: { @@ -69,7 +134,7 @@ export default defineConfig({ '@composables': resolve(ROOT, 'src/shared/composables'), }, }, - plugins: [vue()], + plugins: [vue(), context_relay_plugin], define: { __APP_VERSION__: JSON.stringify(pkg.version), },