From 444030b3c6805b2df4279c8954ac0ac012176639 Mon Sep 17 00:00:00 2001 From: "Cristian D. Moreno (Kyonax)" Date: Thu, 23 Apr 2026 01:31:44 -0500 Subject: [PATCH] feat(kot): brand refinement + OBS FPS perf pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine @kyonax_on_tech components and land a targeted perf pass to restore OBS Browser Source fps on low-core hardware. - cam-log: .hud-group layout; brand.js gains host + region; session-date now `${region} ∇ DD.MM.YYYY // ddd` - remove cyberpunk-glow mixin (root cause of FPS drop); halo/glow reborn as opt-in :root tokens (--hud-halo, --hud-halo-text, --hud-glow) - useRecordingStatus / useSceneName / useAudioAnalyzer → module-level singletons (one WS handler per event per page) - audio analyzer: preallocated Float32Array + 256-entry JITTER_TABLE, event-driven off InputVolumeMeters, no rAF - AudioMeter: direct DOM writes via template refs, quantized SCALE_STRINGS, write-threshold skip, ~10Hz emit, transform:scaleY() over animated height - contain: layout paint on every frequently-updating HUD sub-tree - UiStatusDot: static halo shell + animated-opacity .glow layer (dark shadow stays cached) - preview modal: 100ms resize debounce; consume_trigger → consume-trigger (kebab-case) - neutral-200 → neutral-100 across secondary text - +composables.test.js: 6 singleton-contract tests (27 → 33) Modified-by: Cristian D. Moreno (Kyonax) --- @kyonax_on_tech/brand.js | 3 + @kyonax_on_tech/sources/hud/cam-log.vue | 201 ++++++++++-------- @kyonax_on_tech/styles/_theme.scss | 4 - eslint.config.mjs | 24 --- package-lock.json | 7 + package.json | 1 + src/App.vue | 4 - src/app/scss/abstracts/_mixins.scss | 47 +--- src/app/scss/abstracts/_theme.scss | 30 ++- src/app/scss/base/_global.scss | 4 - src/app/scss/components/_index.scss | 1 - src/app/scss/layout/_index.scss | 1 - src/main.js | 5 - src/shared/brand-loader.js | 8 - src/shared/components/hud/frame.vue | 15 +- src/shared/components/hud/timer.vue | 22 +- src/shared/components/ui/badge.vue | 4 +- src/shared/components/ui/data-point.vue | 3 + src/shared/components/ui/status-dot.vue | 35 ++- src/shared/composables/composables.test.js | 81 +++++++ src/shared/composables/use-audio-analyzer.js | 184 ++++++---------- src/shared/composables/use-obs-websocket.js | 6 - .../composables/use-recording-status.js | 41 ++-- src/shared/composables/use-scene-name.js | 41 ++-- src/shared/version.js | 2 - src/shared/widgets/hud/audio-meter.vue | 120 ++++++----- src/shared/widgets/ui/live-readout.vue | 4 +- src/views/components/elements/card.vue | 4 +- src/views/components/modals/preview.vue | 32 +-- src/views/components/sections/footer.vue | 2 +- src/views/components/sections/sources.vue | 10 +- src/views/utils/markup.js | 7 - vite.config.js | 3 - 33 files changed, 482 insertions(+), 474 deletions(-) create mode 100644 src/shared/composables/composables.test.js diff --git a/@kyonax_on_tech/brand.js b/@kyonax_on_tech/brand.js index 0b73c21..4b6153c 100644 --- a/@kyonax_on_tech/brand.js +++ b/@kyonax_on_tech/brand.js @@ -16,6 +16,9 @@ export default { description: 'Cyberpunk-themed stream overlays for tech content creation.', + host: 'KYO-LABS', + region: 'COL', + identity: { author: 'Cristian D. Moreno', display_handle: '@kyonax_on_tech', diff --git a/@kyonax_on_tech/sources/hud/cam-log.vue b/@kyonax_on_tech/sources/hud/cam-log.vue index 8d0b60b..0ba396c 100644 --- a/@kyonax_on_tech/sources/hud/cam-log.vue +++ b/@kyonax_on_tech/sources/hud/cam-log.vue @@ -10,31 +10,45 @@ @@ -77,8 +80,12 @@ import { getBrand } from '@shared/brand-loader.js'; import { VERSION_TAG } from '@shared/version.js'; import AudioMeter from '@widgets/hud/audio-meter.vue'; import LiveReadout from '@widgets/ui/live-readout.vue'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; import { computed, ref } from 'vue'; +dayjs.extend(utc); + const brand = getBrand('@kyonax_on_tech'); const CANVAS_WIDTH = 1920; @@ -86,19 +93,13 @@ const CANVAS_HEIGHT = 1080; const TAKE_PAD_LENGTH = 2; const DEBUG_REFRESH_MS = 200; -const { obs, connected } = useObsWebsocket(); +const { connected } = useObsWebsocket(); const { is_recording, elapsed_time, take_count, -} = useRecordingStatus({ - obs, - connected, -}); -const { scene_name } = useSceneName({ - obs, - connected, -}); +} = useRecordingStatus(); +const { scene_name } = useSceneName(); const audio_state = ref({ active: false, @@ -106,16 +107,15 @@ const audio_state = ref({ peak: 0, }); -const hud_labels = computed(() => { - const name = scene_name.value || '---'; - const take = String(take_count.value) - .padStart(TAKE_PAD_LENGTH, '0'); - return { - top_left: 'SYS.LOG', - top_right: `SES::${name}::T${take}`, - bottom_left: '', - bottom_right: '', - }; +const session_date = dayjs() + .utc() + .format(`[${brand.region} ∇ ]DD.MM.YYYY[ // ]ddd`) + .toUpperCase(); + +const session_id = computed(() => { + const name = (scene_name.value || '---').toUpperCase(); + const take = String(take_count.value).padStart(TAKE_PAD_LENGTH, '0'); + return `${name}::T${take}`; }); const debug_text = computed(() => { @@ -123,7 +123,7 @@ const debug_text = computed(() => { const audio = audio_state.value.active ? audio_state.value.source : 'NONE'; - return `WS:${ws} | AUDIO:${audio} | L0:${audio_state.value.peak}`; + return `WS:${ws} | AUDIO:${audio} | L0:${audio_state.value.peak.toFixed(3)}`; }); @@ -137,64 +137,92 @@ const debug_text = computed(() => { background: transparent; } -.hud-label { - @include hud-label-base; - opacity: 0.5; +.dynamic-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.hud-group { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--hud-group-gap); + contain: layout paint; } -.rec-frame { - top: 4.5em; +.group--top-left { + top: 3em; left: 4em; - color: var(--clr-primary-100); - opacity: 0.8; + align-items: flex-start; } -.cam-online { - top: 4.5em; +.group--top-right { + top: 3em; right: 4em; - color: var(--clr-primary-100); - opacity: 0.8; + align-items: flex-end; + text-align: right; } -.identity-block { - position: absolute; +.group--bottom-left { bottom: 3em; left: 4em; - display: flex; - flex-direction: column; - gap: 0.25em; + align-items: flex-start; } -.identity-name { - @include hud-label-base; - position: static; - font-size: var(--fs-425); - opacity: 0.4; +.group--identity { + gap: calc(var(--hud-group-gap) / 2); +} + +.hud-text { + @include hud-text-base; + opacity: 0.7; } -.identity-handle { - @include hud-label-base; - position: static; - letter-spacing: 1px; +.hud-text--primary { color: var(--clr-primary-100); - opacity: 0.8; + text-shadow: var(--hud-glow); + opacity: 1; +} + +.session-date, +.cam-online { + font-size: var(--fs-425); +} + +.bracket { + display: inline-block; + transform: translateY(-0.12em); } .toolkit-id { - bottom: 4.5em; + position: absolute; + bottom: 3em; right: 4em; - font-size: var(--fs-300); + font-size: var(--fs-350); letter-spacing: 3px; - opacity: 0.4; + opacity: 0.7; } .offline { + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: var(--fs-500); - color: var(--clr-neutral-200); letter-spacing: 4px; + opacity: 1; +} + +.status-bar { + position: absolute; + bottom: var(--hud-bar-offset); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: flex-end; + gap: var(--hud-bar-gap); + contain: layout paint; } .debug-info { @@ -206,6 +234,8 @@ const debug_text = computed(() => { letter-spacing: 2px; text-transform: uppercase; opacity: 0.6; + text-shadow: var(--hud-halo-text), var(--hud-glow); + contain: layout paint; } .crosshair { @@ -219,7 +249,8 @@ const debug_text = computed(() => { .crosshair-h, .crosshair-v { position: absolute; - background: var(--clr-border-100); + background: var(--clr-neutral-100); + opacity: 0.4; } .crosshair-h { @@ -235,14 +266,4 @@ const debug_text = computed(() => { top: calc(var(--arm-length) / -2); left: 0; } - -.status-bar { - position: absolute; - bottom: var(--hud-bar-offset); - left: 50%; - transform: translateX(-50%); - display: flex; - align-items: flex-end; - gap: var(--hud-bar-gap); -} diff --git a/@kyonax_on_tech/styles/_theme.scss b/@kyonax_on_tech/styles/_theme.scss index 3ea056c..7222863 100644 --- a/@kyonax_on_tech/styles/_theme.scss +++ b/@kyonax_on_tech/styles/_theme.scss @@ -19,7 +19,6 @@ // into dashes), and tune any subset of variables it wants to override. .brand-kyonax-on-tech { - // ── Primary (gold) ────────────────────────────────────────── --clr-primary-50: hsl(48, 95%, 78%); --clr-primary-100: hsl(47, 95%, 56%); --clr-primary-200: hsl(49, 100%, 39%); @@ -27,12 +26,10 @@ --clr-primary-400: hsl(45, 100%, 20%); --clr-primary-500: hsl(35, 100%, 13%); - // ── Secondary (blue accent) ───────────────────────────────── --clr-secondary-50: hsl(224, 95%, 78%); --clr-secondary-100: hsl(224, 95%, 56%); --clr-secondary-200: hsl(224, 74%, 45%); - // ── Neutrals (black → white) ──────────────────────────────── --clr-neutral-50: hsl(0, 0%, 95%); --clr-neutral-100: hsl(0, 0%, 85%); --clr-neutral-200: hsl(0, 0%, 65%); @@ -40,7 +37,6 @@ --clr-neutral-400: hsl(0, 0%, 25%); --clr-neutral-500: hsl(0, 0%, 0%); - // ── Status colors ──────────────────────────────────────────── --clr-error-100: hsl(352, 69%, 48%); --clr-success-100: hsl(91, 62%, 44%); } diff --git a/eslint.config.mjs b/eslint.config.mjs index 3c18256..07cf12a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,7 +63,6 @@ import vue from 'eslint-plugin-vue'; import globals from 'globals'; export default [ - // ── Global ignores ─────────────────────────────────────────── { ignores: [ 'dist/**', @@ -73,10 +72,8 @@ export default [ ], }, - // ── Base: ESLint recommended ───────────────────────────────── js.configs.recommended, - // ── Main ruleset (browser JS) ──────────────────────────────── { files: ['**/*.{js,mjs}'], @@ -103,7 +100,6 @@ export default [ }, rules: { - // ── Naming conventions (CCS standards) ───────────────── '@typescript-eslint/naming-convention': [ 'error', { @@ -160,20 +156,17 @@ export default [ }, ], - // ── Import ordering ──────────────────────────────────── 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', 'import/first': 'error', 'import/newline-after-import': 'error', 'import/no-duplicates': 'error', - // ── Filename conventions ───────────────────────────────── 'unicorn/filename-case': [ 'error', { case: 'kebabCase' }, ], - // ── Code quality ─────────────────────────────────────── 'no-console': 'warn', 'eqeqeq': ['error', 'always'], 'no-var': 'error', @@ -207,20 +200,17 @@ export default [ 'prefer-arrow-callback': 'error', 'object-shorthand': ['error', 'always'], - // ── Formatting ───────────────────────────────────────── 'comma-dangle': ['error', 'always-multiline'], 'keyword-spacing': ['error', { before: true, after: true }], 'space-in-parens': ['error', 'never'], 'object-curly-spacing': ['error', 'always'], 'eol-last': ['error', 'always'], - // ── Security: explicit dangerous-pattern bans ────────── 'no-eval': 'error', 'no-implied-eval': 'error', 'no-new-func': 'error', 'no-script-url': 'error', - // ── Security plugin rules ────────────────────────────── 'security/detect-eval-with-expression': 'error', 'security/detect-non-literal-regexp': 'warn', 'security/detect-object-injection': 'warn', @@ -230,7 +220,6 @@ export default [ 'security/detect-no-csrf-before-method-override': 'error', 'security/detect-possible-timing-attacks': 'warn', - // ── innerHTML ban (prefer textContent) ───────────────── 'no-restricted-properties': [ 'error', { @@ -250,7 +239,6 @@ export default [ }, ], - // ── Unicorn extras ───────────────────────────────────── 'unicorn/no-array-for-each': 'warn', 'unicorn/prefer-query-selector': 'error', 'unicorn/prefer-dom-node-append': 'error', @@ -260,13 +248,11 @@ export default [ 'unicorn/prefer-modern-dom-apis': 'error', 'unicorn/prefer-number-properties': 'error', - // ── JSDoc ────────────────────────────────────────────── 'jsdoc/require-jsdoc': 'off', 'jsdoc/check-alignment': 'warn', }, }, - // ── Test file overrides ────────────────────────────────────── { files: [ '**/*.test.{js,mjs}', @@ -278,8 +264,6 @@ export default [ sourceType: 'module', globals: { ...globals.browser, - // Vitest globals (enabled via `test.globals: true` in - // vite.config.js — no import needed inside tests). describe: 'readonly', it: 'readonly', test: 'readonly', @@ -298,11 +282,9 @@ export default [ }, }, - // ── Vue SFC base (plugin, processor, essential rules) ──────── ...vue.configs['flat/essential'], ...vue.configs['flat/strongly-recommended'], - // ── Vue SFC overrides (project-specific rules) ────────────── { files: ['**/*.vue'], @@ -329,7 +311,6 @@ export default [ }, rules: { - // ── Vue rule overrides ───────────────────────────────── 'vue/multi-word-component-names': 'off', 'vue/html-self-closing': 'off', 'vue/html-indent': ['warn', 2], @@ -338,7 +319,6 @@ export default [ 'vue/prop-name-casing': 'off', 'vue/valid-define-props': 'off', - // ── Naming conventions (CCS standards) ───────────────── '@typescript-eslint/naming-convention': [ 'error', { @@ -395,14 +375,12 @@ export default [ }, ], - // ── Import ordering ──────────────────────────────────── 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', 'import/first': 'error', 'import/newline-after-import': 'error', 'import/no-duplicates': 'error', - // ── Code quality (same as JS ruleset) ────────────────── 'no-console': 'warn', 'eqeqeq': ['error', 'always'], 'no-var': 'error', @@ -422,7 +400,6 @@ export default [ }, ], - // ── Security ─────────────────────────────────────────── 'no-eval': 'error', 'no-implied-eval': 'error', 'no-new-func': 'error', @@ -430,7 +407,6 @@ export default [ 'security/detect-object-injection': 'warn', 'security/detect-unsafe-regex': 'error', - // ── Unicorn ──────────────────────────────────────────── 'unicorn/filename-case': [ 'error', { diff --git a/package-lock.json b/package-lock.json index 13b9ebe..0f07bb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.3.0", "license": "(MPL-2.0 OR Apache-2.0)", "dependencies": { + "dayjs": "^1.11.20", "obs-websocket-js": "^5.0.6", "vue": "^3.5.13", "vue-router": "^4.5.0" @@ -2846,6 +2847,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 84743b6..84acb83 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "dayjs": "^1.11.20", "obs-websocket-js": "^5.0.6", "vue": "^3.5.13", "vue-router": "^4.5.0" diff --git a/src/App.vue b/src/App.vue index 746469a..730b61d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,10 +16,6 @@ import { useRoute } from 'vue-router'; const route = useRoute(); -// Brand theme is applied via a class selector — the actual CSS -// custom property overrides live in `@/styles/_theme.scss`, -// auto-loaded by src/main.js. This component only announces the -// brand context; the CSS cascade does the rest. const brand_class = computed(() => { const handle = route.meta?.brand; diff --git a/src/app/scss/abstracts/_mixins.scss b/src/app/scss/abstracts/_mixins.scss index 304cd37..cf8d41b 100644 --- a/src/app/scss/abstracts/_mixins.scss +++ b/src/app/scss/abstracts/_mixins.scss @@ -5,7 +5,6 @@ */ @use "sass:map"; -@use "sass:math"; @use "variables" as *; @mixin font-face($name, $path, $weight: normal, $style: normal) { @@ -25,50 +24,16 @@ } } -@mixin max-media-query($key) { - $size: map.get($breakpoints, $key); - - @media only screen and (max-width: $size) { - @content; - } -} - -@mixin hud-label-base { - position: absolute; +@mixin hud-text-base { font-family: var(--font-mono); font-size: var(--fs-475); text-transform: uppercase; letter-spacing: 2px; - color: var(--clr-neutral-50); + color: var(--clr-neutral-100); } -@mixin cyberpunk-glow( - $glow-color: hsl(47, 95%, 56%), - $blur: 15px, - $spread: 5px, - $animated: true, - $speed: 1.5s, - $pulse-variation: 0.2 -) { - box-shadow: 0 0 $spread $blur $glow-color; - - @if $animated { - $index: math.floor(calc(math.random() * 1000000)); - - animation: cyberpunk-pulse-#{$index} $speed ease-in-out infinite alternate; - - @keyframes cyberpunk-pulse-#{$index} { - 0% { - box-shadow: 0 0 calc(#{$spread} / 2) calc(#{$blur} / 2) $glow-color; - } - - 50% { - box-shadow: 0 0 calc(#{$spread} * (1.5 + $pulse-variation)) calc(#{$blur} * (1.5 + $pulse-variation)) $glow-color; - } - - 100% { - box-shadow: 0 0 calc(#{$spread} / 2) calc(#{$blur} / 2) $glow-color; - } - } - } +@mixin hud-label-base { + @include hud-text-base; + position: absolute; } + diff --git a/src/app/scss/abstracts/_theme.scss b/src/app/scss/abstracts/_theme.scss index adc042b..6ff67aa 100644 --- a/src/app/scss/abstracts/_theme.scss +++ b/src/app/scss/abstracts/_theme.scss @@ -40,6 +40,26 @@ --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); + --clr-primary-100-40: color-mix(in srgb, var(--clr-primary-100) 40%, transparent); + + --hud-halo: + drop-shadow(0 0 1px rgba(0, 0, 0, 0.9)) + drop-shadow(0 0 3px rgba(0, 0, 0, 0.6)) + drop-shadow(0 0 6px rgba(0, 0, 0, 0.45)); + + --hud-halo-text: + 0 0 1px rgba(0, 0, 0, 0.9), + 0 0 3px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(0, 0, 0, 0.45); + + --hud-glow-color: var(--clr-primary-100); + --hud-glow: + 0 0 1px color-mix(in srgb, var(--hud-glow-color) 75%, transparent), + 0 0 5px color-mix(in srgb, var(--hud-glow-color) 50%, transparent); + + --hud-group-gap: 0.6em; } ::selection { @@ -51,13 +71,3 @@ background-color: var(--clr-primary-100); color: var(--clr-neutral-500); } - -.cyberpunk-glow { - @include cyberpunk-glow( - var(--cyberpunk-glow-color, var(--clr-primary-100)), - var(--cyberpunk-glow-blur, 1px), - var(--cyberpunk-glow-spread, 9px), - true, - var(--cyberpunk-glow-speed, 1.5s) - ); -} diff --git a/src/app/scss/base/_global.scss b/src/app/scss/base/_global.scss index c5087bd..5a6379c 100644 --- a/src/app/scss/base/_global.scss +++ b/src/app/scss/base/_global.scss @@ -14,9 +14,6 @@ box-sizing: border-box; } -/* 12px base: OBS overlay targets 1920x1080 with tighter - type scale than browser default (16px). Changing this - rescales all rem-based sizing. */ html { font-size: 12px; } @@ -36,7 +33,6 @@ body { min-height: 100vh; } -/* Custom scrollbar — minimal, monochrome */ * { scrollbar-width: thin; scrollbar-color: var(--clr-neutral-300) transparent; diff --git a/src/app/scss/components/_index.scss b/src/app/scss/components/_index.scss index 168fd37..ebd7ff1 100644 --- a/src/app/scss/components/_index.scss +++ b/src/app/scss/components/_index.scss @@ -4,4 +4,3 @@ * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ */ -// Component partials — add as HUD components grow diff --git a/src/app/scss/layout/_index.scss b/src/app/scss/layout/_index.scss index c549462..ebd7ff1 100644 --- a/src/app/scss/layout/_index.scss +++ b/src/app/scss/layout/_index.scss @@ -4,4 +4,3 @@ * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ */ -// Layout partials — add as overlays grow diff --git a/src/main.js b/src/main.js index 1c0efa6..f07b7b3 100644 --- a/src/main.js +++ b/src/main.js @@ -9,11 +9,6 @@ import { createApp } from 'vue'; import App from './App.vue'; import { router } from './router.js'; -// Auto-load every brand's theme SCSS so the global CSS bundle -// carries each brand's `.brand-` selector with its CSS -// custom property overrides. Vite processes every matching file -// eagerly at build time — the brand theme is the single source -// of truth for colors/tokens per brand. import.meta.glob( '/@*/styles/_theme.scss', { eager: true }, diff --git a/src/shared/brand-loader.js b/src/shared/brand-loader.js index da536ba..e93cf2e 100644 --- a/src/shared/brand-loader.js +++ b/src/shared/brand-loader.js @@ -28,18 +28,10 @@ const scene_components = import.meta.glob('/@*/sources/scene/*.vue'); export const BRANDS = Object.values(brand_modules); export const SOURCES = Object.values(source_modules).flat(); -/** - * Get a brand's metadata by handle. - */ export function getBrand(handle) { return BRANDS.find((b) => b.handle === handle) || null; } -/** - * Resolve the lazy component loader for a given source entry. - * Matches source.type + source.brand + source.id to the glob - * results. - */ export function resolveComponent(source) { const type_map = { hud: hud_components, diff --git a/src/shared/components/hud/frame.vue b/src/shared/components/hud/frame.vue index 865f0e3..9e7eaa3 100644 --- a/src/shared/components/hud/frame.vue +++ b/src/shared/components/hud/frame.vue @@ -33,11 +33,6 @@ class="bracket bottom-right" /> -
-
-
-
- diff --git a/src/shared/components/ui/badge.vue b/src/shared/components/ui/badge.vue index eff1166..27db6eb 100644 --- a/src/shared/components/ui/badge.vue +++ b/src/shared/components/ui/badge.vue @@ -48,7 +48,7 @@ const variant_class = computed(() => `ui-badge--${props.variant}`); } .ui-badge--dim { - border-color: var(--clr-neutral-200); - color: var(--clr-neutral-200); + border-color: var(--clr-neutral-100); + color: var(--clr-neutral-100); } diff --git a/src/shared/components/ui/data-point.vue b/src/shared/components/ui/data-point.vue index 05aa4d0..15b3d9e 100644 --- a/src/shared/components/ui/data-point.vue +++ b/src/shared/components/ui/data-point.vue @@ -54,17 +54,20 @@ const size_class = computed(() => `ui-data-point--${props.size}`); display: flex; flex-direction: column; min-width: 0; + contain: layout paint; } .ui-data-point__label { @include hud-label-base; position: static; opacity: 0.5; + text-shadow: var(--hud-halo-text); } .ui-data-point__value { font-family: var(--font-mono); color: var(--clr-primary-100); + text-shadow: var(--hud-halo-text), var(--hud-glow); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; diff --git a/src/shared/components/ui/status-dot.vue b/src/shared/components/ui/status-dot.vue index 9014209..c7fd8ef 100644 --- a/src/shared/components/ui/status-dot.vue +++ b/src/shared/components/ui/status-dot.vue @@ -3,19 +3,28 @@ 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/ - ui-status-dot — small 6x6 square that blinks red when active, + ui-status-dot — small 6x6 square that breathes red when active, renders muted grey when inactive. Name answers "status of what, shown how?": a status indicator shown as a dot. + Composition (perf-driven): the dark 3-layer legibility halo lives on + the root span as a STATIC box-shadow (never animated). The red glow + lives on a ::before-like inner span (`.glow`) that only animates + `opacity` during recording. Keeping the dark layers out of the + @keyframes lets the browser keep them cached — only the red layer's + alpha changes per frame, no shadow re-rasterization. + Props: - active — boolean. When true, dot turns red + blinks. + active — boolean. When true, dot turns red + breathes (soft glow pulse). --> diff --git a/src/shared/widgets/ui/live-readout.vue b/src/shared/widgets/ui/live-readout.vue index e74ea75..6999af7 100644 --- a/src/shared/widgets/ui/live-readout.vue +++ b/src/shared/widgets/ui/live-readout.vue @@ -40,7 +40,9 @@ const displayed = ref(props.text); let interval_id = null; function sync() { - displayed.value = props.text; + if (props.text !== displayed.value) { + displayed.value = props.text; + } } function stopPolling() { diff --git a/src/views/components/elements/card.vue b/src/views/components/elements/card.vue index 4cdad12..bbc36a7 100644 --- a/src/views/components/elements/card.vue +++ b/src/views/components/elements/card.vue @@ -167,7 +167,7 @@ :is_open="is_modal_open" :pending_trigger="pending_trigger" @close="closeModal" - @consume_trigger="pending_trigger = null" + @consume-trigger="pending_trigger = null" /> { }; }); -/** - * Measure the stage width and write the scale factor as a - * CSS custom property. Bypasses Vue reactivity to avoid any - * re-render feedback loop. - */ function applyScale() { const element = stage_el.value; @@ -134,7 +129,7 @@ function applyScale() { return; } - const width = element.clientWidth; + const width = element.getBoundingClientRect().width; const scale = width / props.overlay.width; element.style.setProperty('--iframe-scale', scale); @@ -175,7 +170,7 @@ function onIframeLoad() { if (props.pending_trigger) { setTimeout(() => { fire({ payload: props.pending_trigger }); - emit('consume_trigger'); + emit('consume-trigger'); }, READY_DELAY_MS); } } @@ -189,12 +184,25 @@ watch(() => props.is_open, async (open) => { requestAnimationFrame(applyScale); }); +const RESIZE_DEBOUNCE_MS = 100; +let resize_timer = null; + +function handleResize() { + if (resize_timer) { + clearTimeout(resize_timer); + } + resize_timer = setTimeout(applyScale, RESIZE_DEBOUNCE_MS); +} + onMounted(() => { - window.addEventListener('resize', applyScale); + window.addEventListener('resize', handleResize); }); onUnmounted(() => { - window.removeEventListener('resize', applyScale); + window.removeEventListener('resize', handleResize); + if (resize_timer) { + clearTimeout(resize_timer); + } }); @@ -223,7 +231,7 @@ onUnmounted(() => { position: static; font-size: var(--fs-175); letter-spacing: 0.2em; - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); } .modal-stage { @@ -260,7 +268,7 @@ onUnmounted(() => { position: static; font-size: var(--fs-175); letter-spacing: 0.2em; - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); } .actions-row { diff --git a/src/views/components/sections/footer.vue b/src/views/components/sections/footer.vue index 46ddf7e..bc4fef6 100644 --- a/src/views/components/sections/footer.vue +++ b/src/views/components/sections/footer.vue @@ -24,7 +24,7 @@ border-top: 1px solid var(--clr-border-100); font-family: var(--font-mono); font-size: var(--fs-275); - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); letter-spacing: 0.1em; text-transform: uppercase; opacity: 0.6; diff --git a/src/views/components/sections/sources.vue b/src/views/components/sections/sources.vue index 2827fad..cc86ded 100644 --- a/src/views/components/sections/sources.vue +++ b/src/views/components/sections/sources.vue @@ -169,7 +169,7 @@ const filtered_overlays = computed(() => { padding: 0.75em 1.5em; font-family: var(--font-mono); font-size: var(--fs-375); - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); background: transparent; border: 1px solid transparent; border-bottom: none; @@ -203,7 +203,7 @@ const filtered_overlays = computed(() => { min-width: 1.75em; padding: 0.05em 0.4em; font-size: var(--fs-175); - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); border: 1px solid var(--clr-border-100); letter-spacing: 0.1em; } @@ -272,7 +272,7 @@ const filtered_overlays = computed(() => { } .filter-input input::placeholder { - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); opacity: 0.5; } @@ -310,7 +310,7 @@ const filtered_overlays = computed(() => { font-family: var(--font-mono); font-size: var(--fs-175); letter-spacing: 0.2em; - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); background: transparent; border: none; border-right: 1px solid var(--clr-border-100); @@ -351,7 +351,7 @@ const filtered_overlays = computed(() => { text-align: center; font-family: var(--font-mono); font-size: var(--fs-375); - color: var(--clr-neutral-200); + color: var(--clr-neutral-100); letter-spacing: 0.1em; border: 1px dashed var(--clr-border-100); } diff --git a/src/views/utils/markup.js b/src/views/utils/markup.js index 88c3653..a032325 100644 --- a/src/views/utils/markup.js +++ b/src/views/utils/markup.js @@ -19,13 +19,6 @@ const EMPHASIS_PATTERN = /\*\*(.+?)\*\*/g; -/** - * Parse `**bold**` markers in text into an array of - * `{ text, bold }` segments for Vue template rendering. - * - * @param {string} text - * @returns {Array<{ text: string, bold: boolean }>} - */ export function parseEmphasis(text) { if (!text) { return []; diff --git a/vite.config.js b/vite.config.js index 69c1b27..bb0a4e5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -54,18 +54,15 @@ const DEV_SERVER_PORT = 5173; export default defineConfig({ resolve: { alias: { - // Top-level scopes '@shared': resolve(ROOT, 'src/shared'), '@views': resolve(ROOT, 'src/views'), '@app': resolve(ROOT, 'src/app'), '@assets': resolve(ROOT, '.github/assets'), - // Views: kind folders '@sections': resolve(ROOT, 'src/views/components/sections'), '@elements': resolve(ROOT, 'src/views/components/elements'), '@modals': resolve(ROOT, 'src/views/components/modals'), - // Shared: kind folders '@ui': resolve(ROOT, 'src/shared/components/ui'), '@hud': resolve(ROOT, 'src/shared/components/hud'), '@widgets': resolve(ROOT, 'src/shared/widgets'),