feat(kot): brand refinement + OBS FPS performance pass - #5
Conversation
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) <kyonax25@gmail.com>
Protected Files ModifiedOne or more files in the protected set were changed in this PR. Each category below explains why the file matters. Legal / Licensing
Modifying these files changes the project's legal posture. Confirm with the maintainer before merging. Governance
CODEOWNERS / SECURITY / PR template changes affect how every future PR is reviewed. Review carefully. Supply Chain
Dependency or lockfile changes. Verify the diff (no unexpected packages, no version downgrades). CI / Security Config
Workflow / lint config. A quiet edit here can disable gates — diff against origin carefully. Build / Config
Build or gitignore config. Verify the build still passes and no ignored paths were accidentally un-ignored. Release Artifact
Release-tracking files. Expected on release PRs; flag on non-release PRs. |
|
Superseded by #6, which now targets The brand refinement and OBS FPS performance pass ( Closing to keep a single open PR for the v0.5 release. |
Checklist (check if it applies)
npm run lint)Pre-Check Failedlabel applied by CI)What does this PR do?
Feature branch
feat-brand_kot("feature brand Kyonax on Tech"). Two intertwined workstreams land together: a refinement of every component the@kyonax_on_techbrand touches (cam-log restructured around a.hud-grouplayout system,brand.jsschema extended withhost+region, session-date prefix driven by brand metadata) AND a targeted OBS FPS performance pass triggered by a real regression — thecyberpunk-glowmixin applied via CSS-class cascade to every gold-text element was crushing fps on low-core-processor hardware. The mixin is gone, halo/glow is reborn as opt-in--hud-halo/--hud-halo-text/--hud-glowCSS custom properties, every OBS-WS composable is now a module-level singleton, the audio analyzer is zero-allocation event-driven, and<AudioMeter>bypasses Vue reactivity in the hot path. ~32 files changed, 1 new test file, net −73 LoC (the perf rewrite undoes more than it adds).Design / Reference: Decisions #116–#129 + §1.14 Performance Budget tracked in project architecture docs at
CONTRIBUTING.org. HUD vocabulary + recording-light idiom defined alongside the brand assets at.github/assets/logo.txt. Scope tracked inCHANGELOG.orgTODO block.Implementation
Brand — @kyonax_on_tech (
@kyonax_on_tech/)brand.js— schema gainshost: "KYO-LABS"+region: "COL".regiondrives the session-date prefix incam-log.vue(replaces the previously hardcoded"UTC"). Colors-in-SCSS rule unchanged —brand.jsstill must never carry acolorsfield.sources/hud/cam-log.vue— layout rewritten around a.hud-groupstructural system. Four positioned groups (.group--top-left,.group--top-right,.group--bottom-left,.group--identity) replace ad-hoc absolute-positioned classes (.rec-frame,.cam-online,.identity-block). Unified.hud-text+.hud-text--primarylabel markup..dynamic-layerextracted as a sibling of<HudFrame>to separate static HUD chrome from OBS-state widgets. Top-right now renders[SESSION] name::T00+CAM ONLINE(via explicit markup, not the deprecated<HudFrame>labelsprop). Session-date format is now${brand.region} ∇ DD.MM.YYYY // ddd→ e.g.COL ∇ 23.04.2026 // WED.styles/_theme.scss—--clr-neutral-100lightened fromhsl(0 0% 95%)→hsl(0 0% 85%)so it keeps tonal separation from--clr-neutral-50after the view-layer--clr-neutral-200 → --clr-neutral-100harmonization. Section-divider comments stripped.SCSS design tokens (
src/app/scss/abstracts/)_mixins.scss— removescyberpunk-glowmixin (perf-fatal — see Technical Details),max-media-querymixin (unused),@use "sass:math"import (only consumer wascyberpunk-glow). Addshud-text-basemixin as the minimal label typography core (font family, size, uppercase, letter-spacing, color);hud-label-basenow composeshud-text-base+ addsposition: absolute._theme.scss— declares four new design tokens on:root:--hud-halo(filter: drop-shadow()chain, 3 layers),--hud-halo-text(text-shadow equivalent),--hud-glow(brand-primary glow, rebindable via--hud-glow-colorso consumers can re-point to error/success colors without redefining the chain),--hud-group-gap(shared 0.6em vertical rhythm for HUD groups). Plus--clr-primary-100-80/--clr-primary-100-40viacolor-mix()for bar-shadow tinting. Removes the.cyberpunk-glowutility class (replaced by opt-in token references per consumer).Shared composables — singleton conversion (
src/shared/composables/)composables.test.js— 6 tests covering the three newly-singleton composables. MocksuseObsWebsocketat module scope (stubobs+ aconnectedref). For each ofuseRecordingStatus,useSceneName,useAudioAnalyzer: asserts documented initial state AND asserts the singleton identity contract (expect(a).toBe(b)with same sub-references).use-obs-websocket.js— comment strip (module-level singleton pattern was already in place).use-recording-status.js— converted to module-level singleton.let shared_state = null;at module scope; first call wires the WS handler + fills state; subsequent callers return the same object. Callers dropped the{ obs, connected }params — composable callsuseObsWebsocket()internally.watch(connected, fn, { immediate: true })replaces the oldonMountedfetch.onUnmountedcleanup removed (singletons live for page lifetime).use-scene-name.js— same singleton conversion.use-audio-analyzer.js— singleton + full hot-path rewrite. PreallocatedFloat32Array(bar_count)for levels + smoothed (mutated in place every tick). 256-entryJITTER_TABLE: Float32Arrayseeded once at module load, cursor-advanced per tick — replaces per-frameMath.random().requestAnimationFrameloop deleted; analyzer now ticks off the OBSInputVolumeMetersevent (~50 Hz). Reactivity surface reduced to a singletick: ref(0)counter + three low-churn refs (active,source_name); bar levels are NOT reactive. Target input hardcoded toMic/Aux(eliminates per-eventArray.find).Shared components — HUD + UI primitives (
src/shared/components/)hud/frame.vue—filter: var(--hud-halo)added to the root so bracket SVGs inherit the dark outline automatically. Dead.border-*divs removed (weredisplay: nonefrom a previous iteration).hud/timer.vue—.rec.activenow overrides--hud-glow-colortovar(--clr-error-100)so the REC indicator glows red via the same halo-token pathway the rest of the HUD uses.contain: layout paintadded to.recording-timer.ui/status-dot.vue— composition split for per-frame paint cost. Outer<span>owns the STATIC dark halobox-shadow: var(--hud-halo-text)(never animated). Inner<span class="glow">owns a redbox-shadow: var(--hud-glow)withopacity: 0by default, animated to a1 ↔ 0.35pulse via a 2s ease-in-outbreathekeyframe while.active. Onlyopacitychanges per frame — the dark shadow stays cached.--hud-glow-colorscoped inside.glowtovar(--clr-error-100).ui/data-point.vue—contain: layout painton root. Opt-in halo viatext-shadow: var(--hud-halo-text)on the label andtext-shadow: var(--hud-halo-text), var(--hud-glow)on the value.ui/badge.vue—--dimvariant color harmonization (--clr-neutral-200→--clr-neutral-100).Shared widgets — DOM-direct hot path (
src/shared/widgets/)hud/audio-meter.vue— bypasses Vue reactivity in the per-frame hot path. Template rendersv-forof static<div ref="bar_els" class="bar" />(no:stylebinding). Awatch(tick, ...)callback readslevels[i]synchronously and assignsel.style.transform = SCALE_STRINGS[idx].SCALE_STRINGSis a precomputed 101-entry Array ofscaleY(0.00)...scaleY(1.00)strings (quantized to 2 decimals) — no per-frame concat. Write-threshold skip: if|scale − last_scale[i]| < 0.01, skip the DOM write.update:stateemit throttled to ~10 Hz viaperformance.now()delta. Bar CSS switched from animatedheight→transform: scaleY()withtransform-origin: bottom(GPU-composited, zero layout cost).source_nameprop removed (analyzer owns the target).ui/live-readout.vue— skip no-op text writes (if (props.text !== displayed.value) displayed.value = props.text). Prevents unnecessary reactive updates when the interval fires but the upstream text hasn't changed.Shared bootstrap / utilities (
src/shared/,src/)brand-loader.js— comment strip.version.js— comment strip.../App.vue— comment strip.../main.js— comment strip.Views — modal + sections + card (
src/views/)components/elements/card.vue— handler binding renamed@consume_trigger→@consume-kebab-caseto match Vue's template-event convention. Secondary-text color harmonization (--clr-neutral-200→--clr-neutral-100).components/modals/preview.vue—window.resizehandler now debounced to 100 ms (RESIZE_DEBOUNCE_MS) viasetTimeout/clearTimeout. Before, every drag-resize fire (~60 Hz) recomputedgetBoundingClientRect()+ wrote a CSS custom property per frame. Emit event renamedconsume_trigger→consume-trigger(defineEmits). Color harmonization on meta-labels.components/sections/footer.vue— color harmonization.components/sections/sources.vue— color harmonization across tabs, count badge, filter input, placeholder, toolbar, and empty-state text.utils/markup.js— comment strip.Dependencies
dayjs(^1.11.20) — first project runtime dependency beyondvue/vue-router/obs-websocket-js. Used bycam-log.vuefor the session-date format ([${region} ∇ ]DD.MM.YYYY[ // ]ddd). UTC plugin (dayjs/plugin/utc.js) extended at module scope. Small bundle cost (~2 kB min+gz), tree-shakes the UTC plugin separately.package-lock.jsonupdated with thedayjstree (one new top-level entry).CI & Tooling
eslint.config.mjs— section-divider comments stripped (the// ── <Section> ──────banners). Rules unchanged. Lint output is identical before / after.vite.config.js— alias group-header comments stripped. Alias list unchanged.Technical Details
FPS regression root cause —
cyberpunk-glowvia CSS-class cascade.cyberpunk-glowutility +sass:mathimport entirely. Re-express halo/glow as opt-in CSS custom properties on:rootthat consumers reference per element.prefers-reduced-motion, or reducing the shadow layer count.box-shadowkeyframe. With dozens of.cyberpunk-glowconsumers on one HUD, the cost compounds — measured perceptual fps drop on a multi-core low-clock laptop. Opt-in per-element tokens let the developer see the cost at the use site, and let specific consumers group halo on a shared container (e.g.<HudFrame>appliesfilter: var(--hud-halo)once; bracket SVGs inherit the rasterization).project_hud_legibility_strategies.mdpointers to the mixin file became stale as a result; the architectural rationale ("why no blend modes") still holds.OBS-WS composables → module-level singletons
let shared_state = null;at module scope; first call wires the WS handler + fills state; subsequent callers return the same object. NoonUnmountedcleanup. Test assertsexpect(a).toBe(b)identity.provide/injectwould have worked but requires a Vue app context (no plain-JS consumption); the singleton pattern is already in use byuseObsWebsocketand is test-verifiable.Audio analyzer — event-driven + zero-allocation hot path
InputVolumeMetersevent directly (~50 Hz). PreallocatedFloat32Arrayfor levels + smoothed; precomputed 256-entryJITTER_TABLE: Float32Array+ cursor-advanced per tick; classicfor (let i = 0; i < len; i++)loops; hardcodedMic/Auxtarget.requestAnimationFramerender loop + per-frame array allocation +Math.random()variation + configurablesource_nameoption.new Array(bar_count)+Math.random()trigger minor GC pauses that manifest as dropped frames;Array.find's iterator callback allocates closures each call; destructuring allocates binding records. Every one of these costs is avoidable, and the cumulative savings are what lets the HUD hold fps on constrained hardware.Mic/Auxis hardcoded, and the reactivity surface is atickcounter that consumers must watch (not directly-consumablelevelsrefs). Documented in the composable's header; the singleton-contract test locks the shape.<AudioMeter>— direct DOM writes via template refsv-fortemplate;watch(tick, ...)callback readslevels[i]sync and writesel.style.transform = SCALE_STRINGS[idx]directly; quantized 101-entrySCALE_STRINGSlookup; write-threshold skip (|scale − last_scale| < 0.01); emit throttled to ~10 Hz.:style="{ height: \${scaleLevel(level)}px` }"binding on each bar, or a reactive ref backing per-bartransform` strings.:styleon 16 bars × 50 Hz = 800 reactive patch invocations/sec, each walking the dep graph + scheduling a patch. Direct DOM writes skip the entire reactivity machinery and land the value on the element in one statement.transform: scaleY()withtransform-origin: bottomis GPU-composited —heightanimation triggers layout every frame. The quantized table removes all string allocation; the write-threshold removes 30–40% of would-be DOM writes at typical audio levels.tickcounter +levelsFloat32Array are inspectable, and the visualization is the truth anyway.<UiStatusDot>— split static halo from animated glow<span>owns the static dark halo (box-shadow: var(--hud-halo-text), never in a keyframe). Inner<span class="glow">owns a redbox-shadow: var(--hud-glow)withopacity: 0default, animated1 ↔ 0.35via a 2s ease-in-outbreathekeyframe while.active.@keyframes(the pre-refactor form).box-shadowvalue is part of a keyframe, the browser re-rasterizes the shadow every frame. Keeping the expensive dark halo STATIC lets the browser cache its rasterization permanently — only the cheapopacityon the sibling changes per frame. This is the "split static from animated" discipline codified in §1.14.3.Preview-modal resize — 100 ms trailing debounce
setTimeout(applyScale, 100)withclearTimeouton re-fire +onUnmounted.applyScaledirectly on every resize event (the prior form).window.resizefires ~60 Hz during drag;applyScalecallsgetBoundingClientRect()(forces style flush) + writes a CSS custom property. 60 flushes/sec during a slow drag is pure cost — a single trailing recompute gives the correct final scale with 1/60th the cost.RESIZE_DEBOUNCE_MS.Testing Coverage
Test runner: Vitest 4.1 (happy-dom env)
Command:
npm run testAutomated tests
src/shared/brand-loader.test.jscolors === undefinedenforcementsrc/shared/version.test.jspackage.jsonsrc/shared/composables/composables.test.jsuseRecordingStatus,useSceneName,useAudioAnalyzerTotal: 33 tests across 3 files, all passing in 371 ms. Dynamic-behavior tests for the new composables (event-driven tick propagation, Float32Array mutation under simulated
InputVolumeMetersevents) are deferred to a follow-up PR — singleton identity + initial state contract is the first discipline stake to lock down.Quality gates (run on every PR)
eslint.config.mjsvianpm run lintvite.config.jsvianpm run testvite.config.jsvianpm run build.github/workflows/ci.yml.github/workflows/ci.ymlPre-Check Failedlabelpre-check-labeljob in.github/workflows/ci.ymlLint warnings note: 15 non-blocking warnings surface on the perf-critical hot-path files. All are
security/detect-object-injectionfalse positives on intentional typed-array indexing (e.g.levels[i],JITTER_TABLE[cursor],els[i].style.transform = SCALE_STRINGS[idx]) — these patterns are MANDATED by the performance discipline (§1.14.6 / D1). One additional warning (no-magic-numberson.toFixed(3)incam-log.vuedebug-text formatting) is cosmetic. Zero errors; the gate passes.How to test this PR
Setup
git checkout feat-brand_kot && npm ciExpected:
dayjs@^1.11.20installed undernode_modules/dayjs/. No other new top-level deps.npm run lintExpected:
✖ 15 problems (0 errors, 15 warnings). All warnings on the hot-path files (audio analyzer, audio meter, cam-log). Zero errors.npm run testExpected:
Test Files 3 passed (3)/Tests 33 passed (33)in <500 ms.npm run devExpected: Vite listens on
http://localhost:5173, zero console errors.Brand refinement — cam-log layout + brand.host + brand.region
http://localhost:5173/@kyonax_on_tech/cam-log.Expected: top-left renders
KYO-LABS(secondary) above the primary gold session-date readingCOL ∇ <DD>.<MM>.<YYYY> // <DDD>for today's UTC date — e.g.COL ∇ 23.04.2026 // WED. Glyph∇renders correctly.@kyonax_on_tech/brand.jsin the source tree.Expected: exports include
host: 'KYO-LABS'andregion: 'COL'.cam-log.vueconsumes both viagetBrand('@kyonax_on_tech').Expected: four
.hud-groupelements (.group--top-left,.group--top-right,.group--bottom-left,.group--identity). Each group contains.hud-text/.hud-text--primaryspans..dynamic-layeris a sibling of<HudFrame>carrying.status-bar+.debug-info.Expected:
[SESSION] <scene_name>::T<take_count>non-primary label above the primary goldCAM ONLINE. If OBS is not connected, the label still renders (scene_name falls back to---, take_count to00).FPS pass — CSS cost (halo tokens,
cyberpunk-glowremoval)cyberpunk-glow—grep -rn 'cyberpunk-glow' src/ @kyonax_on_tech/ --include='*.scss' --include='*.vue'.Expected: zero matches. The mixin and the
.cyberpunk-glowutility class are fully gone.src/app/scss/abstracts/_theme.scss.Expected:
:rootdeclares--hud-halo,--hud-halo-text,--hud-glow,--hud-glow-color,--hud-group-gap,--clr-primary-100-80,--clr-primary-100-40..session-dateelement.Expected: computed
text-shadowresolves to the full--hud-halo-text, --hud-glowchain. No animatedfilterorbox-shadowin any keyframe — use the DevTools Animations panel to confirm no filter/shadow keyframes are registered.Expected: frame rate holds 60 fps. Paint cost per frame is low (compositing-only for animated layers).
FPS pass — OBS-WS composables as singletons
src/shared/composables/use-recording-status.js,use-scene-name.js,use-audio-analyzer.js.Expected: each starts with
let shared_state = null;at module scope. Each function body early-returnsshared_stateif set. None callonUnmounted.src/shared/composables/composables.test.js.Expected: 6 tests, 3 describe blocks (one per composable). Each describe block has an initial-state test and a
expect(a).toBe(b)singleton-identity test.npm run test.Expected: all 6 new tests pass (
useRecordingStatus (singleton) > returns the documented initial state, etc.).FPS pass — Audio analyzer +
<AudioMeter>direct DOM writeshttp://localhost:5173/@kyonax_on_tech/cam-log.Expected: audio meter bars at the bottom of the HUD respond to the microphone level — bars grow from the bottom (
transform: scaleY()) rather than from the top (no more animatedheight)..barelement inside.audio-meter.Expected:
transformvalue changes over time (e.g.scaleY(0.42),scaleY(0.88)), not theheightinline style. The element hastransform-origin: bottom.Expected:
.audio-meterhascontain: layout paintin computed styles. Same for.status-bar,.hud-group,.recording-timer,.ui-data-point,.debug-info,.hud-frame,.cam-log-overlay.Expected: no purple "Layout" bars during audio-meter animation; only green "Composite Layers" and yellow "Paint" where needed.
<AudioMeter>props and events.Expected: no
source_nameprop; noobsprop (analyzer owns the WS).update:statestill fires but throttled — observe via a@update:state="console.log"temporary wrapper: events should fire at ~10 Hz max.FPS pass — Status dot layered composition
/cam-log.Expected: the 6×6 square status dot turns red, breathes smoothly (2s ease-in-out, opacity 1 ↔ 0.35) with a soft red glow pulse. No hard step-flicker.
.ui-status-dotelement in DevTools.Expected: two child elements — outer
<span class="ui-status-dot active">and inner<span class="glow">. Outer span has a staticbox-shadow(dark halo,--hud-halo-textchain, NOT in any keyframe). Inner.glowhasanimation: breathe 2s ease-in-out infinitewithopacityas the only changing property.Expected: only one animation registered (
breathe), and its keyframes touch onlyopacity— no shadow / filter / transform keyframe entries.Expected: dot returns to muted grey,
.glowanimation stops (opacity: 0 by default).FPS pass — Preview modal resize debounce
http://localhost:5173/, find the CAM-LOG overlay card, click "Preview".Expected: modal opens, iframe fits inside stage with no sub-pixel overflow (the Session 9
getBoundingClientRect()fix is still in place).Expected: the iframe rescales smoothly but NOT on every frame —
applyScalefires only ~100 ms after you stop moving. During the drag, the scale is stale for up to 100 ms; after drag end, it snaps to the correct value.src/views/components/modals/preview.vue.Expected:
handleResizewrapper usessetTimeout(applyScale, RESIZE_DEBOUNCE_MS)withclearTimeouton re-fire.onUnmountedclears the pending timer.@consume_trigger→@consume-triggerkebab-case rename in<PreviewModal>emit +<Card>handler binding.Expected: modal open + close flow still works (triggers emit reaches Card correctly). No console warnings about unknown emit names.
Regression check — Session 9 behaviors still intact
Expected: reads
COL ∇ <DD>.<MM>.<YYYY> // <DDD>(Session 9 wasUTC ∇ ...; now parameterized viabrand.region). Still one-shot computed at setup.Expected: no ~1px right-edge overflow (the Session 9
getBoundingClientRect().widthfix).Expected: still breathes — but now via the layered composition (Session 10 refinement of the Session 9 behavior).
Documentation
VIDEO — OBS FPS before vs after with live webcam overlay
SCREENSHOT — Cam-log HUD with brand.region-driven session-date
DIAGRAM — Halo / glow token architecture
DIAGRAM — AudioMeter hot path (direct DOM write bypassing Vue reactivity)