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('
+
+
diff --git a/src/shared/components/ui/status-dot.vue b/src/shared/components/ui/status-dot.vue
new file mode 100644
index 0000000..c7fd8ef
--- /dev/null
+++ b/src/shared/components/ui/status-dot.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/shared/composables/composables.test.js b/src/shared/composables/composables.test.js
new file mode 100644
index 0000000..d782813
--- /dev/null
+++ b/src/shared/composables/composables.test.js
@@ -0,0 +1,152 @@
+/**
+ * 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 singleton composables that drive the cam-log HUD:
+ * useObsWebsocket is mocked; each composable is verified to return
+ * the documented initial state and to share one instance across
+ * repeated calls (singleton contract).
+ */
+
+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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@composables/use-obs-websocket.js', async () => {
+ const vue = await vi.importActual('vue');
+ return {
+ useObsWebsocket: () => ({
+ obs: {
+ on: () => {},
+ off: () => {},
+ call: () => Promise.resolve({ outputActive: false }),
+ },
+ connected: vue.ref(false),
+ }),
+ };
+});
+
+const EXPECTED_BAR_COUNT = 16;
+
+describe('useRecordingStatus (singleton)', () => {
+ it('returns the documented initial state', () => {
+ const state = useRecordingStatus();
+ expect(state.is_recording.value).toBe(false);
+ expect(state.elapsed_time.value).toBe('00:00:00');
+ expect(state.record_state.value).toBe('stopped');
+ expect(state.take_count.value).toBe(0);
+ });
+
+ it('returns the same instance on repeated calls', () => {
+ const a = useRecordingStatus();
+ const b = useRecordingStatus();
+ expect(a).toBe(b);
+ expect(a.is_recording).toBe(b.is_recording);
+ });
+});
+
+describe('useSceneName (singleton)', () => {
+ it('initial scene_name is empty', () => {
+ const state = useSceneName();
+ expect(state.scene_name.value).toBe('');
+ });
+
+ it('returns the same instance on repeated calls', () => {
+ const a = useSceneName();
+ const b = useSceneName();
+ expect(a).toBe(b);
+ expect(a.scene_name).toBe(b.scene_name);
+ });
+});
+
+describe('useAudioAnalyzer (singleton)', () => {
+ it('returns a preallocated Float32Array of 16 levels', () => {
+ const state = useAudioAnalyzer();
+ expect(state.levels).toBeInstanceOf(Float32Array);
+ expect(state.levels.length).toBe(EXPECTED_BAR_COUNT);
+ expect(state.tick.value).toBe(0);
+ expect(state.active.value).toBe(false);
+ expect(state.source_name.value).toBe('');
+ });
+
+ it('returns the same instance on repeated calls', () => {
+ const a = useAudioAnalyzer();
+ const b = useAudioAnalyzer();
+ expect(a).toBe(b);
+ expect(a.levels).toBe(b.levels);
+ 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-audio-analyzer.js b/src/shared/composables/use-audio-analyzer.js
index 691a4d0..172dddf 100644
--- a/src/shared/composables/use-audio-analyzer.js
+++ b/src/shared/composables/use-audio-analyzer.js
@@ -3,162 +3,112 @@
* 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/
*
- * Audio analyzer — reads real-time volume from OBS via
- * the InputVolumeMeters WebSocket event. Generates a
- * multi-band display with smoothed variation around the
- * actual level (OBS sends a single level per input, not
- * frequency data — standard for overlay visualizers).
+ * Audio analyzer — singleton composable. One InputVolumeMeters
+ * subscription per page; any caller (typically one AudioMeter)
+ * receives the same levels buffer and tick counter.
*/
-import { onUnmounted, ref } from 'vue';
+import { useObsWebsocket } from '@composables/use-obs-websocket.js';
+import { ref } from 'vue';
const BAR_COUNT = 16;
-const MAX_LEVEL = 255;
+const TARGET_SOURCE = 'Mic/Aux';
+const PEAK_INDEX = 1;
const GAIN = 8;
const SMOOTHING = 0.2;
-const VARIATION_RANGE = 0.7;
-const VARIATION_CENTER = 0.5;
const DECAY_RATE = 0.85;
+const VARIATION_CENTER = 0.5;
+const VARIATION_RANGE = 0.7;
const MIN_AUDIBLE = 0.005;
-const PEAK_INDEX = 1;
-/**
- * @param {object} params
- * @param {object} params.obs - OBSWebSocket instance
- * @param {object} [params.options]
- * @param {string} [params.options.source_name] - OBS input
- * name to monitor. Empty = first audio input found.
- * @param {number} [params.options.bar_count] - bars
- */
-export function useAudioAnalyzer({ obs, options = {} }) {
+const JITTER_SIZE = 256;
+const JITTER_MASK = JITTER_SIZE - 1;
+const JITTER_TABLE = new Float32Array(JITTER_SIZE);
+for (let i = 0; i < JITTER_SIZE; i++) {
+ JITTER_TABLE[i] = Math.random();
+}
+
+let shared_state = null;
+
+export function useAudioAnalyzer({ options = {} } = {}) {
+ if (shared_state) {
+ return shared_state;
+ }
+
const bar_count = options.bar_count || BAR_COUNT;
- const target_source = options.source_name || '';
+ const { obs } = useObsWebsocket();
+
+ const levels = new Float32Array(bar_count);
+ const smoothed = new Float32Array(bar_count);
- const levels = ref(
- Array.from({ length: bar_count }, () => 0),
- );
+ const tick = ref(0);
const active = ref(false);
const source_name = ref('');
- let raw_level = 0;
- const previous_bands = new Float32Array(bar_count);
- let animation_id = null;
- let is_stopped = false;
-
- /**
- * Handle InputVolumeMeters — extract peak level.
- * Each channel has [magnitude, peak, input_peak].
- * We use index 1 (peak) for responsive visualization.
- * Skips inputs with empty inputLevelsMul.
- * Runs at ~50Hz, zero allocation in hot path.
- */
- function handleVolumeMeters(event) {
- const { inputs } = event;
+ let jitter_cursor = 0;
+ function handleVolumeMeters(event) {
+ const inputs = event?.inputs;
if (!inputs || inputs.length === 0) {
return;
}
let target = null;
-
- if (target_source) {
- target = inputs.find(
- (input) => input.inputName === target_source,
- );
- }
-
- if (!target) {
- target = inputs.find(
- (input) => input.inputLevelsMul
- && input.inputLevelsMul.length > 0,
- );
+ for (let i = 0; i < inputs.length; i++) {
+ const input = inputs[i];
+ if (input.inputName === TARGET_SOURCE
+ && input.inputLevelsMul
+ && input.inputLevelsMul.length > 0) {
+ target = input;
+ break;
+ }
}
-
if (!target) {
return;
}
- source_name.value = target.inputName;
- active.value = true;
-
- const channel_levels = target.inputLevelsMul;
-
- if (!channel_levels || channel_levels.length === 0) {
- raw_level = 0;
- return;
+ if (source_name.value !== target.inputName) {
+ source_name.value = target.inputName || 'obs';
+ }
+ if (!active.value) {
+ active.value = true;
}
+ const channels = target.inputLevelsMul;
let peak = 0;
-
- for (let ch = 0; ch < channel_levels.length; ch++) {
- const channel = channel_levels[ch];
- const value = channel[PEAK_INDEX] || 0;
-
+ for (let i = 0; i < channels.length; i++) {
+ const value = channels[i][PEAK_INDEX] || 0;
if (value > peak) {
peak = value;
}
}
-
- raw_level = Math.min(1, peak * GAIN);
- }
-
- /**
- * Compute visual band levels from the single raw_level.
- * Adds per-band variation + exponential smoothing.
- * Runs on rAF (~60Hz).
- */
- function renderFrame() {
- if (is_stopped) {
- return;
- }
-
- const base = raw_level * MAX_LEVEL;
- const result = new Array(bar_count);
+ const raw_level = peak * GAIN > 1 ? 1 : peak * GAIN;
+ const audible = raw_level > MIN_AUDIBLE;
for (let i = 0; i < bar_count; i++) {
- const variation = 1
- + (Math.random() - VARIATION_CENTER) * VARIATION_RANGE;
- const target_value = Math.min(
- MAX_LEVEL,
- Math.max(0, base * variation),
- );
-
- const smoothed = previous_bands[i] * SMOOTHING
- + target_value * (1 - SMOOTHING);
-
- const decayed = raw_level > MIN_AUDIBLE
- ? smoothed
- : previous_bands[i] * DECAY_RATE;
-
- previous_bands[i] = decayed;
- result[i] = Math.round(decayed);
- }
-
- levels.value = result;
- animation_id = requestAnimationFrame(renderFrame);
- }
-
- function start() {
- obs.on('InputVolumeMeters', handleVolumeMeters);
- animation_id = requestAnimationFrame(renderFrame);
- }
+ const jitter = JITTER_TABLE[(i + jitter_cursor) & JITTER_MASK];
+ const variation = 1 + (jitter - VARIATION_CENTER) * VARIATION_RANGE;
+ let target_value = raw_level * variation;
+ if (target_value > 1) {
+ target_value = 1;
+ } else if (target_value < 0) {
+ target_value = 0;
+ }
- function stop() {
- is_stopped = true;
- obs.off('InputVolumeMeters', handleVolumeMeters);
+ const smooth_new = smoothed[i] * SMOOTHING
+ + target_value * (1 - SMOOTHING);
+ const next = audible ? smooth_new : smoothed[i] * DECAY_RATE;
- if (animation_id) {
- cancelAnimationFrame(animation_id);
- animation_id = null;
+ smoothed[i] = next;
+ levels[i] = next;
}
- raw_level = 0;
- previous_bands.fill(0);
- active.value = false;
+ jitter_cursor = (jitter_cursor + 1) & JITTER_MASK;
+ tick.value++;
}
- onUnmounted(stop);
- start();
+ obs.on('InputVolumeMeters', handleVolumeMeters);
- return { levels, active, source_name, stop };
+ shared_state = { levels, tick, active, source_name };
+ return shared_state;
}
diff --git a/src/shared/composables/use-context-channel.js b/src/shared/composables/use-context-channel.js
new file mode 100644
index 0000000..82707b3
--- /dev/null
+++ b/src/shared/composables/use-context-channel.js
@@ -0,0 +1,276 @@
+/**
+ * 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);
+ // Raw .org text authored live (e.g. from the OBS script panel). When
+ // non-empty it OVERRIDES the file-backed context: consumers parse it
+ // at runtime instead of reading the build-time CONTEXTS map. Empty
+ // string means "fall back to active_slug".
+ const draft_org = ref('');
+ // Bracketed label on the cam-log HUD's top-right row (`[SESSION]`).
+ // Empty string means "use the component's own default" — the relay
+ // never dictates a fallback, the consumer owns it.
+ const cam_label = ref('');
+
+ 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;
+ }
+ if (typeof persisted.draft_org === 'string') {
+ draft_org.value = persisted.draft_org;
+ }
+ if (typeof persisted.cam_label === 'string') {
+ cam_label.value = persisted.cam_label;
+ }
+ }
+
+ 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;
+ }
+ if (typeof remote.draft_org === 'string') {
+ draft_org.value = remote.draft_org;
+ }
+ if (typeof remote.cam_label === 'string') {
+ cam_label.value = remote.cam_label;
+ }
+ }
+
+ 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,
+ draft_org: draft_org.value,
+ cam_label: cam_label.value,
+ }),
+ );
+ } catch {
+ // Storage quota / disabled — silently degrade.
+ }
+ }
+
+ function broadcastSnapshot() {
+ const snapshot = {
+ active_slug: active_slug.value,
+ sidebar_open: sidebar_open.value,
+ draft_org: draft_org.value,
+ cam_label: cam_label.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();
+ }
+
+ function setDraftOrg(text) {
+ draft_org.value = typeof text === 'string' ? text : '';
+ broadcastSnapshot();
+ }
+
+ function clearDraftOrg() {
+ draft_org.value = '';
+ broadcastSnapshot();
+ }
+
+ function setCamLabel(text) {
+ cam_label.value = typeof text === 'string' ? text : '';
+ broadcastSnapshot();
+ }
+
+ const scope = effectScope(true);
+ scope.run(() => {
+ watch(
+ [active_slug, sidebar_open, draft_org, cam_label],
+ schedulePersist,
+ );
+ watch(sidebar_open, (open) => applyDocumentClass(open));
+ });
+
+ applyDocumentClass(sidebar_open.value);
+
+ shared_state = {
+ active_slug,
+ sidebar_open,
+ draft_org,
+ cam_label,
+ setActiveSlug,
+ toggleSidebar,
+ hideSidebar,
+ setDraftOrg,
+ clearDraftOrg,
+ setCamLabel,
+ };
+ 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/composables/use-obs-websocket.js b/src/shared/composables/use-obs-websocket.js
index 2291076..28a0faa 100644
--- a/src/shared/composables/use-obs-websocket.js
+++ b/src/shared/composables/use-obs-websocket.js
@@ -4,21 +4,14 @@
* License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/
*/
+import { OBS_CONFIG } from '@shared/config.js';
import OBSWebSocket, { EventSubscription } from 'obs-websocket-js';
import { ref } from 'vue';
-import { OBS_CONFIG } from '../config.js';
-
const RECONNECT_DELAY = 5000;
let shared_state = null;
-/**
- * Composable — shared OBS WebSocket connection.
- * Singleton: every caller receives the same instance.
- * Auto-reconnects on disconnect. Subscribes to all events
- * including InputVolumeMeters (high-volume, opt-in).
- */
export function useObsWebsocket() {
if (shared_state) {
return shared_state;
diff --git a/src/shared/composables/use-recording-status.js b/src/shared/composables/use-recording-status.js
index 3138dd5..5bdd3eb 100644
--- a/src/shared/composables/use-recording-status.js
+++ b/src/shared/composables/use-recording-status.js
@@ -4,24 +4,24 @@
* License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/
*/
-import { onUnmounted, ref, watch } from 'vue';
+import { useObsWebsocket } from '@composables/use-obs-websocket.js';
+import { ref, watch } from 'vue';
const TIMER_INTERVAL = 1000;
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
-const PAD_LENGTH = 2;
+const PAD_BOUNDARY = 10;
+
+let shared_state = null;
+
+export function useRecordingStatus() {
+ if (shared_state) {
+ return shared_state;
+ }
+
+ const { obs, connected } = useObsWebsocket();
-/**
- * Composable — recording state from OBS WebSocket.
- * Tracks recording status, elapsed time, and take count
- * (incremented every time recording starts in the session).
- *
- * @param {object} params
- * @param {object} params.obs - OBSWebSocket instance
- * @param {import('vue').Ref} params.connected
- */
-export function useRecordingStatus({ obs, connected }) {
const is_recording = ref(false);
const elapsed_time = ref('00:00:00');
const record_state = ref('stopped');
@@ -41,9 +41,10 @@ export function useRecordingStatus({ obs, connected }) {
);
const seconds = total_seconds % SECONDS_PER_MINUTE;
- return [hours, minutes, seconds]
- .map((n) => String(n).padStart(PAD_LENGTH, '0'))
- .join(':');
+ const hh = hours < PAD_BOUNDARY ? `0${hours}` : `${hours}`;
+ const mm = minutes < PAD_BOUNDARY ? `0${minutes}` : `${minutes}`;
+ const ss = seconds < PAD_BOUNDARY ? `0${seconds}` : `${seconds}`;
+ return `${hh}:${mm}:${ss}`;
}
function startTimer() {
@@ -105,17 +106,13 @@ export function useRecordingStatus({ obs, connected }) {
} else {
stopTimer();
}
- });
-
- onUnmounted(() => {
- stopTimer();
- obs.off('RecordStateChanged', handleRecordStateChanged);
- });
+ }, { immediate: true });
- return {
+ shared_state = {
is_recording,
elapsed_time,
record_state,
take_count,
};
+ return shared_state;
}
diff --git a/src/shared/composables/use-scene-name.js b/src/shared/composables/use-scene-name.js
index 8f50879..92c14a4 100644
--- a/src/shared/composables/use-scene-name.js
+++ b/src/shared/composables/use-scene-name.js
@@ -4,17 +4,17 @@
* License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/
*/
-import { onUnmounted, ref, watch } from 'vue';
+import { useObsWebsocket } from '@composables/use-obs-websocket.js';
+import { ref, watch } from 'vue';
-/**
- * Composable — current OBS scene name.
- * Updates on scene changes via WebSocket events.
- *
- * @param {object} params
- * @param {object} params.obs - OBSWebSocket instance
- * @param {import('vue').Ref} params.connected
- */
-export function useSceneName({ obs, connected }) {
+let shared_state = null;
+
+export function useSceneName() {
+ if (shared_state) {
+ return shared_state;
+ }
+
+ const { obs, connected } = useObsWebsocket();
const scene_name = ref('');
function handleSceneChanged(event) {
@@ -23,32 +23,21 @@ export function useSceneName({ obs, connected }) {
async function fetchInitialScene() {
try {
- const result = await obs.call(
- 'GetCurrentProgramScene',
- );
+ const result = await obs.call('GetCurrentProgramScene');
scene_name.value = result.sceneName || '';
} catch {
scene_name.value = '';
}
}
- obs.on(
- 'CurrentProgramSceneChanged',
- handleSceneChanged,
- );
+ obs.on('CurrentProgramSceneChanged', handleSceneChanged);
watch(connected, (is_connected) => {
if (is_connected) {
fetchInitialScene();
}
- });
-
- onUnmounted(() => {
- obs.off(
- 'CurrentProgramSceneChanged',
- handleSceneChanged,
- );
- });
+ }, { immediate: true });
- return { scene_name };
+ shared_state = { scene_name };
+ return shared_state;
}
diff --git a/src/shared/data/overlays.test.js b/src/shared/data/overlays.test.js
deleted file mode 100644
index 77c7136..0000000
--- a/src/shared/data/overlays.test.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * 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 overlay registry — enforces the schema documented
- * in the session guidelines so future entries stay consistent.
- */
-
-import { describe, expect, it } from 'vitest';
-
-import { OVERLAYS } from './overlays.js';
-
-const REQUIRED_FIELDS = [
- 'id',
- 'brand',
- 'name',
- 'description',
- 'use_cases',
- 'path',
- 'width',
- 'height',
- 'fps',
- 'requires',
- 'triggers',
- 'status',
-];
-
-const ALLOWED_STATUSES = ['ready', 'planned'];
-const EM_DASH = '\u2014';
-
-describe('OVERLAYS registry', () => {
- it('is a non-empty array', () => {
- expect(Array.isArray(OVERLAYS)).toBe(true);
- expect(OVERLAYS.length).toBeGreaterThan(0);
- });
-
- it('every overlay id is unique', () => {
- const ids = OVERLAYS.map((o) => o.id);
- expect(new Set(ids).size).toBe(ids.length);
- });
-
- it.each(OVERLAYS)('overlay $id declares every required field', (overlay) => {
- for (const field of REQUIRED_FIELDS) {
- expect(overlay).toHaveProperty(field);
- }
- });
-
- it.each(OVERLAYS)('overlay $id uses a valid status', (overlay) => {
- expect(ALLOWED_STATUSES).toContain(overlay.status);
- });
-
- it.each(OVERLAYS)(
- 'overlay $id description has no em dashes',
- (overlay) => {
- expect(overlay.description).not.toContain(EM_DASH);
- },
- );
-
- it.each(OVERLAYS)(
- 'overlay $id use_cases is a string array',
- (overlay) => {
- expect(Array.isArray(overlay.use_cases)).toBe(true);
- expect(
- overlay.use_cases.every((keyword) => typeof keyword === 'string'),
- ).toBe(true);
- },
- );
-
- it.each(OVERLAYS)(
- 'overlay $id path matches /@brand/id',
- (overlay) => {
- expect(overlay.path).toBe(`/${overlay.brand}/${overlay.id}`);
- },
- );
-
- it.each(OVERLAYS)(
- 'overlay $id canvas dimensions are positive integers',
- (overlay) => {
- expect(Number.isInteger(overlay.width)).toBe(true);
- expect(Number.isInteger(overlay.height)).toBe(true);
- expect(Number.isInteger(overlay.fps)).toBe(true);
- expect(overlay.width).toBeGreaterThan(0);
- expect(overlay.height).toBeGreaterThan(0);
- expect(overlay.fps).toBeGreaterThan(0);
- },
- );
-});
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/shared/version.js b/src/shared/version.js
index d6dd9d3..8bd19e9 100644
--- a/src/shared/version.js
+++ b/src/shared/version.js
@@ -12,10 +12,8 @@
const MAJOR_MINOR_SEGMENTS = 2;
-/** Full semver string, e.g. "0.3.0". */
export const VERSION = __APP_VERSION__;
-/** Short display tag, e.g. "v0.3". Strips the patch segment. */
export const VERSION_TAG = `v${VERSION
.split('.')
.slice(0, MAJOR_MINOR_SEGMENTS)
diff --git a/src/shared/version.test.js b/src/shared/version.test.js
index 0c26dd7..2ed8312 100644
--- a/src/shared/version.test.js
+++ b/src/shared/version.test.js
@@ -8,10 +8,9 @@
* into VERSION_TAG correctly.
*/
+import { VERSION, VERSION_TAG } from '@shared/version.js';
import { describe, expect, it } from 'vitest';
-import { VERSION, VERSION_TAG } from './version.js';
-
describe('version', () => {
it('exposes a valid semver string from package.json', () => {
expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/);
diff --git a/src/shared/widgets/audio-meter.vue b/src/shared/widgets/audio-meter.vue
deleted file mode 100644
index 7febcce..0000000
--- a/src/shared/widgets/audio-meter.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/shared/widgets/hud/audio-meter.vue b/src/shared/widgets/hud/audio-meter.vue
new file mode 100644
index 0000000..2ff1b36
--- /dev/null
+++ b/src/shared/widgets/hud/audio-meter.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/shared/widgets/live-readout.vue b/src/shared/widgets/ui/live-readout.vue
similarity index 95%
rename from src/shared/widgets/live-readout.vue
rename to src/shared/widgets/ui/live-readout.vue
index e74ea75..6999af7 100644
--- a/src/shared/widgets/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/shared/components/overlay-card.vue b/src/views/components/elements/card.vue
similarity index 65%
rename from src/shared/components/overlay-card.vue
rename to src/views/components/elements/card.vue
index 0496355..b3c54b8 100644
--- a/src/shared/components/overlay-card.vue
+++ b/src/views/components/elements/card.vue
@@ -12,35 +12,36 @@
class="overlay-card"
:class="{ 'is-planned': overlay.status === 'planned' }"
>
-
-
-
-
{{ overlay.brand }}
{{ overlay.name }}
- {{ overlay.status }}
+
+ {{ overlay.status }}
+
@@ -61,37 +62,31 @@
class="card-use-case"
>
WHEN TO USE
-
+
+
+ +{{ extra_use_cases_count }}
+
+
-
- SIZE
-
- {{ overlay.width }} × {{ overlay.height }}
-
-
-
- FPS
- {{ overlay.fps }}
-
-
- CACHE
- DISABLE
-
-
- CSS
- CLEAR
-
+
+
+
+
@@ -111,11 +106,17 @@
REQUIRES
-
{{ req }}
+ -
+ +{{ extra_requires_count }} more
+
@@ -152,24 +153,66 @@
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/components/modals/base.vue b/src/views/components/modals/base.vue
new file mode 100644
index 0000000..12e9c80
--- /dev/null
+++ b/src/views/components/modals/base.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/components/modals/context-control.vue b/src/views/components/modals/context-control.vue
new file mode 100644
index 0000000..e0c4987
--- /dev/null
+++ b/src/views/components/modals/context-control.vue
@@ -0,0 +1,533 @@
+
+
+
+
+
+ {{ overlay.brand }}
+ {{ overlay.name }}
+ CONTROLS
+
+
+
+
+
+ CONTEXTS
+
+ {{ slugs.length }} discovered for {{ overlay.brand }}
+
+
+
+
+ -
+
+
+
+ ACTIVE
+
+
+ ERROR
+
+
+
+
+
+ No .org files discovered. Drop one at
+ {{ overlay.brand }}/data/contexts/<slug>.org
+ to author a context.
+
+
+
+
+
+
+
+
+
+ LIVE PREVIEW
+
+ {{ overlay.width }} × {{ overlay.height }}
+ @ {{ overlay.fps }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/components/modals/detail.vue b/src/views/components/modals/detail.vue
new file mode 100644
index 0000000..adc20a7
--- /dev/null
+++ b/src/views/components/modals/detail.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+ {{ overlay.brand }}
+ {{ overlay.name }}
+
+ {{ overlay.status }}
+
+
+
+
+
+ DESCRIPTION
+
+
+ {{ segment.text }}
+ {{ segment.text }}
+
+
+
+
+
+ USE CASES
+
+
+
+
+ SPECS
+
+
+
+
+
+
+
+
+
+ REQUIREMENTS
+
+ -
+ {{ req }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/components/modals/preview.vue b/src/views/components/modals/preview.vue
new file mode 100644
index 0000000..09d994c
--- /dev/null
+++ b/src/views/components/modals/preview.vue
@@ -0,0 +1,311 @@
+
+
+
+
+
+ {{ overlay.brand }}
+ {{ overlay.name }}
+
+ {{ overlay.width }} × {{ overlay.height }} @ {{ overlay.fps }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TRIGGERS
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/components/sections/footer.vue b/src/views/components/sections/footer.vue
new file mode 100644
index 0000000..bc4fef6
--- /dev/null
+++ b/src/views/components/sections/footer.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/views/components/sections/hero.vue b/src/views/components/sections/hero.vue
new file mode 100644
index 0000000..80814f7
--- /dev/null
+++ b/src/views/components/sections/hero.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+ SYS.LOG
+ RECKIT {{ VERSION_TAG }}
+
+
+
+ {{ ascii_logo }}
+
+ Realtime · Edit-free · Capture ·
+ Kyonax · Integrated · Toolkit
+
+
+
+
+
+
+
+
diff --git a/src/views/components/sections/setup.vue b/src/views/components/sections/setup.vue
new file mode 100644
index 0000000..e2f8956
--- /dev/null
+++ b/src/views/components/sections/setup.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+ QUICK SETUP
+
+
+ -
+ 01
+ Copy URL
+
+ -
+ 02
+ OBS → Sources → + → Browser
+
+ -
+ 03
+ Paste URL
+
+ -
+ 04
+ Match size / FPS
+
+ -
+ 05
+ Clear Custom CSS
+
+ -
+ 06
+ Layer above scene
+
+
+
+
+
+
+
+
diff --git a/src/views/components/sections/sources.vue b/src/views/components/sections/sources.vue
new file mode 100644
index 0000000..d5490d0
--- /dev/null
+++ b/src/views/components/sections/sources.vue
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/views/components/sections/stats.vue b/src/views/components/sections/stats.vue
new file mode 100644
index 0000000..720eb9d
--- /dev/null
+++ b/src/views/components/sections/stats.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/views/control.vue b/src/views/control.vue
new file mode 100644
index 0000000..3bb2d70
--- /dev/null
+++ b/src/views/control.vue
@@ -0,0 +1,299 @@
+
+
+
+
+
+ CONTEXT CONTROL
+ RECKIT {{ VERSION_TAG }}
+
+
+
+
+
+
+
+ -
+
+
+
+
+ No .org contexts found. Drop one at
+ <brand>/data/contexts/<slug>.org
+
+
+
+
+
+
+
+
+
diff --git a/src/views/home.vue b/src/views/home.vue
index 7b2275e..dbac62b 100644
--- a/src/views/home.vue
+++ b/src/views/home.vue
@@ -3,176 +3,50 @@
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/
- home — RECKIT landing page. Index of all available browser
- sources with copy-to-clipboard URLs and OBS setup specs.
+ home — RECKIT landing page. Composes 5 top-level sections.
+ Owns brand + count derivation only; each section manages its
+ own internal state. Thin on purpose — see CONTRIBUTING for
+ the views/ architecture (sections / elements / modals).
-->
-
-
diff --git a/src/views/utils/markup.js b/src/views/utils/markup.js
new file mode 100644
index 0000000..a032325
--- /dev/null
+++ b/src/views/utils/markup.js
@@ -0,0 +1,47 @@
+/**
+ * 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/
+ *
+ * markup — inline-markup parsers for view-rendered content.
+ * Topic-based library (Rule J): tokenizes authored strings with
+ * lightweight markers (e.g. ** **, [label](url), `code`) into
+ * structured segments that Vue templates can iterate over.
+ *
+ * Add new markup parsers here as named exports — do NOT create a
+ * new file per parser. Leave typography/CSS-level helpers (font
+ * sizes, tabular nums, letter-spacing resolvers) for a future
+ * typography.js — this file is about PARSING, not STYLING.
+ *
+ * Exports:
+ * parseEmphasis(text) tokenizes ** ** bold markers into segments
+ */
+
+const EMPHASIS_PATTERN = /\*\*(.+?)\*\*/g;
+
+export function parseEmphasis(text) {
+ if (!text) {
+ return [];
+ }
+
+ const segments = [];
+ let last_index = 0;
+
+ for (const match of text.matchAll(EMPHASIS_PATTERN)) {
+ if (match.index > last_index) {
+ segments.push({
+ text: text.slice(last_index, match.index),
+ bold: false,
+ });
+ }
+
+ segments.push({ text: match[1], bold: true });
+ last_index = match.index + match[0].length;
+ }
+
+ if (last_index < text.length) {
+ segments.push({ text: text.slice(last_index), bold: false });
+ }
+
+ return segments;
+}
diff --git a/tools/obs/install-dock.sh b/tools/obs/install-dock.sh
new file mode 100755
index 0000000..d9f3161
--- /dev/null
+++ b/tools/obs/install-dock.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+# 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/
+#
+# install-dock.sh — register the RECKIT control panel as an OBS
+# Custom Browser Dock, the same mechanism Twitch uses for its
+# in-OBS panels.
+#
+# OBS rewrites global.ini on shutdown, so it MUST NOT be running
+# while this edits the file — the script refuses otherwise.
+#
+# Usage: tools/obs/install-dock.sh [url] [title]
+# Default url: http://localhost:5173/control
+# Default title: RECKIT Context
+
+set -euo pipefail
+
+URL="${1:-http://localhost:5173/control}"
+TITLE="${2:-RECKIT Context}"
+INI="$HOME/.config/obs-studio/global.ini"
+
+if pgrep -x obs >/dev/null 2>&1; then
+ echo "ERROR: OBS is running (pid $(pgrep -x obs | tr '\n' ' '))." >&2
+ echo " Quit OBS first — it overwrites global.ini on exit," >&2
+ echo " which would silently discard this change." >&2
+ exit 1
+fi
+
+if [[ ! -f "$INI" ]]; then
+ echo "ERROR: $INI not found. Launch OBS once, then quit it." >&2
+ exit 1
+fi
+
+BACKUP="$INI.bak.$(date +%Y%m%d-%H%M%S)"
+cp "$INI" "$BACKUP"
+echo "backup -> $BACKUP"
+
+URL="$URL" TITLE="$TITLE" INI="$INI" python3 <<'PY'
+import json
+import os
+import re
+import uuid
+
+ini = os.environ["INI"]
+url = os.environ["URL"]
+title = os.environ["TITLE"]
+
+with open(ini, "r", encoding="utf-8") as handle:
+ lines = handle.read().splitlines()
+
+key_re = re.compile(r"^ExtraBrowserDocks=(.*)$")
+
+docks = []
+key_index = None
+for i, line in enumerate(lines):
+ match = key_re.match(line)
+ if match:
+ key_index = i
+ try:
+ docks = json.loads(match.group(1))
+ except ValueError:
+ docks = []
+ break
+
+# Replace an existing entry with the same title, else append.
+docks = [d for d in docks if d.get("title") != title]
+docks.append({"title": title, "url": url, "uuid": uuid.uuid4().hex})
+encoded = "ExtraBrowserDocks=" + json.dumps(docks, separators=(",", ":"))
+
+if key_index is not None:
+ lines[key_index] = encoded
+ action = "updated existing ExtraBrowserDocks"
+else:
+ try:
+ section = lines.index("[BasicWindow]")
+ lines.insert(section + 1, encoded)
+ action = "inserted into existing [BasicWindow]"
+ except ValueError:
+ if lines and lines[-1].strip():
+ lines.append("")
+ lines.append("[BasicWindow]")
+ lines.append(encoded)
+ action = "created [BasicWindow] section"
+
+with open(ini, "w", encoding="utf-8") as handle:
+ handle.write("\n".join(lines) + "\n")
+
+print(f"{action}: {len(docks)} dock(s) registered")
+for d in docks:
+ print(f" - {d['title']} -> {d['url']}")
+PY
+
+echo
+echo "Done. Start OBS — the dock appears under View -> Docks."
diff --git a/tools/obs/reckit-context.py b/tools/obs/reckit-context.py
new file mode 100644
index 0000000..fe9c2e2
--- /dev/null
+++ b/tools/obs/reckit-context.py
@@ -0,0 +1,493 @@
+# 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/
+
+# __ __ __ _ __
+# / /_/ / ___ / / ____ (_)__ / /_
+# / __/ _ \/ -_) / _ \/ __/ / / _ \/ __/
+# \__/_//_/\__/ /_.__/_/ /_/_//_/\__/
+#
+# reckit-context.py — OBS front-end script (the joint)
+# 2026-08-31
+#
+# Drives the RECKIT context-screen web source from inside OBS. Two
+# modes, both live:
+#
+# FILE MODE — pick one of the .org files discovered on disk.
+# LIVE TEXT — type the title / description / talking points / body
+# directly in this panel. The text is assembled into a
+# valid .org document, pushed over the relay and parsed
+# in the browser source at runtime, so the full .org
+# feature set (headings, lists, checkboxes, tables,
+# code blocks, GitHub-style alerts) is available to
+# text typed here. Live text OVERRIDES file mode while
+# "Use live text" is ticked.
+#
+# Talks to the Vite dev-server relay documented in session file §1.16:
+# GET/POST JSON at /__context_state, shape
+# { "active_slug": str|null, "sidebar_open": bool, "draft_org": str }.
+#
+# Tools -> Scripts -> + -> select this file
+#
+# Guidelines:
+# Never block the UI thread — every request is timeout-bounded
+# Contexts are discovered from disk, not hardcoded
+# Relay base URL is configurable (dev server port may drift)
+#
+# Cristian D. Moreno (Kyonax)
+# kyonax.corp@gmail.com
+
+import json
+import os
+import urllib.error
+import urllib.request
+
+import obspython as obs
+
+RELAY_PATH = "/__context_state"
+REQUEST_TIMEOUT_S = 1.5
+
+DEFAULT_BASE_URL = "http://localhost:5173"
+DEFAULT_CONTEXTS_DIR = (
+ "/run/media/kyonax/Da_ Disk/dev/github-kyonax/kyo-recording-automation/@kyonax_on_tech/data/contexts"
+)
+
+base_url = DEFAULT_BASE_URL
+contexts_dir = DEFAULT_CONTEXTS_DIR
+active_slug = ""
+sidebar_open = False
+
+# Live-text draft fields.
+use_live_text = False
+draft_title = ""
+draft_subtitle = ""
+draft_description = ""
+draft_tags = ""
+draft_marquee = ""
+draft_body = ""
+save_slug = "live-note"
+
+# cam-log HUD: the bracketed label on its top-right row, e.g. [SESSION].
+# Empty string lets the overlay use its own default.
+cam_label = ""
+
+# slug -> display title, rebuilt by refresh_contexts()
+context_titles = {}
+context_slugs = []
+
+hotkey_ids = {}
+HOTKEYS = {
+ "reckit_toggle_sidebar": "RECKIT: toggle context sidebar",
+ "reckit_next_context": "RECKIT: next context",
+ "reckit_prev_context": "RECKIT: previous context",
+ "reckit_clear_context": "RECKIT: clear context",
+}
+
+
+# ------------------------------------------------------------ draft build
+
+def build_draft_org():
+ """Assemble the panel fields into a valid .org document.
+
+ Returns "" when live text is off or has no title — the overlay
+ treats an empty draft as "fall back to the selected file".
+ `#+TITLE:` and `#+DESCRIPTION:` are required by the parser's schema
+ lock, so a description is always emitted even when blank.
+ """
+ if not use_live_text:
+ return ""
+ if not draft_title.strip():
+ return ""
+
+ lines = [f"#+TITLE: {draft_title.strip()}"]
+ if draft_subtitle.strip():
+ lines.append(f"#+SUBTITLE: {draft_subtitle.strip()}")
+ lines.append(f"#+DESCRIPTION: {draft_description.strip()}")
+
+ tags = [t.strip() for t in draft_tags.replace(",", ":").split(":")]
+ tags = [t for t in tags if t]
+ if tags:
+ lines.append("#+TAGS: :" + ":".join(tags) + ":")
+
+ marquee = [m.strip() for m in draft_marquee.splitlines()]
+ marquee = [m for m in marquee if m]
+ if marquee:
+ lines.append("")
+ lines.append("#+begin_marquee")
+ lines.extend(marquee)
+ lines.append("#+end_marquee")
+
+ if draft_body.strip():
+ lines.append("")
+ lines.append(draft_body.rstrip())
+
+ return "\n".join(lines) + "\n"
+
+
+# ---------------------------------------------------------------- relay
+
+def relay_url():
+ return base_url.rstrip("/") + RELAY_PATH
+
+
+def relay_get():
+ """Return current relay state, or None when unreachable."""
+ try:
+ req = urllib.request.Request(relay_url(), method="GET")
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as res:
+ return json.loads(res.read().decode("utf-8"))
+ except (urllib.error.URLError, OSError, ValueError) as err:
+ obs.script_log(obs.LOG_WARNING, f"[reckit] relay GET failed: {err}")
+ return None
+
+
+def push():
+ """Push a full state snapshot. Returns True on success."""
+ draft = build_draft_org()
+ payload = json.dumps({
+ "active_slug": active_slug if active_slug else None,
+ "sidebar_open": bool(sidebar_open),
+ "draft_org": draft,
+ "cam_label": cam_label,
+ }).encode("utf-8")
+ try:
+ req = urllib.request.Request(
+ relay_url(),
+ data=payload,
+ method="POST",
+ headers={"Content-Type": "application/json"},
+ )
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S):
+ pass
+ mode = f"live({len(draft)}b)" if draft else (active_slug or "(none)")
+ obs.script_log(
+ obs.LOG_INFO,
+ f"[reckit] pushed {mode} sidebar={sidebar_open} "
+ f"cam=[{cam_label or 'SESSION'}]",
+ )
+ return True
+ except (urllib.error.URLError, OSError) as err:
+ obs.script_log(obs.LOG_WARNING, f"[reckit] relay POST failed: {err}")
+ return False
+
+
+# ------------------------------------------------------------- discovery
+
+def read_title(path):
+ """Pull #+TITLE: from an .org file; fall back to the filename."""
+ try:
+ with open(path, "r", encoding="utf-8") as handle:
+ for _ in range(40):
+ line = handle.readline()
+ if not line:
+ break
+ stripped = line.strip()
+ if stripped.upper().startswith("#+TITLE:"):
+ return stripped.split(":", 1)[1].strip()
+ except OSError:
+ pass
+ return os.path.splitext(os.path.basename(path))[0]
+
+
+def refresh_contexts():
+ """Rescan the contexts directory for .org files."""
+ global context_titles, context_slugs
+ context_titles = {}
+ context_slugs = []
+ if not os.path.isdir(contexts_dir):
+ obs.script_log(
+ obs.LOG_WARNING,
+ f"[reckit] contexts dir not found: {contexts_dir}",
+ )
+ return
+ for name in sorted(os.listdir(contexts_dir)):
+ if not name.endswith(".org"):
+ continue
+ slug = name[: -len(".org")]
+ context_slugs.append(slug)
+ context_titles[slug] = read_title(os.path.join(contexts_dir, name))
+ obs.script_log(
+ obs.LOG_INFO,
+ f"[reckit] discovered {len(context_slugs)} context(s)",
+ )
+
+
+def step_context(delta):
+ """Move the selection through the discovered list, wrapping."""
+ global active_slug
+ if not context_slugs:
+ refresh_contexts()
+ if not context_slugs:
+ return
+ if active_slug in context_slugs:
+ index = (context_slugs.index(active_slug) + delta) % len(context_slugs)
+ else:
+ index = 0 if delta > 0 else len(context_slugs) - 1
+ active_slug = context_slugs[index]
+ push()
+
+
+# --------------------------------------------------------------- hotkeys
+
+def on_toggle_sidebar(pressed):
+ global sidebar_open
+ if not pressed:
+ return
+ sidebar_open = not sidebar_open
+ push()
+
+
+def on_next_context(pressed):
+ if pressed:
+ step_context(1)
+
+
+def on_prev_context(pressed):
+ if pressed:
+ step_context(-1)
+
+
+def on_clear_context(pressed):
+ global active_slug
+ if not pressed:
+ return
+ active_slug = ""
+ push()
+
+
+HOTKEY_CALLBACKS = {
+ "reckit_toggle_sidebar": on_toggle_sidebar,
+ "reckit_next_context": on_next_context,
+ "reckit_prev_context": on_prev_context,
+ "reckit_clear_context": on_clear_context,
+}
+
+
+# ------------------------------------------------------------- ui buttons
+
+def button_apply(props, prop):
+ push()
+ return True
+
+
+def button_refresh(props, prop):
+ refresh_contexts()
+ return True
+
+
+def button_open(props, prop):
+ global sidebar_open
+ sidebar_open = True
+ push()
+ return True
+
+
+def button_close(props, prop):
+ global sidebar_open
+ sidebar_open = False
+ push()
+ return True
+
+
+def button_clear(props, prop):
+ global active_slug
+ active_slug = ""
+ push()
+ return True
+
+
+def button_pull(props, prop):
+ """Adopt whatever the relay currently holds (landing page wins)."""
+ global active_slug, sidebar_open
+ state = relay_get()
+ if state is None:
+ return True
+ active_slug = state.get("active_slug") or ""
+ sidebar_open = bool(state.get("sidebar_open"))
+ obs.script_log(
+ obs.LOG_INFO,
+ f"[reckit] pulled slug={active_slug or '(none)'} "
+ f"sidebar={sidebar_open}",
+ )
+ return True
+
+
+def button_save_draft(props, prop):
+ """Persist the live draft to /.org."""
+ draft = build_draft_org()
+ if not draft:
+ obs.script_log(
+ obs.LOG_WARNING,
+ "[reckit] nothing to save — tick 'Use live text' and set a title",
+ )
+ return True
+ slug = save_slug.strip() or "live-note"
+ slug = "".join(c for c in slug if c.isalnum() or c in "-_").lower()
+ if not os.path.isdir(contexts_dir):
+ obs.script_log(
+ obs.LOG_WARNING, f"[reckit] contexts dir missing: {contexts_dir}")
+ return True
+ path = os.path.join(contexts_dir, f"{slug}.org")
+ try:
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(draft)
+ obs.script_log(obs.LOG_INFO, f"[reckit] saved {path}")
+ except OSError as err:
+ obs.script_log(obs.LOG_WARNING, f"[reckit] save failed: {err}")
+ return True
+ refresh_contexts()
+ return True
+
+
+# --------------------------------------------------------- obs lifecycle
+
+def script_description():
+ return (
+ "RECKIT — Context Screen control
"
+ "Drive the context-screen browser source without "
+ "leaving OBS.
"
+ "File mode — pick a discovered .org file.
"
+ "Live text — tick Use live text and just type. "
+ "The overlay updates as you write; the Body field accepts full "
+ "org markup (headings, - lists, "
+ "- [ ] checkboxes, tables, "
+ "#+begin_src blocks).
"
+ "Requires the RECKIT dev server — set the base URL to match "
+ "its port. Bind the four RECKIT: actions in "
+ "Settings → Hotkeys."
+ )
+
+
+def script_defaults(settings):
+ obs.obs_data_set_default_string(settings, "base_url", DEFAULT_BASE_URL)
+ obs.obs_data_set_default_string(
+ settings, "contexts_dir", DEFAULT_CONTEXTS_DIR)
+ obs.obs_data_set_default_bool(settings, "sidebar_open", False)
+ obs.obs_data_set_default_bool(settings, "use_live_text", False)
+ obs.obs_data_set_default_string(settings, "save_slug", "live-note")
+ obs.obs_data_set_default_string(settings, "cam_label", "")
+
+
+def script_properties():
+ props = obs.obs_properties_create()
+
+ obs.obs_properties_add_text(
+ props, "base_url", "Relay base URL", obs.OBS_TEXT_DEFAULT)
+ obs.obs_properties_add_path(
+ props, "contexts_dir", "Contexts folder",
+ obs.OBS_PATH_DIRECTORY, "", contexts_dir)
+
+ picker = obs.obs_properties_add_list(
+ props, "context_slug", "Active context (file)",
+ obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING)
+ obs.obs_property_list_add_string(picker, "— none —", "")
+ for slug in context_slugs:
+ label = f"{context_titles.get(slug, slug)} ({slug})"
+ obs.obs_property_list_add_string(picker, label, slug)
+
+ obs.obs_properties_add_bool(props, "sidebar_open", "Sidebar open")
+
+ # ---- cam-log --------------------------------------------------------
+ obs.obs_properties_add_text(
+ props, "cam_label", "cam-log [BRACKET] label", obs.OBS_TEXT_DEFAULT)
+
+ # ---- live text -----------------------------------------------------
+ obs.obs_properties_add_bool(
+ props, "use_live_text", "Use live text (overrides file)")
+ obs.obs_properties_add_text(
+ props, "draft_title", "Title", obs.OBS_TEXT_DEFAULT)
+ obs.obs_properties_add_text(
+ props, "draft_subtitle", "Subtitle", obs.OBS_TEXT_DEFAULT)
+ obs.obs_properties_add_text(
+ props, "draft_description", "Description", obs.OBS_TEXT_MULTILINE)
+ obs.obs_properties_add_text(
+ props, "draft_tags", "Tags (comma separated)", obs.OBS_TEXT_DEFAULT)
+ obs.obs_properties_add_text(
+ props, "draft_marquee", "Talking points (one per line)",
+ obs.OBS_TEXT_MULTILINE)
+ obs.obs_properties_add_text(
+ props, "draft_body", "Body (org markup)", obs.OBS_TEXT_MULTILINE)
+
+ obs.obs_properties_add_button(
+ props, "btn_apply", "Apply now", button_apply)
+ obs.obs_properties_add_button(
+ props, "btn_open", "Sidebar: OPEN", button_open)
+ obs.obs_properties_add_button(
+ props, "btn_close", "Sidebar: CLOSE", button_close)
+
+ obs.obs_properties_add_text(
+ props, "save_slug", "Save as slug", obs.OBS_TEXT_DEFAULT)
+ obs.obs_properties_add_button(
+ props, "btn_save", "Save live text as .org file", button_save_draft)
+
+ obs.obs_properties_add_button(
+ props, "btn_refresh", "Rescan contexts folder", button_refresh)
+ obs.obs_properties_add_button(
+ props, "btn_clear", "Clear file context", button_clear)
+ obs.obs_properties_add_button(
+ props, "btn_pull", "Pull state from relay", button_pull)
+
+ return props
+
+
+def script_update(settings):
+ global base_url, contexts_dir, active_slug, sidebar_open
+ global use_live_text, draft_title, draft_subtitle, draft_description
+ global draft_tags, draft_marquee, draft_body, save_slug, cam_label
+
+ new_dir = obs.obs_data_get_string(settings, "contexts_dir")
+ base_url = obs.obs_data_get_string(settings, "base_url")
+ save_slug = obs.obs_data_get_string(settings, "save_slug")
+
+ before = (
+ active_slug, sidebar_open, use_live_text, draft_title,
+ draft_subtitle, draft_description, draft_tags, draft_marquee,
+ draft_body, cam_label,
+ )
+
+ active_slug = obs.obs_data_get_string(settings, "context_slug")
+ sidebar_open = obs.obs_data_get_bool(settings, "sidebar_open")
+ use_live_text = obs.obs_data_get_bool(settings, "use_live_text")
+ draft_title = obs.obs_data_get_string(settings, "draft_title")
+ draft_subtitle = obs.obs_data_get_string(settings, "draft_subtitle")
+ draft_description = obs.obs_data_get_string(settings, "draft_description")
+ draft_tags = obs.obs_data_get_string(settings, "draft_tags")
+ draft_marquee = obs.obs_data_get_string(settings, "draft_marquee")
+ draft_body = obs.obs_data_get_string(settings, "draft_body")
+ cam_label = obs.obs_data_get_string(settings, "cam_label")
+
+ after = (
+ active_slug, sidebar_open, use_live_text, draft_title,
+ draft_subtitle, draft_description, draft_tags, draft_marquee,
+ draft_body, cam_label,
+ )
+
+ dir_changed = new_dir != contexts_dir
+ contexts_dir = new_dir
+ if dir_changed:
+ refresh_contexts()
+
+ if before != after:
+ push()
+
+
+def script_load(settings):
+ global contexts_dir, base_url
+ contexts_dir = obs.obs_data_get_string(settings, "contexts_dir")
+ base_url = obs.obs_data_get_string(settings, "base_url")
+ refresh_contexts()
+
+ for name, description in HOTKEYS.items():
+ hotkey_id = obs.obs_hotkey_register_frontend(
+ name, description, HOTKEY_CALLBACKS[name])
+ hotkey_ids[name] = hotkey_id
+ saved = obs.obs_data_get_array(settings, name + "_hotkey")
+ obs.obs_hotkey_load(hotkey_id, saved)
+ obs.obs_data_array_release(saved)
+
+
+def script_save(settings):
+ for name, hotkey_id in hotkey_ids.items():
+ saved = obs.obs_hotkey_save(hotkey_id)
+ obs.obs_data_set_array(settings, name + "_hotkey", saved)
+ obs.obs_data_array_release(saved)
diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json
new file mode 100644
index 0000000..4f46ba6
--- /dev/null
+++ b/tsconfig.eslint.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": false,
+ "noEmit": true,
+ "strict": false,
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "paths": {
+ "@shared/*": ["src/shared/*"],
+ "@views/*": ["src/views/*"],
+ "@app/*": ["src/app/*"],
+ "@assets/*": [".github/assets/*"]
+ }
+ },
+ "include": ["src/**/*.js", "src/**/*.mjs", "src/**/*.vue", "@*/**/*.js", "@*/**/*.vue", "eslint.config.mjs", "vite.config.js"]
+}
diff --git a/vite.config.js b/vite.config.js
index 5975dde..3f85694 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -4,17 +4,43 @@
* 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/
+ */
+
+/**
+ * __ __ ___
+ * / /_/ / ___ / _/__ _______ ____
+ * / __/ _ \/ -_) / _/ _ \/ __/ _ `/ -_)
+ * \__/_//_/\__/ /_/ \___/_/ \_, /\__/
+ * /___/
+ *
+ * vite.config.js — Build, dev server and test pipeline
+ * 2026-04-17
+ *
+ * Main build configuration for the RECKIT Vue 3 app. Injects the
+ * version from package.json and hosts the Vitest test config in
+ * the same file to reuse the plugin pipeline.
*
- * Vite config. package.json is the single source of truth for the
- * version; it's injected into the bundle as __APP_VERSION__ so UI
- * components can display it without hardcoding a string.
+ * Plugins (Vue 3)
+ * define: __APP_VERSION__
+ * Server config (port 5173)
+ * Vitest config (environment, globals, coverage)
*
- * Also hosts the Vitest config (reuses Vite's plugin + transform
- * pipeline — zero extra tooling cost). See the `test.include`
- * block below for the test-file pattern.
+ * Guidelines:
+ * Version comes from package.json only, never hardcode
+ * New plugins go in the plugins array, not separate configs
+ * Test patterns colocated next to source files
+ *
+ * Requirements:
+ * Shared by Vite and Vitest, no separate vitest.config.js
+ * Kill stale dev servers before adding plugins
+ *
+ * Cristian D. Moreno (Kyonax)
+ * kyonax.corp@gmail.com
*/
import { createRequire } from 'node:module';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
@@ -22,10 +48,93 @@ import { defineConfig } from 'vite';
const require = createRequire(import.meta.url);
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({
- plugins: [vue()],
+ resolve: {
+ alias: {
+ '@shared': resolve(ROOT, 'src/shared'),
+ '@views': resolve(ROOT, 'src/views'),
+ '@app': resolve(ROOT, 'src/app'),
+ '@assets': resolve(ROOT, '.github/assets'),
+
+ '@sections': resolve(ROOT, 'src/views/components/sections'),
+ '@elements': resolve(ROOT, 'src/views/components/elements'),
+ '@modals': resolve(ROOT, 'src/views/components/modals'),
+
+ '@ui': resolve(ROOT, 'src/shared/components/ui'),
+ '@hud': resolve(ROOT, 'src/shared/components/hud'),
+ '@widgets': resolve(ROOT, 'src/shared/widgets'),
+ '@composables': resolve(ROOT, 'src/shared/composables'),
+ },
+ },
+ plugins: [vue(), context_relay_plugin],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
@@ -36,16 +145,23 @@ export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
- include: ['src/**/*.{test,spec}.{js,mjs}'],
+ include: [
+ 'src/**/*.{test,spec}.{js,mjs}',
+ '@*/**/*.{test,spec}.{js,mjs}',
+ ],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
- include: ['src/**/*.{js,mjs,vue}'],
+ include: [
+ 'src/**/*.{js,mjs,vue}',
+ '@*/**/*.{js,mjs,vue}',
+ ],
exclude: [
'src/main.js',
'src/App.vue',
'src/router.js',
'src/**/*.{test,spec}.{js,mjs}',
+ '@*/**/*.{test,spec}.{js,mjs}',
],
},
},