diff --git a/.env.example b/.env.example index 5f41b08..4d785d1 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,26 @@ +# 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/ + +# __ __ ____ +# / /_/ / ___ _ _____ ___ __/ / /_ +# / __/ _ \/ -_) | |/ / _ `/ // / / __/ +# \__/_//_/\__/ |___/\_,_/\_,_/_/\__/ +# +# .env.example — Environment variable template +# 2026-04-17 +# +# Template for the runtime env vars consumed by the Vue app. +# Copy to .env and fill in your values, VITE_ prefix is required. +# +# VITE_OBS_WS_HOST WebSocket host +# VITE_OBS_WS_PORT WebSocket port +# VITE_OBS_WS_PASS WebSocket password +# VITE_OBS_WS_LAN LAN address +# +# Cristian D. Moreno (Kyonax) +# kyonax.corp@gmail.com + VITE_OBS_WS_HOST=127.0.0.1 VITE_OBS_WS_PORT=4455 VITE_OBS_WS_PASS= diff --git a/.gitattributes b/.gitattributes index e00d8c6..910769d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,25 @@ +# 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/ + +# __ __ __ __ +# / /_/ / ___ / /__ _/ / +# / __/ _ \/ -_) / / _ `/ _ \ +# \__/_//_/\__/ /_/\_,_/_.__/ +# +# .gitattributes — Git file handling rules +# 2026-04-17 +# +# Controls how Git handles specific files on checkout and commit. +# The logo.txt rule prevents Windows CRLF from corrupting the +# Unicode box-drawing glyphs in the ASCII logo. +# +# logo.txt: UTF-8 + LF enforcement +# Default: auto-normalize text to LF +# +# Cristian D. Moreno (Kyonax) +# kyonax.corp@gmail.com + # Preserve box-drawing characters and line endings in the brand logo. # Windows clones would otherwise apply CRLF conversion and potentially # corrupt the Unicode frame glyphs. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 93ea7bd..0b65670 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,23 @@ + + + + + + + + diff --git a/@kyonax_on_tech/sources/hud/context-screen.vue b/@kyonax_on_tech/sources/hud/context-screen.vue new file mode 100644 index 0000000..18b4861 --- /dev/null +++ b/@kyonax_on_tech/sources/hud/context-screen.vue @@ -0,0 +1,697 @@ + + + + + + + + + diff --git a/@kyonax_on_tech/styles/_theme.scss b/@kyonax_on_tech/styles/_theme.scss new file mode 100644 index 0000000..7222863 --- /dev/null +++ b/@kyonax_on_tech/styles/_theme.scss @@ -0,0 +1,42 @@ +// 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/ +// +// Brand theme for @kyonax_on_tech — gold + black cyberpunk palette. +// +// Single source of truth for this brand's visual palette. Auto-loaded +// by src/main.js (eager glob over every brand's theme SCSS file). +// Active whenever App.vue applies `class="brand-kyonax-on-tech"` — +// which happens on any route whose meta.brand resolves to this brand +// (see src/router.js + src/App.vue). +// +// Override any CSS custom property declared by src/app/scss/abstracts +// by redeclaring it inside this class block. Unlisted variables fall +// back to the global default from the project's :root scope. +// +// A new brand can copy this file, rename the class selector to +// `.brand-` (with the @-sign stripped and underscores turned +// into dashes), and tune any subset of variables it wants to override. + +.brand-kyonax-on-tech { + --clr-primary-50: hsl(48, 95%, 78%); + --clr-primary-100: hsl(47, 95%, 56%); + --clr-primary-200: hsl(49, 100%, 39%); + --clr-primary-300: hsl(48, 100%, 29%); + --clr-primary-400: hsl(45, 100%, 20%); + --clr-primary-500: hsl(35, 100%, 13%); + + --clr-secondary-50: hsl(224, 95%, 78%); + --clr-secondary-100: hsl(224, 95%, 56%); + --clr-secondary-200: hsl(224, 74%, 45%); + + --clr-neutral-50: hsl(0, 0%, 95%); + --clr-neutral-100: hsl(0, 0%, 85%); + --clr-neutral-200: hsl(0, 0%, 65%); + --clr-neutral-300: hsl(0, 0%, 45%); + --clr-neutral-400: hsl(0, 0%, 25%); + --clr-neutral-500: hsl(0, 0%, 0%); + + --clr-error-100: hsl(352, 69%, 48%); + --clr-success-100: hsl(91, 62%, 44%); +} diff --git a/CHANGELOG.org b/CHANGELOG.org index 83d248d..0213f68 100644 --- a/CHANGELOG.org +++ b/CHANGELOG.org @@ -1,9 +1,39 @@ +# 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/ + +# __ __ __ +# / /_/ / ___ / /__ ___ ____ +# / __/ _ \/ -_) / / _ \/ _ `(_-< +# \__/_//_/\__/ /_/\___/\_, /___/ +# /___/ +# +# CHANGELOG.org — Release history and pending work +# 2026-04-17 +# +# Tracks every version, decisions made, and the TODO backlog. +# Each release follows: * [vX.Y] — YYYY-MM-DD :: Short Title +# +# [TODO] — Bugs, features, infrastructure +# [Unreleased] +# [v0.3] — Vue App + CAM-LOG Overlay + Landing Index +# [v0.1] — Brand Bootstrap +# +# Guidelines: +# Entries: *Summary:* hook, *** sub-grouped Added/Changed/Removed/Decided +# Decided items: bolded title with rationale +# No arrows, no emojis, no private-file references +# Bare facts only, decisions carry rationale +# +# Cristian D. Moreno (Kyonax) +# kyonax.corp@gmail.com + #+TITLE: RECKIT Changelog #+AUTHOR: [[https://orcid.org/0009-0006-4459-5538][Cristian D. Moreno - Kyonax]] #+EMAIL: kyonax.corp@gmail.com #+DATE: Apr 13, 2026 #+FILETAGS: :KYO:RECKIT:CHANGELOG: -#+LAST_UPDATE: Apr 15, 2026 +#+LAST_UPDATE: Apr 17, 2026 #+OPTIONS: toc:nil num:nil H:4 * [TODO] @@ -60,7 +90,7 @@ *** Overlays - =src/shared/data/overlays.js= — overlay registry schema (=id=, =brand=, =name=, =description=, =use_cases[]=, =path=, dimensions, =requires[]=, =triggers[]=, =status=) -- =src/brands/kyonax-on-tech/cam-person.vue= — CAM-LOG HUD at =/@kyonax_on_tech/cam-person= +- =src/brands/kyonax-on-tech/cam-log.vue= — CAM-LOG HUD at =/@kyonax_on_tech/cam-log= - Dynamic =SES::::T= label driven by scene + take counter - Recording timer (REC text + dot red while recording; MODE + time stay white) - == bound to =Mic/Aux= diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3d77992 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ + + + + +# CLAUDE.md — RECKIT Project Instructions + +## ABSOLUTE PROHIBITION: Git Write Operations + +**NEVER run ANY of these commands under ANY circumstance unless the user EXPLICITLY and DIRECTLY tells you to:** + +- `git commit` (any form, any flags) +- `git push` (any form, any flags) +- `git tag` +- `git merge` +- `git rebase` +- `git reset --hard` +- `git checkout -- .` +- `git restore .` +- `git clean` +- `git stash drop` +- `git branch -D` +- `gh pr merge` +- `gh release create` + +**"Create the commit text" means write to COMMIT.org. It does NOT mean run git commit.** +**"Do the PR" means write to PR.org or generate PR text. It does NOT mean run gh pr create.** + +The user manages all git operations manually. Your job is to generate text, write code, and prepare files. The user commits, pushes, and manages the repository themselves. + +There are ZERO exceptions to this rule. diff --git a/CONTRIBUTING.org b/CONTRIBUTING.org new file mode 100644 index 0000000..47faa6c --- /dev/null +++ b/CONTRIBUTING.org @@ -0,0 +1,175 @@ +# 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/ + +# __ __ __ _ +# / /_/ / ___ ___/ /__ (_)__ +# / __/ _ \/ -_) / _ / _ \ / / _ \ +# \__/_//_/\__/ \_,_/\___/_/ /\___/ +# |___/ +# +# CONTRIBUTING.org — How to set up, develop, and contribute +# 2026-04-17 +# +# Prerequisites, dev environment setup, code conventions, +# branch workflow, CI pipeline, and PR process. +# +# Prerequisites +# Setup +# Code Conventions +# Branch Workflow +# CI Pipeline +# Pull Requests +# Licensing +# +# Cristian D. Moreno (Kyonax) +# kyonax.corp@gmail.com + +#+TITLE: RECKIT Contributing Guide +#+AUTHOR: [[https://orcid.org/0009-0006-4459-5538][Cristian D. Moreno - Kyonax]] +#+EMAIL: kyonax.corp@gmail.com +#+DATE: Apr 17, 2026 +#+FILETAGS: :KYO:RECKIT:CONTRIBUTING: +#+LAST_UPDATE: Apr 17, 2026 +#+OPTIONS: toc:nil num:nil H:3 + +#+BEGIN_QUOTE +*NOTE:* This is a Personal project. RECKIT is built to solve a specific problem for its author. It is not currently designed as a community tool. Contributions are welcome for learning or personal use, but the roadmap and scope are driven by one person's needs. If this ever becomes a viable open-source project, the contribution process will be updated accordingly. +#+END_QUOTE + +* Prerequisites + +| Tool | Version | Check | +|------------+---------------------+--------------------------------------| +| Node.js | =>=20.0.0= | =node --version= | +| npm | ships with Node | =npm --version= | +| Git | any recent | =git --version= | +| OBS Studio | 32.x with WebSocket | =obs-studio-browser= AUR or equivalent | + +OBS is only needed to test overlays live. You can develop the Vue app and landing page without it. + +* Setup + +#+BEGIN_SRC bash +git clone https://github.com/Kyonax/reckit.git +cd reckit +npm ci +#+END_SRC + +Copy the environment template and fill in your values: + +#+BEGIN_SRC bash +cp .env.example .env +#+END_SRC + +The =VITE_= prefix is required for Vite to expose variables to the app. See =.env.example= for the full list. + +Start the dev server: + +#+BEGIN_SRC bash +npm run dev +#+END_SRC + +Open =http://localhost:5173/= for the landing page. + +** Available scripts + +| Script | Command | Purpose | +|------------+-----------------------+----------------------------| +| Dev server | =npm run dev= | Vite on port 5173 | +| Build | =npm run build= | Production bundle | +| Lint | =npm run lint= | ESLint (errors + warnings) | +| Lint fix | =npm run lint:fix= | Auto-fix fixable issues | +| Test | =npm run test= | Vitest (CI entrypoint) | +| Test watch | =npm run test:watch= | Vitest in watch mode | +| Coverage | =npm run test:coverage= | v8 coverage report | + +* Code Conventions +Enforced by [[file:eslint.config.mjs][eslint.config.mjs]] ([[https://github.com/ccs-devhub][CCS]] standards). + +** Naming + +| Scope | Style | Example | +|-----------------------+------------+-------------------| +| Variables, parameters | =snake_case= | =overlay_timer= | +| Functions, methods | =camelCase= | =startOverlay()= | +| Constants | =UPPER_CASE= | =MAX_RETRY= | +| Classes | =PascalCase= | =HudController= | +| Filenames | =kebab-case= | =scene-switcher.js= | +| Vue component imports | =PascalCase= | =OverlayCard= | + +These are machine-enforced via =@typescript-eslint/naming-convention=. + +** Vue + +- Composition API with = + diff --git a/src/app/fonts/Geomanist/GeomanistBold.ttf b/src/app/fonts/Geomanist/GeomanistBold.ttf new file mode 100644 index 0000000..7e85856 Binary files /dev/null and b/src/app/fonts/Geomanist/GeomanistBold.ttf differ diff --git a/src/app/fonts/Geomanist/GeomanistItalic.ttf b/src/app/fonts/Geomanist/GeomanistItalic.ttf new file mode 100644 index 0000000..1e75211 Binary files /dev/null and b/src/app/fonts/Geomanist/GeomanistItalic.ttf differ diff --git a/src/app/fonts/Geomanist/GeomanistRegular.ttf b/src/app/fonts/Geomanist/GeomanistRegular.ttf new file mode 100644 index 0000000..63d0aa6 Binary files /dev/null and b/src/app/fonts/Geomanist/GeomanistRegular.ttf differ diff --git a/src/app/scss/abstracts/_mixins.scss b/src/app/scss/abstracts/_mixins.scss index 304cd37..565b762 100644 --- a/src/app/scss/abstracts/_mixins.scss +++ b/src/app/scss/abstracts/_mixins.scss @@ -5,7 +5,6 @@ */ @use "sass:map"; -@use "sass:math"; @use "variables" as *; @mixin font-face($name, $path, $weight: normal, $style: normal) { @@ -25,50 +24,146 @@ } } -@mixin max-media-query($key) { - $size: map.get($breakpoints, $key); - - @media only screen and (max-width: $size) { - @content; - } -} - -@mixin hud-label-base { - position: absolute; +@mixin hud-text-base { font-family: var(--font-mono); font-size: var(--fs-475); text-transform: uppercase; letter-spacing: 2px; - color: var(--clr-neutral-50); + color: var(--clr-neutral-100); } -@mixin cyberpunk-glow( - $glow-color: hsl(47, 95%, 56%), - $blur: 15px, - $spread: 5px, - $animated: true, - $speed: 1.5s, - $pulse-variation: 0.2 -) { - box-shadow: 0 0 $spread $blur $glow-color; +@mixin hud-label-base { + @include hud-text-base; + position: absolute; +} - @if $animated { - $index: math.floor(calc(math.random() * 1000000)); +// Ad-hoc color helpers for cases where a dedicated --clr-X-XX token does +// not exist. Always prefer a named token (e.g. var(--clr-primary-100-06)) +// when available — these helpers are for one-off variants only. Each +// produces a runtime color-mix() expression compatible with CSS custom +// properties. +// +// Usage: +// background: alpha-tint(--clr-primary-100, 6%); +// color: darken(--clr-primary-100, 30%); +// border: 1px solid lighten(--clr-neutral-100, 20%); +@function alpha-tint($color-var, $percent) { + @return color-mix(in srgb, var(#{$color-var}) #{$percent}, transparent); +} - animation: cyberpunk-pulse-#{$index} $speed ease-in-out infinite alternate; +@function darken($color-var, $by) { + @return color-mix(in srgb, var(#{$color-var}) #{100% - $by}, black); +} - @keyframes cyberpunk-pulse-#{$index} { - 0% { - box-shadow: 0 0 calc(#{$spread} / 2) calc(#{$blur} / 2) $glow-color; - } +@function lighten($color-var, $by) { + @return color-mix(in srgb, var(#{$color-var}) #{100% - $by}, white); +} - 50% { - box-shadow: 0 0 calc(#{$spread} * (1.5 + $pulse-variation)) calc(#{$blur} * (1.5 + $pulse-variation)) $glow-color; - } +// Adds hard-edge corner squares to a container — a HUD/cyberpunk +// treatment that pairs with a 1px solid border. Expects the consumer +// to set its own background-color + border; this mixin only paints +// the corner markers via background-image. +// +// Usage: +// .container { +// background-color: var(--clr-neutral-500); +// border: 1px solid var(--clr-border-100); +// @include corner-dots(var(--clr-border-100)); // all 4 +// @include corner-dots(var(--clr-border-100), 4.5px, tl bl); // left only +// } +// +// Parameters: +// $color — solid CSS color (e.g. var(--clr-border-100)) +// $size — square edge length, default 4.5px +// $corners — space-separated list of corner keys to draw: +// tl top-left +// tr top-right +// bl bottom-left +// br bottom-right +// Default: tl tr bl br (all four). +// +// Implementation: stacked linear-gradient background-image layers, one +// per requested corner. background-origin: border-box places position +// 0 0 / 100% 100% at the OUTER border edge, so each square sits flush +// with the corner intersection rather than inset by the border width. +@mixin corner-dots($color, $size: 4.5px, $corners: tl tr bl br) { + $position-map: ( + tl: 0 0, + tr: 100% 0, + bl: 0 100%, + br: 100% 100%, + ); + $images: (); + $positions: (); - 100% { - box-shadow: 0 0 calc(#{$spread} / 2) calc(#{$blur} / 2) $glow-color; - } + @each $corner in $corners { + $pos: map.get($position-map, $corner); + @if $pos { + $images: append( + $images, + linear-gradient(#{$color}, #{$color}), + $separator: comma + ); + $positions: append($positions, $pos, $separator: comma); } } + + @if length($images) > 0 { + background-image: $images; + background-position: $positions; + background-size: #{$size} #{$size}; + background-repeat: no-repeat; + background-origin: border-box; + background-clip: border-box; + } } + +// Pseudo-element corner squares — half outside, half inside the +// container so the square sits CENTERED on the corner intersection. +// +// IMPORTANT: the consumer must be a positioned element (any of +// `position: relative | absolute | fixed | sticky`). The mixin does +// NOT set `position: relative` itself — that would clobber existing +// `position: absolute` consumers (e.g. a sidebar pinned to the right +// edge). The consumer must also NOT use `contain: paint`, which would +// clip pseudo-elements rendered outside the box. +// +// Each mixin handles ONE corner via either ::before or ::after, so a +// surface needing two corners (e.g. strip = TL + TR) calls both +// `corner-square-tl` and `corner-square-tr`. +// +// Usage: +// .container { +// position: relative; // or absolute / fixed +// border: 1px solid var(--clr-border-100); +// @include corner-square-tl(var(--clr-neutral-50)); +// @include corner-square-tr(var(--clr-neutral-50)); +// } +@mixin corner-square-tl($color, $size: 4.5px) { + &::before { + content: ''; + position: absolute; + top: -#{$size * 0.5}; + left: -#{$size * 0.5}; + width: $size; + height: $size; + background: $color; + pointer-events: none; + z-index: 1; + } +} + +@mixin corner-square-tr($color, $size: 4.5px) { + &::after { + content: ''; + position: absolute; + top: -#{$size * 0.5}; + right: -#{$size * 0.5}; + width: $size; + height: $size; + background: $color; + pointer-events: none; + z-index: 1; + } +} + diff --git a/src/app/scss/abstracts/_theme.scss b/src/app/scss/abstracts/_theme.scss index adc042b..0c36400 100644 --- a/src/app/scss/abstracts/_theme.scss +++ b/src/app/scss/abstracts/_theme.scss @@ -35,11 +35,49 @@ } --font-mono: "SpaceMono", "Roboto Mono", "Courier New", Courier, monospace; + --font-display: "Geomanist", "Inter", "Helvetica Neue", Arial, sans-serif; --canvas-width: 1920px; --canvas-height: 1080px; --hud-bar-offset: 24px; --hud-bar-gap: 24px; + + --surface-bg: linear-gradient(135deg, hsl(0 0% 2%) 0%, hsl(0 0% 7%) 50%, hsl(0 0% 14%) 100%); + + --motion-sidebar-ms: 280ms; + --motion-strip-ease: cubic-bezier(0.22, 1, 0.36, 1); + --motion-peek-pulse-s: 2.5s; + --motion-marquee-s: 90s; + + --clr-primary-100-02: color-mix(in srgb, var(--clr-primary-100) 2%, transparent); + --clr-primary-100-03: color-mix(in srgb, var(--clr-primary-100) 3%, transparent); + --clr-primary-100-04: color-mix(in srgb, var(--clr-primary-100) 4%, transparent); + --clr-primary-100-06: color-mix(in srgb, var(--clr-primary-100) 6%, transparent); + --clr-primary-100-10: color-mix(in srgb, var(--clr-primary-100) 10%, transparent); + --clr-primary-100-14: color-mix(in srgb, var(--clr-primary-100) 14%, transparent); + --clr-primary-100-40: color-mix(in srgb, var(--clr-primary-100) 40%, transparent); + --clr-primary-100-80: color-mix(in srgb, var(--clr-primary-100) 80%, transparent); + + --clr-neutral-50-02: color-mix(in srgb, var(--clr-neutral-50) 2%, transparent); + --clr-neutral-50-04: color-mix(in srgb, var(--clr-neutral-50) 4%, transparent); + --clr-neutral-50-12: color-mix(in srgb, var(--clr-neutral-50) 12%, transparent); + + --hud-halo: + drop-shadow(0 0 1px rgba(0, 0, 0, 0.9)) + drop-shadow(0 0 3px rgba(0, 0, 0, 0.6)) + drop-shadow(0 0 6px rgba(0, 0, 0, 0.45)); + + --hud-halo-text: + 0 0 1px rgba(0, 0, 0, 0.9), + 0 0 3px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(0, 0, 0, 0.45); + + --hud-glow-color: var(--clr-primary-100); + --hud-glow: + 0 0 1px color-mix(in srgb, var(--hud-glow-color) 75%, transparent), + 0 0 5px color-mix(in srgb, var(--hud-glow-color) 50%, transparent); + + --hud-group-gap: 0.6em; } ::selection { @@ -51,13 +89,3 @@ background-color: var(--clr-primary-100); color: var(--clr-neutral-500); } - -.cyberpunk-glow { - @include cyberpunk-glow( - var(--cyberpunk-glow-color, var(--clr-primary-100)), - var(--cyberpunk-glow-blur, 1px), - var(--cyberpunk-glow-spread, 9px), - true, - var(--cyberpunk-glow-speed, 1.5s) - ); -} diff --git a/src/app/scss/abstracts/_variables.scss b/src/app/scss/abstracts/_variables.scss index 4b9ff3a..5356b0c 100644 --- a/src/app/scss/abstracts/_variables.scss +++ b/src/app/scss/abstracts/_variables.scss @@ -28,6 +28,14 @@ $colors: ( 400: hsl(224, 74%, 34%), 500: hsl(224, 74%, 22%), ), + tertiary: ( + 50: hsl(266, 88%, 78%), + 100: hsl(266, 73%, 56%), + 200: hsl(266, 73%, 45%), + 300: hsl(266, 73%, 39%), + 400: hsl(266, 73%, 28%), + 500: hsl(266, 73%, 17%), + ), neutral: ( 50: hsl(0, 0%, 95%), 100: hsl(0, 0%, 85%), diff --git a/src/app/scss/base/_global.scss b/src/app/scss/base/_global.scss index c5087bd..5a6379c 100644 --- a/src/app/scss/base/_global.scss +++ b/src/app/scss/base/_global.scss @@ -14,9 +14,6 @@ box-sizing: border-box; } -/* 12px base: OBS overlay targets 1920x1080 with tighter - type scale than browser default (16px). Changing this - rescales all rem-based sizing. */ html { font-size: 12px; } @@ -36,7 +33,6 @@ body { min-height: 100vh; } -/* Custom scrollbar — minimal, monochrome */ * { scrollbar-width: thin; scrollbar-color: var(--clr-neutral-300) transparent; diff --git a/src/app/scss/base/_typography.scss b/src/app/scss/base/_typography.scss index d385e9f..226162b 100644 --- a/src/app/scss/base/_typography.scss +++ b/src/app/scss/base/_typography.scss @@ -10,3 +10,7 @@ @include mixins.font-face("SpaceMono", "SpaceMono/SpaceMonoNerdFont-Bold", 700, normal); @include mixins.font-face("SpaceMono", "SpaceMono/SpaceMonoNerdFont-Italic", 400, italic); @include mixins.font-face("SpaceMono", "SpaceMono/SpaceMonoNerdFont-BoldItalic", 700, italic); + +@include mixins.font-face("Geomanist", "Geomanist/GeomanistRegular", 400, normal); +@include mixins.font-face("Geomanist", "Geomanist/GeomanistBold", 700, normal); +@include mixins.font-face("Geomanist", "Geomanist/GeomanistItalic", 400, italic); diff --git a/src/app/scss/components/_index.scss b/src/app/scss/components/_index.scss index 168fd37..ebd7ff1 100644 --- a/src/app/scss/components/_index.scss +++ b/src/app/scss/components/_index.scss @@ -4,4 +4,3 @@ * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ */ -// Component partials — add as HUD components grow diff --git a/src/app/scss/layout/_index.scss b/src/app/scss/layout/_index.scss index c549462..ebd7ff1 100644 --- a/src/app/scss/layout/_index.scss +++ b/src/app/scss/layout/_index.scss @@ -4,4 +4,3 @@ * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ */ -// Layout partials — add as overlays grow diff --git a/src/brands/kyonax-on-tech/cam-person.vue b/src/brands/kyonax-on-tech/cam-person.vue deleted file mode 100644 index dde1e04..0000000 --- a/src/brands/kyonax-on-tech/cam-person.vue +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - - diff --git a/src/main.js b/src/main.js index 136557c..f07b7b3 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,11 @@ import { createApp } from 'vue'; import App from './App.vue'; import { router } from './router.js'; +import.meta.glob( + '/@*/styles/_theme.scss', + { eager: true }, +); + const app = createApp(App); app.use(router); diff --git a/src/router.js b/src/router.js index 8b742fc..f92754c 100644 --- a/src/router.js +++ b/src/router.js @@ -4,21 +4,43 @@ * License, v. 2.0. See LICENSE or https://mozilla.org/MPL/2.0/ */ +import { resolveComponent, SOURCES } from '@shared/brand-loader.js'; import { createRouter, createWebHistory } from 'vue-router'; +const source_routes = SOURCES + .map((source) => { + const loader = resolveComponent(source); + + if (!loader) { + return null; + } + + return { + path: source.path, + name: `${source.brand.replace('@', '')}-${source.id}`, + component: loader, + meta: { + brand: source.brand, + source_id: source.id, + source_type: source.type, + }, + }; + }) + .filter(Boolean); + const routes = [ { path: '/', name: 'home', - component: () => import('./views/home.vue'), + component: () => import('@views/home.vue'), }, { - path: '/@kyonax_on_tech/cam-person', - name: 'kyonax-cam-person', - component: () => import( - './brands/kyonax-on-tech/cam-person.vue' - ), + // Mounted inside OBS as a Custom Browser Dock — see tools/obs/. + path: '/control', + name: 'control', + component: () => import('@views/control.vue'), }, + ...source_routes, { path: '/:pathMatch(.*)*', name: 'blank', diff --git a/src/shared/assets/svg/corner-bracket.svg b/src/shared/assets/svg/corner-bracket.svg new file mode 100644 index 0000000..eec2a68 --- /dev/null +++ b/src/shared/assets/svg/corner-bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/brand-loader.js b/src/shared/brand-loader.js new file mode 100644 index 0000000..44cfc3d --- /dev/null +++ b/src/shared/brand-loader.js @@ -0,0 +1,108 @@ +/** + * 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/ + * + * brand-loader — auto-discovers @brand/ folders at the project + * root and merges their metadata + source registries into a + * single runtime object. Uses import.meta.glob for static + * analysis by Vite's bundler. + * + * Also discovers per-brand .org context files at /@/data/ + * contexts/.org, parses each via uniorg-parse at glob-time, + * and exposes them as a CONTEXTS map: { brand → slug → { raw, + * parsed } }. Cached AST means the sidebar renderer has zero + * parse cost per frame (Plan #context-screen, decision D3). + */ + +import { OrgSchemaError, parseOrg } from '@shared/utils/org.js'; + +const BRAND_HANDLE_PATTERN = /^\/(@[^/]+)\/data\/contexts\//; +const ORG_FILENAME_PATTERN = /([^/]+)\.org$/; + +const brand_modules = import.meta.glob( + '/@*/brand.js', + { eager: true, import: 'default' }, +); + +const source_modules = import.meta.glob( + '/@*/sources.js', + { eager: true, import: 'default' }, +); + +const hud_components = import.meta.glob('/@*/sources/hud/*.vue'); +const animation_components = import.meta.glob( + '/@*/sources/animation/*.vue', +); +const scene_components = import.meta.glob('/@*/sources/scene/*.vue'); + +const context_modules = import.meta.glob( + '/@*/data/contexts/*.org', + { eager: true, query: '?raw', import: 'default' }, +); + +export const BRANDS = Object.values(brand_modules); +export const SOURCES = Object.values(source_modules).flat(); +export const CONTEXTS = buildContextsMap(context_modules); + +export function getBrand(handle) { + return BRANDS.find((b) => b.handle === handle) || null; +} + +export function resolveComponent(source) { + const type_map = { + hud: hud_components, + animation: animation_components, + scene: scene_components, + }; + + const components = type_map[source.type]; + + if (!components) { + return null; + } + + const key = `/${source.brand}/sources/${source.type}/${source.id}.vue`; + + return components[key] || null; +} + +export function getContexts(handle) { + return CONTEXTS[handle] || {}; +} + +function buildContextsMap(modules) { + const map = {}; + for (const path of Object.keys(modules)) { + const brand_match = path.match(BRAND_HANDLE_PATTERN); + const slug_match = path.match(ORG_FILENAME_PATTERN); + if (!brand_match || !slug_match) { + continue; + } + const brand_handle = brand_match[1]; + const slug = slug_match[1]; + const raw = modules[path]; + + let parsed = null; + let parse_error = null; + try { + parsed = parseOrg(raw); + } catch (error) { + if (error instanceof OrgSchemaError) { + parse_error = { + name: error.name, + message: error.message, + missing_keys: error.missing_keys, + }; + } else { + parse_error = { name: 'Error', message: String(error) }; + } + } + + if (!map[brand_handle]) { + map[brand_handle] = {}; + } + map[brand_handle][slug] = { raw, parsed, parse_error }; + } + return map; +} diff --git a/src/shared/brand-loader.test.js b/src/shared/brand-loader.test.js new file mode 100644 index 0000000..bdc9d69 --- /dev/null +++ b/src/shared/brand-loader.test.js @@ -0,0 +1,222 @@ +/** + * 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 brand-loader auto-discovery engine. + * Validates brand metadata and web source schema. + */ + +import { BRANDS, CONTEXTS, getContexts, SOURCES } from '@shared/brand-loader.js'; +import { describe, expect, it } from 'vitest'; + +const REQUIRED_SOURCE_FIELDS = [ + 'id', + 'type', + 'brand', + 'name', + 'description', + 'use_cases', + 'path', + 'width', + 'height', + 'fps', + 'requires', + 'triggers', + 'status', +]; + +const ALLOWED_STATUSES = ['ready', 'planned']; +const ALLOWED_TYPES = ['hud', 'animation', 'scene']; +const EM_DASH = '\u2014'; + +describe('Brand loader — BRANDS', () => { + it('discovers at least one brand', () => { + expect(BRANDS.length).toBeGreaterThan(0); + }); + + it.each(BRANDS)( + 'brand $handle has handle, name, description', + (brand) => { + expect(brand).toHaveProperty('handle'); + expect(brand).toHaveProperty('name'); + expect(brand).toHaveProperty('description'); + expect(typeof brand.handle).toBe('string'); + expect(typeof brand.name).toBe('string'); + expect(typeof brand.description).toBe('string'); + }, + ); + + it.each(BRANDS)( + 'brand $handle has identity with author and display_handle', + (brand) => { + expect(brand).toHaveProperty('identity'); + expect(brand.identity).toHaveProperty('author'); + expect(brand.identity).toHaveProperty('display_handle'); + }, + ); + + it.each(BRANDS)( + 'brand $handle has links object', + (brand) => { + expect(brand).toHaveProperty('links'); + expect(typeof brand.links).toBe('object'); + }, + ); + + it.each(BRANDS)( + 'brand $handle does NOT carry a colors field (theme lives in styles/_theme.scss)', + (brand) => { + expect(brand.colors).toBeUndefined(); + }, + ); + + it('every brand handle is unique', () => { + const handles = BRANDS.map((b) => b.handle); + expect(new Set(handles).size).toBe(handles.length); + }); +}); + +describe('Brand loader — SOURCES', () => { + it('is a non-empty array', () => { + expect(Array.isArray(SOURCES)).toBe(true); + expect(SOURCES.length).toBeGreaterThan(0); + }); + + it('every source composite key (brand/id) is unique', () => { + const keys = SOURCES.map((s) => `${s.brand}/${s.id}`); + expect(new Set(keys).size).toBe(keys.length); + }); + + it.each(SOURCES)( + 'source $id declares every required field', + (source) => { + for (const field of REQUIRED_SOURCE_FIELDS) { + expect(source).toHaveProperty(field); + } + }, + ); + + it.each(SOURCES)( + 'source $id has a valid type', + (source) => { + expect(ALLOWED_TYPES).toContain(source.type); + }, + ); + + it.each(SOURCES)( + 'source $id uses a valid status', + (source) => { + expect(ALLOWED_STATUSES).toContain(source.status); + }, + ); + + it.each(SOURCES)( + 'source $id description has no em dashes', + (source) => { + expect(source.description).not.toContain(EM_DASH); + }, + ); + + it.each(SOURCES)( + 'source $id use_cases is a string array', + (source) => { + expect(Array.isArray(source.use_cases)).toBe(true); + expect( + source.use_cases.every((k) => typeof k === 'string'), + ).toBe(true); + }, + ); + + it.each(SOURCES)( + 'source $id path matches /@brand/id', + (source) => { + expect(source.path).toBe(`/${source.brand}/${source.id}`); + }, + ); + + it.each(SOURCES)( + 'source $id canvas dimensions are positive integers', + (source) => { + expect(Number.isInteger(source.width)).toBe(true); + expect(Number.isInteger(source.height)).toBe(true); + expect(Number.isInteger(source.fps)).toBe(true); + expect(source.width).toBeGreaterThan(0); + expect(source.height).toBeGreaterThan(0); + expect(source.fps).toBeGreaterThan(0); + }, + ); + + it.each(SOURCES)( + 'source $id brand exists in BRANDS', + (source) => { + const handles = BRANDS.map((b) => b.handle); + expect(handles).toContain(source.brand); + }, + ); +}); + +describe('Brand loader — CONTEXTS', () => { + const BRAND_KOT = '@kyonax_on_tech'; + + // Context .org files are per-contributor content and stay untracked + // (see .gitignore). Only the discovery plumbing is asserted here, so a + // clean checkout — CI included — validates the same contract. + + it('exposes CONTEXTS as a brand-keyed object', () => { + expect(typeof CONTEXTS).toBe('object'); + expect(CONTEXTS).not.toBe(null); + }); + + it(`exposes a per-brand map for ${BRAND_KOT}`, () => { + const kot = CONTEXTS[BRAND_KOT] || {}; + expect(typeof kot).toBe('object'); + expect(kot).not.toBe(null); + }); + + it('every context has { raw: string, parsed: object|null }', () => { + for (const brand of Object.keys(CONTEXTS)) { + for (const slug of Object.keys(CONTEXTS[brand])) { + const entry = CONTEXTS[brand][slug]; + expect(typeof entry.raw).toBe('string'); + expect(entry.raw.length).toBeGreaterThan(0); + expect( + entry.parsed === null || typeof entry.parsed === 'object', + ).toBe(true); + } + } + }); + + it('every parsed context (no parse_error) has the locked Q5 schema', () => { + for (const brand of Object.keys(CONTEXTS)) { + for (const slug of Object.keys(CONTEXTS[brand])) { + const entry = CONTEXTS[brand][slug]; + if (entry.parse_error) { + continue; + } + expect(typeof entry.parsed.title).toBe('string'); + expect(typeof entry.parsed.description).toBe('string'); + expect(Array.isArray(entry.parsed.tags)).toBe(true); + expect(Array.isArray(entry.parsed.marquee_items)).toBe(true); + expect(Array.isArray(entry.parsed.body_ast)).toBe(true); + } + } + }); + + it('slugs are unique within each brand', () => { + for (const brand of Object.keys(CONTEXTS)) { + const slugs = Object.keys(CONTEXTS[brand]); + expect(new Set(slugs).size).toBe(slugs.length); + } + }); + + it('getContexts(handle) returns the per-brand map', () => { + const kot = getContexts(BRAND_KOT); + expect(typeof kot).toBe('object'); + expect(kot).not.toBe(null); + }); + + it('getContexts(missing-handle) returns empty object', () => { + expect(getContexts('@no-such-brand')).toEqual({}); + }); +}); diff --git a/src/shared/components/corner-bracket.vue b/src/shared/components/corner-bracket.vue deleted file mode 100644 index 9499eaa..0000000 --- a/src/shared/components/corner-bracket.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - diff --git a/src/shared/components/hud-frame.vue b/src/shared/components/hud/frame.vue similarity index 63% rename from src/shared/components/hud-frame.vue rename to src/shared/components/hud/frame.vue index 9d6f85e..9e7eaa3 100644 --- a/src/shared/components/hud-frame.vue +++ b/src/shared/components/hud/frame.vue @@ -12,15 +12,26 @@ height: `${height}px`, }" > - - - - - -
-
-
-
+ + + + diff --git a/src/shared/components/preview-modal.vue b/src/shared/components/preview-modal.vue deleted file mode 100644 index 02c1728..0000000 --- a/src/shared/components/preview-modal.vue +++ /dev/null @@ -1,395 +0,0 @@ - - -