├── .gitignore ├── .gitmodules ├── package.json ├── client ├── logger.js ├── config.json ├── core │ ├── color-utils.js │ ├── operation-schemas.js │ ├── status.js │ ├── animator.js │ ├── theme-service.js │ ├── AGENTS.md │ ├── vector.js │ ├── config.js │ ├── help.js │ ├── format-utils.js │ ├── mode-manager.js │ └── results-panel.js ├── app.js ├── tensor-mode.css ├── vector-mode.css ├── help-modal.js ├── bespoke.css ├── AGENTS.md ├── help-content-tensor.html ├── modes │ ├── matrix-operations.js │ ├── AGENTS.md │ └── vector-canvas.js ├── layout.css ├── help-content-vector.html ├── help-content-matrix.html ├── linear-algebra.js ├── matrix-mode.css └── entities │ └── matrix.js ├── .github └── workflows │ └── release.yml ├── LICENSE ├── AGENTS.md ├── CLAUDE.md ├── .DS_Store └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | logs 3 | .claude 4 | dist -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "client/design-system"] 2 | path = client/design-system 3 | url = https://github.com/CodeSignal/learn_bespoke-design-system 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codesignal-linear-algebra-playground", 3 | "version": "0.0.1", 4 | "description": "An interactive educational tool for learning linear algebra through visual experimentation.", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "dev": "node server.js", 9 | "build": "node build.js" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "dependencies": { 14 | "ws": "^8.14.2" 15 | }, 16 | "devDependencies": { 17 | "esbuild": "^0.27.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger Utility 3 | * Simple logging system that sends user action messages to the server 4 | */ 5 | 6 | /** 7 | * Logs a user action message to the server 8 | * @param {string} message - The log message to record 9 | */ 10 | function logAction(message) { 11 | // Send log message to server 12 | fetch('/log', { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | }, 17 | body: JSON.stringify({ message }), 18 | }).catch(() => { 19 | // Silent failure - don't disrupt user experience if logging fails 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /client/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultMode": "vector", 3 | "enabledModes": ["vector", "matrix", "tensor"], 4 | "vectorMode": { 5 | "maxVectors": 2, 6 | "operationGroups": { 7 | "addition": true, 8 | "scalarMultiplication": true, 9 | "dotProduct": true, 10 | "projectionAngle": true, 11 | "normalization": true, 12 | "perpendicular": true, 13 | "reflection": true, 14 | "linearCombination": true 15 | } 16 | }, 17 | "matrixMode": { 18 | "maxMatrices": 2, 19 | "includeVector": true, 20 | "operationGroups": { 21 | "addition": true, 22 | "scalarMultiplication": true, 23 | "multiplication": true, 24 | "determinant": true, 25 | "linearTransformation": true 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/core/color-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Color Utilities 3 | * Provides functions to extract colors from CSS custom properties 4 | */ 5 | 6 | (function() { 7 | /** 8 | * Get colors from CSS custom properties 9 | * Reads directly from design system CSS variables 10 | * @param {Object} [styleConstants] - Optional parameter kept for backward compatibility (unused) 11 | * @returns {Object} Color object with grid, axis, text, hover, hoverHighlight, accent, danger properties 12 | */ 13 | function getColorsFromCSS(styleConstants = null) { 14 | const bespokeElement = document.querySelector('.bespoke') || document.documentElement; 15 | const getColor = (varName) => getComputedStyle(bespokeElement).getPropertyValue(varName).trim() || null; 16 | 17 | return { 18 | grid: getColor('--Colors-Stroke-Stronger'), 19 | axis: getColor('--Colors-Stroke-Primary-Dark'), 20 | text: getColor('--Colors-Text-Body-Light'), 21 | hover: getColor('--Colors-Alert-Warning-Default'), 22 | hoverHighlight: getColor('--Colors-Alert-Warning-Medium-Dark'), 23 | accent: getColor('--Colors-Primary-Default') || '#3b82f6', 24 | danger: getColor('--Colors-Alert-Error-Default') || '#ef4444' 25 | }; 26 | } 27 | 28 | // Export to global scope 29 | window.ColorUtils = { 30 | getColorsFromCSS 31 | }; 32 | })(); 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Populate design system submodule 17 | run: git submodule update --init 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | cache: 'npm' 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Build project 29 | run: npm run build 30 | 31 | - name: Create tarball 32 | run: | 33 | tar -czf linear-algebra-playground.tar.gz dist/ 34 | 35 | - name: Create or update release 36 | uses: ncipollo/release-action@v1 37 | with: 38 | tag: latest 39 | name: Latest Release 40 | body: | 41 | Latest build from main branch. 42 | 43 | ## Installation 44 | 45 | ```bash 46 | wget https://github.com/${{ github.repository }}/releases/download/latest/linear-algebra-playground.tar.gz 47 | tar -xzf linear-algebra-playground.tar.gz 48 | cd dist && npm start 49 | ``` 50 | artifacts: linear-algebra-playground.tar.gz 51 | draft: false 52 | prerelease: false 53 | makeLatest: true 54 | allowUpdates: true 55 | -------------------------------------------------------------------------------- /client/core/operation-schemas.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Operation Group Schemas 3 | * Defines valid operation group keys for vector and matrix modes 4 | * Ensures consistency across modes and prevents drift 5 | */ 6 | 7 | // Vector operation group keys 8 | const VECTOR_OPERATION_GROUPS = { 9 | addition: 'addition', 10 | scalarMultiplication: 'scalarMultiplication', 11 | dotProduct: 'dotProduct', 12 | projectionAngle: 'projectionAngle', 13 | normalization: 'normalization', 14 | perpendicular: 'perpendicular', 15 | reflection: 'reflection', 16 | linearCombination: 'linearCombination' 17 | }; 18 | 19 | // Matrix operation group keys 20 | const MATRIX_OPERATION_GROUPS = { 21 | addition: 'addition', 22 | scalarMultiplication: 'scalarMultiplication', 23 | multiplication: 'multiplication', 24 | determinant: 'determinant', 25 | linearTransformation: 'linearTransformation' 26 | }; 27 | 28 | /** 29 | * Validate operation groups for a given mode 30 | * @param {string} mode - Mode name ('vector' or 'matrix') 31 | * @param {Object} groups - Operation groups object to validate 32 | * @returns {Object} - Validated and normalized operation groups object 33 | */ 34 | function validateOperationGroups(mode, groups) { 35 | if (!groups || typeof groups !== 'object') { 36 | return {}; 37 | } 38 | 39 | const validKeys = mode === 'vector' 40 | ? Object.keys(VECTOR_OPERATION_GROUPS) 41 | : Object.keys(MATRIX_OPERATION_GROUPS); 42 | 43 | const validated = {}; 44 | 45 | for (const key of validKeys) { 46 | // Include key if it exists in groups and is truthy, or default to false 47 | validated[key] = groups.hasOwnProperty(key) ? Boolean(groups[key]) : false; 48 | } 49 | 50 | return validated; 51 | } 52 | 53 | // Export to global scope 54 | window.OperationSchemas = { 55 | VECTOR_OPERATION_GROUPS, 56 | MATRIX_OPERATION_GROUPS, 57 | validateOperationGroups 58 | }; 59 | 60 | -------------------------------------------------------------------------------- /client/core/status.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Status Service 3 | * Provides centralized status message management for the application 4 | */ 5 | 6 | (function() { 7 | const statusElement = document.getElementById('status'); 8 | 9 | if (!statusElement) { 10 | console.warn('Status element not found. Status messages will not be displayed.'); 11 | } 12 | 13 | /** 14 | * Set the status message 15 | * @param {string} message - Status message to display 16 | */ 17 | function setStatus(message) { 18 | if (statusElement) { 19 | statusElement.textContent = message; 20 | } 21 | } 22 | 23 | /** 24 | * Set status to "Loading..." 25 | */ 26 | function setLoading() { 27 | setStatus('Loading...'); 28 | } 29 | 30 | /** 31 | * Set status to "Saving..." 32 | */ 33 | function setSaving() { 34 | setStatus('Saving...'); 35 | } 36 | 37 | /** 38 | * Set status to "Ready" 39 | */ 40 | function setReady() { 41 | setStatus('Ready'); 42 | } 43 | 44 | /** 45 | * Set status to "Save failed (will retry)" 46 | */ 47 | function setSaveFailed() { 48 | setStatus('Save failed (will retry)'); 49 | } 50 | 51 | /** 52 | * Set status to "Changes saved" 53 | */ 54 | function setChangesSaved() { 55 | setStatus('Changes saved'); 56 | } 57 | 58 | /** 59 | * Set status to "Failed to load data" 60 | */ 61 | function setLoadFailed() { 62 | setStatus('Failed to load data'); 63 | } 64 | 65 | /** 66 | * Set status to "Auto-save initialized" 67 | */ 68 | function setAutoSaveInitialized() { 69 | setStatus('Auto-save initialized'); 70 | } 71 | 72 | // Export to global scope 73 | window.StatusService = { 74 | setStatus, 75 | setLoading, 76 | setSaving, 77 | setReady, 78 | setSaveFailed, 79 | setChangesSaved, 80 | setLoadFailed, 81 | setAutoSaveInitialized 82 | }; 83 | })(); 84 | 85 | -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | // app.js 2 | (function() { 3 | let websocket = null; 4 | 5 | // Initialize WebSocket connection 6 | function initializeWebSocket() { 7 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 8 | const host = window.location.host; 9 | const wsUrl = `${protocol}//${host}`; 10 | 11 | try { 12 | websocket = new WebSocket(wsUrl); 13 | 14 | websocket.onopen = function(event) { 15 | console.log('WebSocket connected'); 16 | if (window.StatusService) { 17 | window.StatusService.setReady(); 18 | } 19 | }; 20 | 21 | websocket.onmessage = function(event) { 22 | try { 23 | const data = JSON.parse(event.data); 24 | if (data.type === 'message' && data.message) { 25 | alert(data.message); 26 | } 27 | } catch (error) { 28 | console.error('Error parsing WebSocket message:', error); 29 | } 30 | }; 31 | 32 | websocket.onclose = function(event) { 33 | console.log('WebSocket disconnected'); 34 | if (window.StatusService) { 35 | window.StatusService.setReady(); 36 | } 37 | 38 | // Attempt to reconnect after 3 seconds 39 | setTimeout(() => { 40 | console.log('Attempting to reconnect WebSocket...'); 41 | initializeWebSocket(); 42 | }, 3000); 43 | }; 44 | 45 | websocket.onerror = function(error) { 46 | console.error('WebSocket error:', error); 47 | if (window.StatusService) { 48 | window.StatusService.setReady(); 49 | } 50 | }; 51 | 52 | } catch (error) { 53 | console.error('Failed to create WebSocket connection:', error); 54 | if (window.StatusService) { 55 | window.StatusService.setReady(); 56 | } 57 | } 58 | } 59 | 60 | // Initialize both help modal and WebSocket when DOM is ready 61 | async function initialize() { 62 | // Determine the current mode from config 63 | let mode = 'vector'; // Default to vector mode 64 | if (window.ConfigService) { 65 | try { 66 | const config = await window.ConfigService.loadConfig(); 67 | mode = config.defaultMode || config.mode || 'vector'; // Support both new and old config format 68 | } catch (error) { 69 | console.warn('Failed to load config for help modal, using default mode:', error); 70 | } 71 | } 72 | 73 | // Initialize help modal using shared service 74 | if (window.HelpService) { 75 | await window.HelpService.initializeHelpModal({ 76 | triggerSelector: '#btn-help', 77 | mode: mode, 78 | theme: 'auto' 79 | }); 80 | 81 | // Subscribe to mode changes to update help content 82 | if (window.ModeManager && typeof window.ModeManager.onModeChange === 'function') { 83 | window.ModeManager.onModeChange(async (newMode) => { 84 | try { 85 | if (window.HelpService && typeof window.HelpService.updateHelpContent === 'function') { 86 | await window.HelpService.updateHelpContent(newMode); 87 | } 88 | } catch (error) { 89 | console.error('Failed to update help content on mode change:', error); 90 | if (window.StatusService) { 91 | window.StatusService.setReady(); 92 | } 93 | } 94 | }); 95 | } 96 | } 97 | 98 | initializeWebSocket(); 99 | } 100 | 101 | if (document.readyState === 'loading') { 102 | document.addEventListener('DOMContentLoaded', initialize); 103 | } else { 104 | initialize(); 105 | } 106 | })(); 107 | -------------------------------------------------------------------------------- /client/core/animator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Animator Class 3 | * Provides animation utilities and easing functions 4 | */ 5 | 6 | class Animator { 7 | /** 8 | * Easing function: ease-out-cubic 9 | * Starts fast, ends slow 10 | * @param {number} progress - Animation progress from 0 to 1 11 | * @returns {number} - Eased progress value 12 | */ 13 | static easeOutCubic(progress) { 14 | return 1 - Math.pow(1 - progress, 3); 15 | } 16 | 17 | /** 18 | * Easing function: ease-in-cubic 19 | * Starts slow, ends fast 20 | * @param {number} progress - Animation progress from 0 to 1 21 | * @returns {number} - Eased progress value 22 | */ 23 | static easeInCubic(progress) { 24 | return Math.pow(progress, 3); 25 | } 26 | 27 | /** 28 | * Easing function: ease-in-out-cubic 29 | * Starts slow, speeds up, ends slow 30 | * @param {number} progress - Animation progress from 0 to 1 31 | * @returns {number} - Eased progress value 32 | */ 33 | static easeInOutCubic(progress) { 34 | return progress < 0.5 35 | ? 4 * Math.pow(progress, 3) 36 | : 1 - Math.pow(-2 * progress + 2, 3) / 2; 37 | } 38 | 39 | /** 40 | * Generic animation function using requestAnimationFrame 41 | * @param {object} options - Animation options 42 | * @param {number} options.duration - Duration in milliseconds 43 | * @param {function} options.onFrame - Callback called each frame with eased progress (0-1) 44 | * @param {function} options.onComplete - Callback called when animation completes 45 | * @param {function} options.easingFunction - Easing function to use (default: easeOutCubic) 46 | * @returns {object} - Control object with cancel() method 47 | */ 48 | static animate(options) { 49 | const { 50 | duration, 51 | onFrame, 52 | onComplete, 53 | easingFunction = Animator.easeOutCubic 54 | } = options; 55 | 56 | const startTime = performance.now(); 57 | let cancelled = false; 58 | let animationId = null; 59 | 60 | const frame = (currentTime) => { 61 | if (cancelled) return; 62 | 63 | const elapsed = currentTime - startTime; 64 | const progress = Math.min(elapsed / duration, 1); 65 | const eased = easingFunction(progress); 66 | 67 | onFrame(eased, progress); 68 | 69 | if (progress < 1) { 70 | animationId = requestAnimationFrame(frame); 71 | } else { 72 | if (onComplete) onComplete(); 73 | } 74 | }; 75 | 76 | animationId = requestAnimationFrame(frame); 77 | 78 | // Return control object 79 | return { 80 | cancel: () => { 81 | cancelled = true; 82 | if (animationId) { 83 | cancelAnimationFrame(animationId); 84 | } 85 | } 86 | }; 87 | } 88 | 89 | /** 90 | * Linear interpolation between two values 91 | * @param {number} start - Start value 92 | * @param {number} end - End value 93 | * @param {number} progress - Progress from 0 to 1 94 | * @returns {number} - Interpolated value 95 | */ 96 | static lerp(start, end, progress) { 97 | return start + (end - start) * progress; 98 | } 99 | 100 | /** 101 | * Linear interpolation between two Vector objects 102 | * @param {Vector} start - Start vector 103 | * @param {Vector} end - End vector 104 | * @param {number} progress - Progress from 0 to 1 105 | * @returns {Vector} - Interpolated vector 106 | */ 107 | static lerpVector(start, end, progress) { 108 | return new Vector( 109 | Animator.lerp(start.x, end.x, progress), 110 | Animator.lerp(start.y, end.y, progress), 111 | end.color, 112 | end.label, 113 | end.lineWidth 114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /client/tensor-mode.css: -------------------------------------------------------------------------------- 1 | /* Tensor Mode Styles */ 2 | 3 | .tensor-controls { 4 | display: flex; 5 | flex-direction: column; 6 | gap: var(--UI-Spacing-spacing-ms); 7 | } 8 | 9 | .control-group { 10 | display: flex; 11 | flex-direction: column; 12 | gap: var(--UI-Spacing-spacing-s); 13 | } 14 | 15 | .rank-selector { 16 | display: grid; 17 | grid-template-columns: repeat(2, 1fr); 18 | gap: var(--UI-Spacing-spacing-s); 19 | } 20 | 21 | /* Rank button styles are now handled by design system .button classes */ 22 | 23 | .tensor-input-container { 24 | background: var(--Colors-Backgrounds-Main-Medium); 25 | padding: var(--UI-Spacing-spacing-mxs); 26 | border-radius: var(--UI-Radius-radius-xs); 27 | border: 1px solid var(--Colors-Stroke-Default); 28 | width: 100%; 29 | box-sizing: border-box; 30 | overflow: hidden; 31 | } 32 | 33 | .scalar-input-wrapper, 34 | .vector-input-wrapper, 35 | .matrix-input-wrapper, 36 | .tensor3d-input-wrapper { 37 | display: flex; 38 | flex-direction: column; 39 | width: 100%; 40 | box-sizing: border-box; 41 | } 42 | 43 | .scalar-input-wrapper { 44 | align-items: center; 45 | } 46 | 47 | .tensor-slices { 48 | display: flex; 49 | flex-direction: column; 50 | gap: var(--UI-Spacing-spacing-mxs); 51 | width: 100%; 52 | box-sizing: border-box; 53 | } 54 | 55 | .slice { 56 | background: var(--Colors-Backgrounds-Main-Default); 57 | padding: var(--UI-Spacing-spacing-s); 58 | border-radius: var(--UI-Radius-radius-xxs); 59 | border: 1px solid var(--Colors-Stroke-Default); 60 | width: 100%; 61 | box-sizing: border-box; 62 | overflow: hidden; 63 | } 64 | 65 | .slice span { 66 | display: block; 67 | margin-bottom: var(--UI-Spacing-spacing-xxs); 68 | color: var(--Colors-Text-Body-Light); 69 | } 70 | 71 | /* Tensor input styles - consistent across all ranks */ 72 | .tensor-input-container input[type="number"].matrix-input { 73 | width: 55px; 74 | height: 32px; 75 | max-width: 100%; 76 | box-sizing: border-box; 77 | text-align: center; 78 | font-family: var(--code-family); 79 | border: 2px solid var(--Colors-Stroke-Default); 80 | } 81 | 82 | /* Scalar input - centered single input */ 83 | .scalar-input-wrapper input[type="number"].matrix-input { 84 | width: 100px; 85 | max-width: 100%; 86 | } 87 | 88 | /* Matrix grid styles for tensor mode */ 89 | .tensor-input-container .matrix-grid { 90 | display: flex; 91 | flex-direction: column; 92 | gap: var(--UI-Spacing-spacing-xxs); 93 | margin: 0; 94 | padding: var(--UI-Spacing-spacing-s); 95 | background: var(--Colors-Backgrounds-Main-Strong); 96 | border-radius: var(--UI-Radius-radius-xxs); 97 | width: 100%; 98 | box-sizing: border-box; 99 | overflow: hidden; 100 | } 101 | 102 | .tensor-input-container .matrix-grid.small { 103 | padding: var(--UI-Spacing-spacing-xxs); 104 | align-items: center; 105 | } 106 | 107 | .tensor-input-container .matrix-row { 108 | display: flex; 109 | gap: var(--UI-Spacing-spacing-xxs); 110 | justify-content: center; 111 | width: 100%; 112 | box-sizing: border-box; 113 | flex-wrap: nowrap; 114 | } 115 | 116 | /* Ensure inputs within matrix-grid don't overflow */ 117 | .tensor-input-container .matrix-grid.small .matrix-input { 118 | flex-shrink: 0; 119 | min-width: 0; 120 | } 121 | 122 | /* WebKit autofill override for tensor inputs */ 123 | .tensor-input-container input[type="number"].matrix-input:-webkit-autofill, 124 | .tensor-input-container input[type="number"].matrix-input:-webkit-autofill:hover, 125 | .tensor-input-container input[type="number"].matrix-input:-webkit-autofill:focus { 126 | -webkit-box-shadow: 0 0 0 1000px var(--Colors-Backgrounds-Main-Top) inset !important; 127 | -webkit-text-fill-color: var(--Colors-Text-Body-Default) !important; 128 | } -------------------------------------------------------------------------------- /client/vector-mode.css: -------------------------------------------------------------------------------- 1 | /* Vector Mode Specific Styles */ 2 | 3 | /* Vector Mode Color Variables */ 4 | /* Intentional: Domain-specific colors for visual differentiation of vectors. 5 | These hardcoded colors are kept separate from design system tokens to maintain 6 | clear visual distinction between vector 1 (red) and vector 2 (blue) in the canvas. */ 7 | .mode-content[data-mode="vector"] { 8 | --vector-1-color: #ef4444; 9 | --vector-2-color: #3b82f6; 10 | } 11 | 12 | @media (prefers-color-scheme: dark) { 13 | .mode-content[data-mode="vector"] { 14 | --vector-1-color: #f87171; /* Lighter red for dark bg */ 15 | --vector-2-color: #60a5fa; /* Lighter blue for dark bg */ 16 | } 17 | } 18 | 19 | /* Vector Controls */ 20 | .vector-control { 21 | /* Extend shared control-card styles */ 22 | background: var(--Colors-Backgrounds-Main-Medium); 23 | border-radius: var(--UI-Radius-radius-xs); 24 | padding: var(--UI-Spacing-spacing-mxs); 25 | border: 1px solid var(--Colors-Stroke-Default); 26 | } 27 | 28 | .vector-control h3 { 29 | margin: 0 0 var(--UI-Spacing-spacing-mxs) 0; 30 | color: var(--Colors-Text-Body-Default); 31 | display: flex; 32 | align-items: center; 33 | gap: var(--UI-Spacing-spacing-s); 34 | } 35 | 36 | /* Intentional exception: Times New Roman for math notation (vector labels like v₁) */ 37 | .vector-label { 38 | font-family: 'Times New Roman', serif; 39 | font-style: italic; 40 | font-size: 1.1em; 41 | } 42 | 43 | .v1-color { 44 | color: var(--vector-1-color); 45 | } 46 | 47 | .v2-color { 48 | color: var(--vector-2-color); 49 | } 50 | 51 | .vector-info { 52 | display: flex; 53 | flex-direction: column; 54 | gap: var(--UI-Spacing-spacing-s); 55 | } 56 | 57 | .info-row { 58 | display: flex; 59 | justify-content: space-between; 60 | } 61 | 62 | .info-row .label { 63 | color: var(--Colors-Text-Body-Light); 64 | font-weight: 500; 65 | } 66 | 67 | .info-row .value { 68 | color: var(--Colors-Text-Body-Default); 69 | font-family: 'JetBrains Mono', monospace; 70 | } 71 | 72 | .scalar-control { 73 | display: flex; 74 | flex-wrap: wrap; 75 | gap: var(--UI-Spacing-spacing-s); 76 | align-items: center; 77 | } 78 | 79 | .bespoke .scalar-control input { 80 | width: 70px; 81 | text-align: center; 82 | flex-shrink: 0; 83 | } 84 | 85 | .times-symbol { 86 | color: var(--Colors-Text-Body-Default); 87 | padding: 0 var(--UI-Spacing-spacing-xxs); 88 | flex-shrink: 0; 89 | } 90 | 91 | .scalar-control > div { 92 | flex: 1 1 auto; 93 | min-width: 140px; 94 | } 95 | 96 | 97 | .reflection-control { 98 | display: flex; 99 | flex-direction: column; 100 | gap: var(--UI-Spacing-spacing-mxs); 101 | margin-top: var(--UI-Spacing-spacing-mxs); 102 | padding-top: var(--UI-Spacing-spacing-mxs); 103 | border-top: 1px solid var(--Colors-Stroke-Default); 104 | } 105 | 106 | .reflection-control > div { 107 | width: 100%; 108 | } 109 | 110 | .normalize-control { 111 | display: flex; 112 | flex-direction: column; 113 | gap: var(--UI-Spacing-spacing-mxs); 114 | } 115 | 116 | .normalize-control > div { 117 | width: 100%; 118 | } 119 | 120 | .perpendicular-control { 121 | display: flex; 122 | flex-direction: column; 123 | gap: var(--UI-Spacing-spacing-mxs); 124 | } 125 | 126 | .perpendicular-control > div { 127 | width: 100%; 128 | } 129 | 130 | .linear-combo-control { 131 | display: flex; 132 | flex-wrap: wrap; 133 | gap: var(--UI-Spacing-spacing-s); 134 | align-items: center; 135 | } 136 | 137 | .bespoke .linear-combo-control input[type="number"] { 138 | flex: 1 1 calc(50% - 4px); 139 | min-width: 60px; 140 | text-align: center; 141 | } 142 | 143 | .linear-combo-control > #lc-scalar-a, 144 | .linear-combo-control > #lc-scalar-b { 145 | flex: 1 1 calc(50% - 4px); 146 | min-width: 60px; 147 | } 148 | 149 | .linear-combo-control .button { 150 | flex: 1 1 100%; 151 | margin-top: var(--UI-Spacing-spacing-xxs); 152 | min-width: unset; 153 | } 154 | 155 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | *As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim.* 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /client/core/theme-service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Canvas Theme Service 3 | * Centralizes color loading and theme change listening for canvas-based modes 4 | */ 5 | 6 | (function() { 7 | class CanvasThemeService { 8 | constructor() { 9 | this.subscribers = []; 10 | this.colors = null; 11 | this.styleConstants = null; 12 | this.darkModeQuery = null; 13 | this.themeChangeHandler = null; 14 | // Don't auto-initialize - wait for init() to be called with styleConstants 15 | } 16 | 17 | /** 18 | * Initialize the service 19 | * Loads initial colors and sets up theme listener 20 | * @param {Object} styleConstants - Style constants object (colors property contains domain-specific colors only) 21 | */ 22 | init(styleConstants) { 23 | if (!styleConstants || !styleConstants.colors) { 24 | console.warn('CanvasThemeService.init: styleConstants with colors property is required'); 25 | return; 26 | } 27 | 28 | this.styleConstants = styleConstants; 29 | 30 | // Load initial colors 31 | this.loadColors(); 32 | 33 | // Set up theme change listener 34 | this.darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)'); 35 | this.themeChangeHandler = () => { 36 | this.loadColors(); 37 | this.notifySubscribers(); 38 | }; 39 | this.darkModeQuery.addEventListener('change', this.themeChangeHandler); 40 | } 41 | 42 | /** 43 | * Load colors from CSS using ColorUtils 44 | * Generic colors (grid, axis, text, hover, etc.) come from design system CSS variables. 45 | * styleConstants parameter is kept for backward compatibility but not used for generic color fallbacks. 46 | * @returns {Object} Color object with theme-responsive colors 47 | */ 48 | loadColors() { 49 | if (!this.styleConstants) { 50 | console.warn('CanvasThemeService.loadColors: styleConstants not set, call init() first'); 51 | return {}; 52 | } 53 | 54 | const colors = window.ColorUtils 55 | ? window.ColorUtils.getColorsFromCSS(this.styleConstants) 56 | : {}; 57 | 58 | this.colors = colors; 59 | return colors; 60 | } 61 | 62 | /** 63 | * Get current colors (cached) 64 | * @returns {Object} Color object 65 | */ 66 | getColors() { 67 | if (!this.colors) { 68 | this.loadColors(); 69 | } 70 | return { ...this.colors }; // Return copy to prevent mutation 71 | } 72 | 73 | /** 74 | * Subscribe to theme change notifications 75 | * @param {Function} callback - Callback function called when theme changes 76 | * @returns {Function} Unsubscribe function 77 | */ 78 | subscribe(callback) { 79 | if (typeof callback !== 'function') { 80 | console.warn('CanvasThemeService.subscribe: callback must be a function'); 81 | return () => {}; 82 | } 83 | 84 | this.subscribers.push(callback); 85 | 86 | // Return unsubscribe function 87 | return () => { 88 | const index = this.subscribers.indexOf(callback); 89 | if (index > -1) { 90 | this.subscribers.splice(index, 1); 91 | } 92 | }; 93 | } 94 | 95 | /** 96 | * Unsubscribe from theme change notifications 97 | * @param {Function} callback - Callback function to remove 98 | */ 99 | unsubscribe(callback) { 100 | const index = this.subscribers.indexOf(callback); 101 | if (index > -1) { 102 | this.subscribers.splice(index, 1); 103 | } 104 | } 105 | 106 | /** 107 | * Notify all subscribers of theme change 108 | */ 109 | notifySubscribers() { 110 | this.subscribers.forEach(callback => { 111 | try { 112 | callback(this.getColors()); 113 | } catch (error) { 114 | console.error('Error in theme change subscriber:', error); 115 | } 116 | }); 117 | } 118 | 119 | /** 120 | * Destroy the service and clean up listeners 121 | */ 122 | destroy() { 123 | if (this.darkModeQuery && this.themeChangeHandler) { 124 | this.darkModeQuery.removeEventListener('change', this.themeChangeHandler); 125 | } 126 | this.subscribers = []; 127 | this.colors = null; 128 | this.darkModeQuery = null; 129 | this.themeChangeHandler = null; 130 | } 131 | } 132 | 133 | // Export singleton instance (not initialized until init() is called) 134 | window.CanvasThemeService = new CanvasThemeService(); 135 | })(); 136 | 137 | -------------------------------------------------------------------------------- /client/core/AGENTS.md: -------------------------------------------------------------------------------- 1 | # client/core – AGENTS 2 | 3 | Shared services, utilities, and math primitives that power both vector and 4 | matrix modes. These modules must stay framework-free, DOM-light, and reusable 5 | across future Bespoke apps. 6 | 7 | ## Loading Order + Globals 8 | - Files are loaded (via `