├── .prettierignore ├── src ├── jscad.d.ts ├── ergogen.d.ts ├── context │ └── constants.ts ├── gtag.d.ts ├── atoms │ ├── Title.tsx │ ├── GrowButton.tsx │ ├── LoadingBar.test.tsx │ ├── PcbPreview.tsx │ ├── GithubIcon.tsx │ ├── DiscordIcon.tsx │ ├── TextPreview.tsx │ ├── Input.tsx │ ├── LoadingBar.tsx │ ├── OutlineIconButton.tsx │ ├── StlPreview.test.tsx │ ├── SvgPreview.tsx │ ├── InjectionRow.test.tsx │ ├── Button.test.tsx │ ├── Button.tsx │ ├── GenOption.tsx │ ├── InjectionRow.tsx │ └── DownloadRow.tsx ├── utils │ ├── monaco.ts │ ├── analytics.ts │ ├── object.ts │ ├── object.test.ts │ ├── platform.ts │ ├── localFiles.ts │ └── zip.ts ├── examples │ ├── empty_yaml.ts │ ├── atreus.ts │ ├── alpha.ts │ ├── absolem.ts │ ├── index.ts │ ├── sweeplike.ts │ ├── reviung41.ts │ ├── tiny20.ts │ ├── plank.ts │ └── wubbo.ts ├── react-app-env.d.ts ├── setupTests.js ├── workers │ ├── ergogen.worker.types.ts │ ├── workerFactory.ts │ ├── jscad.worker.types.ts │ └── ergogenFootprints.ts ├── theme │ └── theme.ts ├── hooks │ ├── useInjectionConflictResolution.test.ts │ └── useConfigLoader.ts ├── index.tsx ├── molecules │ ├── InjectionEditor.tsx │ ├── ConfigEditor.tsx │ ├── FilePreview.tsx │ ├── Injections.tsx │ ├── ConflictResolutionDialog.tsx │ ├── ConflictResolutionDialog.test.tsx │ └── Downloads.test.tsx └── organisms │ └── Banners.tsx ├── .markdownlintignore ├── public ├── ergogen.png ├── favicon.ico ├── robots.txt ├── images │ ├── changelog │ │ ├── 2025-10-13.png │ │ ├── 2025-11-02.png │ │ ├── 2025-11-03.png │ │ └── placeholder.png │ └── previews │ │ ├── sweep-like.svg │ │ ├── tiny20.svg │ │ ├── alpha.svg │ │ ├── plank.svg │ │ ├── a._dux.svg │ │ ├── wubbo.svg │ │ ├── absolem.svg │ │ └── corney_island.svg ├── manifest.json └── index.html ├── .prettierrc ├── .markdownlint.json ├── scripts ├── tsconfig.json ├── sync-monaco.sh ├── build-ergogen-wasm.sh └── generate-previews.js ├── e2e ├── debug.spec.ts ├── app.spec.ts ├── utils │ └── screenshots.ts ├── responsive.spec.ts └── routing.spec.ts ├── tsconfig.json ├── knip.jsonc ├── .gitignore ├── .github └── workflows │ ├── ci.yaml │ └── deploy.yaml ├── playwright.config.ts ├── patch ├── patch_ergogen.sh └── footprints_index.js ├── eslint.config.js ├── package.json └── CHANGELOG.md /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /src/jscad.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jscad'; 2 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /src/ergogen.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'ergogen'; 2 | -------------------------------------------------------------------------------- /public/ergogen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/ergogen-rs-gui/master/public/ergogen.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/ergogen-rs-gui/master/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD030": { 4 | "ul_single": 1, 5 | "ol_single": 1 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/images/changelog/2025-10-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/ergogen-rs-gui/master/public/images/changelog/2025-10-13.png -------------------------------------------------------------------------------- /public/images/changelog/2025-11-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/ergogen-rs-gui/master/public/images/changelog/2025-11-02.png -------------------------------------------------------------------------------- /public/images/changelog/2025-11-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/ergogen-rs-gui/master/public/images/changelog/2025-11-03.png -------------------------------------------------------------------------------- /public/images/changelog/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/ergogen-rs-gui/master/public/images/changelog/placeholder.png -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2017", 5 | "esModuleInterop": true, 6 | "strict": true, 7 | "skipLibCheck": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/context/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants used by the ConfigContext. 3 | */ 4 | 5 | /** 6 | * The key used to store the main configuration in local storage. 7 | */ 8 | export const CONFIG_LOCAL_STORAGE_KEY = 'ergogen:config'; 9 | -------------------------------------------------------------------------------- /src/gtag.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | gtag?: ( 4 | command: 'event', 5 | eventName: string, 6 | eventParams?: { [key: string]: any } 7 | ) => void; 8 | } 9 | } 10 | 11 | export {}; 12 | -------------------------------------------------------------------------------- /src/atoms/Title.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { theme } from '../theme/theme'; 3 | 4 | const Title = styled.h3` 5 | font-size: ${theme.fontSizes.base}; 6 | font-weight: ${theme.fontWeights.semiBold}; 7 | color: ${theme.colors.white}; 8 | margin: 1rem; 9 | `; 10 | 11 | export default Title; 12 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Ergogen WebUI", 3 | "name": "Ergogen WebUI", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /scripts/sync-monaco.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 5 | MONACO_DIR="$ROOT_DIR/node_modules/monaco-editor/min/vs" 6 | OUT_DIR="$ROOT_DIR/public/monaco/vs" 7 | 8 | if [ ! -d "$MONACO_DIR" ]; then 9 | echo "monaco-editor not found at $MONACO_DIR (did you install dependencies?)" >&2 10 | exit 1 11 | fi 12 | 13 | mkdir -p "$OUT_DIR" 14 | rsync -a --delete "$MONACO_DIR/" "$OUT_DIR/" 15 | 16 | -------------------------------------------------------------------------------- /e2e/debug.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { makeShooter } from './utils/screenshots'; 3 | 4 | test('minimal test: page loads and has welcome text', async ({ page }) => { 5 | const shoot = makeShooter(page, test.info()); 6 | await page.goto('/'); 7 | const welcome = page.getByText('Ergogen Web UI'); 8 | await shoot('before-welcome-visible'); 9 | await expect(welcome).toBeVisible(); 10 | await shoot('after-welcome-visible'); 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/monaco.ts: -------------------------------------------------------------------------------- 1 | import { Monaco } from '@monaco-editor/react'; 2 | import { theme } from '../theme/theme'; 3 | 4 | let themeDefined = false; 5 | 6 | export const defineErgogenTheme = (monaco: Monaco) => { 7 | if (themeDefined) { 8 | return; 9 | } 10 | monaco.editor.defineTheme('ergogen-theme', { 11 | base: 'vs-dark', 12 | inherit: true, 13 | rules: [], 14 | colors: { 15 | 'editor.background': theme.colors.backgroundLight, 16 | }, 17 | }); 18 | themeDefined = true; 19 | }; 20 | -------------------------------------------------------------------------------- /src/examples/empty_yaml.ts: -------------------------------------------------------------------------------- 1 | import { ConfigExample } from './index'; 2 | 3 | /** 4 | * An empty Ergogen configuration skeleton. 5 | * This provides a starting point for creating a new keyboard layout from scratch. 6 | * @type {ConfigExample} 7 | */ 8 | const EmptyYAML: ConfigExample = { 9 | label: 'Empty YAML configuration', 10 | author: 'ceoloide', 11 | value: `meta: 12 | engine: 4.1.0 13 | units: {} 14 | points: 15 | zones: 16 | matrix: {} 17 | outlines: {} 18 | pcbs: {} 19 | cases: {} 20 | `, 21 | }; 22 | 23 | export default EmptyYAML; 24 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | declare namespace JSX { 4 | interface IntrinsicElements { 5 | 'kicanvas-embed': { 6 | children?: Element; 7 | src?: string | null; 8 | controls?: string; 9 | controlslist?: string; 10 | theme?: string; 11 | zoom?: string; 12 | key?: string; 13 | }; 14 | 'kicanvas-source': { 15 | children?: string; 16 | type: string; 17 | originname?: string; 18 | src?: string; 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/analytics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for Google Analytics event tracking. 3 | */ 4 | 5 | /** 6 | * Tracks a Google Analytics event if gtag is available. 7 | * @param eventName - The name of the event 8 | * @param eventParams - Optional parameters for the event 9 | */ 10 | export const trackEvent = ( 11 | eventName: string, 12 | eventParams?: { [key: string]: string | number | boolean | undefined } 13 | ): void => { 14 | if (window.gtag) { 15 | window.gtag('event', eventName, { 16 | event_category: 'user_action', 17 | ...eventParams, 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 6 | // allows you to do things like: 7 | // expect(element).toHaveTextContent(/react/i) 8 | // learn more: https://github.com/testing-library/jest-dom 9 | import '@testing-library/jest-dom'; 10 | 11 | window.URL.createObjectURL = jest.fn(); 12 | 13 | // Polyfill for TextEncoder and TextDecoder 14 | if (typeof global.TextEncoder === 'undefined') { 15 | const { TextEncoder, TextDecoder } = require('util'); 16 | global.TextEncoder = TextEncoder; 17 | global.TextDecoder = TextDecoder; 18 | } 19 | -------------------------------------------------------------------------------- /knip.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5/schema.json", 3 | "entry": [ 4 | "src/index.tsx", 5 | "**/*.test.{ts,tsx}", 6 | "src/workers/*.worker.{ts,tsx}" 7 | ], 8 | "project": [ 9 | "src/**/*.{ts,tsx}" 10 | ], 11 | "ignore": [ 12 | "src/ergogen.d.ts" 13 | ], 14 | "ignoreDependencies": [ 15 | "@babel/plugin-proposal-private-property-in-object", 16 | "@jscad/csg", 17 | "@jscad/stl-serializer", 18 | "@testing-library/jest-dom", 19 | "@testing-library/user-event", 20 | "@types/styled-components", 21 | "@typescript-eslint/parser", 22 | "eslint-plugin-prettier", 23 | "markdownlint" 24 | ] 25 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # bun 9 | bun.lockb 10 | .bun 11 | 12 | # testing 13 | /coverage 14 | playwright-report/ 15 | test-results/ 16 | e2e/screenshots/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | .env.local 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | 28 | # wasm artifacts 29 | /public/dependencies/ergogen_wasm* 30 | /public/dependencies/ergogen_footprints.json 31 | /scripts/wasm/ergogen_wasm* 32 | /public/monaco/ 33 | 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | yarn_start.log 38 | -------------------------------------------------------------------------------- /e2e/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { makeShooter } from './utils/screenshots'; 3 | 4 | test('renders editor and preview', async ({ page }) => { 5 | const shoot = makeShooter(page, test.info()); 6 | await page.goto('/new'); 7 | await page.getByRole('button', { name: 'Empty Configuration' }).click(); 8 | 9 | const editor = page.getByTestId('config-editor'); 10 | await shoot('before-editor-visible'); 11 | await expect(editor).toBeVisible(); 12 | await shoot('after-editor-visible'); 13 | 14 | const preview = page.getByTestId('demo.svg-file-preview'); 15 | await shoot('before-preview-visible'); 16 | await expect(preview).toBeVisible(); 17 | await shoot('after-preview-visible'); 18 | }); 19 | -------------------------------------------------------------------------------- /src/workers/ergogen.worker.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for messages between the Ergogen worker and main thread. 3 | */ 4 | 5 | export type WorkerRequest = { 6 | type: 'generate'; 7 | inputConfig: string | object; 8 | injectionInput?: string[][]; 9 | /** Unique id to correlate requests and responses */ 10 | requestId: string; 11 | options: { 12 | debug: boolean; 13 | }; 14 | }; 15 | 16 | export type WorkerResponse = 17 | | { 18 | type: 'success'; 19 | results: unknown; 20 | warnings: string[]; 21 | /** Echo of the originating request id */ 22 | requestId: string; 23 | } 24 | | { 25 | type: 'error'; 26 | error: string; 27 | /** Echo of the originating request id */ 28 | requestId: string; 29 | }; 30 | -------------------------------------------------------------------------------- /src/examples/atreus.ts: -------------------------------------------------------------------------------- 1 | import { ConfigExample } from './index'; 2 | 3 | /** 4 | * An example Ergogen configuration for the Atreus keyboard. 5 | * This is a simplified version focusing on the points definition. 6 | * @type {ConfigExample} 7 | */ 8 | const Atreus: ConfigExample = { 9 | label: 'Atreus', 10 | author: 'MrZealot', 11 | value: `points: 12 | zones: 13 | matrix: 14 | columns: 15 | pinky: 16 | ring: 17 | key.stagger: 3 18 | middle: 19 | key.stagger: 5 20 | index: 21 | key.stagger: -5 22 | inner: 23 | key.stagger: -6 24 | thumb: 25 | key.skip: true 26 | key.stagger: 10 27 | rows: 28 | home.skip: false 29 | rows: 30 | bottom: 31 | home: 32 | top: 33 | num: 34 | rotate: -10 35 | mirror: 36 | ref: matrix_thumb_home 37 | distance: 22 38 | `, 39 | }; 40 | 41 | export default Atreus; 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 30 13 | env: 14 | CI: 'true' 15 | ERGOGEN_WASM_SKIP_BUILD: '1' 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: yarn 25 | cache-dependency-path: yarn.lock 26 | 27 | - name: Install dependencies 28 | run: yarn install --frozen-lockfile 29 | 30 | - name: Install Playwright browsers 31 | run: npx playwright install --with-deps chromium 32 | 33 | - name: Format check 34 | run: yarn format:check 35 | 36 | - name: Lint check 37 | run: yarn lint:check 38 | 39 | - name: Unit tests 40 | run: yarn test:unit 41 | 42 | - name: E2E tests 43 | run: yarn test:e2e 44 | 45 | -------------------------------------------------------------------------------- /src/examples/alpha.ts: -------------------------------------------------------------------------------- 1 | import { ConfigExample } from './index'; 2 | 3 | /** 4 | * An example Ergogen configuration for the Alpha keyboard layout. 5 | * This example demonstrates a staggered bottom row. 6 | * @type {ConfigExample} 7 | */ 8 | const Alpha: ConfigExample = { 9 | label: 'Alpha', 10 | author: 'jcmkk3', 11 | value: `points: 12 | mirror: 13 | ref: ortho_inner_home 14 | distance: 1U 15 | zones: 16 | ortho: 17 | columns: 18 | pinky: 19 | ring: 20 | middle: 21 | index: 22 | inner: 23 | rows: 24 | home.padding: 1U 25 | top.padding: 1U 26 | stagger: 27 | anchor: 28 | ref: ortho_pinky_home 29 | shift: [0.5U, -1U] 30 | columns: 31 | pinky: 32 | ring: 33 | middle: 34 | index: 35 | key.asym: left 36 | space: 37 | key: 38 | spread: 0.5U 39 | asym: right 40 | width: 2*(u-1) 41 | rows: 42 | bottom.padding: 1U 43 | `, 44 | }; 45 | 46 | export default Alpha; 47 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Recursively finds a nested property within an object using a dot-separated string. 3 | * @param {string} resultToFind - The dot-separated path to the desired property (e.g., "outlines.top.svg"). 4 | * @param {unknown} resultsToSearch - The object to search within. 5 | * @returns {unknown | undefined} The found property value, or undefined if not found. 6 | */ 7 | export const findResult = ( 8 | resultToFind: string, 9 | resultsToSearch: unknown 10 | ): unknown | undefined => { 11 | if (resultsToSearch === null) return null; 12 | if (resultToFind === '') return resultsToSearch; 13 | if (typeof resultsToSearch !== 'object') return undefined; 14 | const properties = resultToFind.split('.'); 15 | const currentProperty = properties[0]; 16 | const remainingProperties = properties.slice(1).join('.'); 17 | return Object.prototype.hasOwnProperty.call(resultsToSearch, currentProperty) 18 | ? findResult( 19 | remainingProperties, 20 | (resultsToSearch as Record)[currentProperty] 21 | ) 22 | : undefined; 23 | }; 24 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | const port = process.env.E2E_PORT || process.env.PORT || '3000'; 4 | const baseURL = `http://localhost:${port}`; 5 | 6 | export default defineConfig({ 7 | testDir: './e2e', 8 | // These E2E tests hit real GitHub endpoints and also share a single dev-server. 9 | // Running fully-parallel tends to be flaky (rate limits, startup contention). 10 | fullyParallel: !process.env.CI, 11 | // Keep CI stable and deterministic; local runs can use the default. 12 | workers: process.env.CI ? 2 : undefined, 13 | timeout: process.env.CI ? 60 * 1000 : 30 * 1000, 14 | forbidOnly: !!process.env.CI, 15 | retries: 0, 16 | reporter: process.env.CI ? 'list' : 'html', 17 | use: { 18 | baseURL, 19 | trace: 'on-first-retry', 20 | }, 21 | projects: [ 22 | { 23 | name: 'chromium', 24 | use: { ...devices['Desktop Chrome'] }, 25 | }, 26 | ], 27 | webServer: { 28 | command: `BROWSER=none PORT=${port} yarn start`, 29 | url: baseURL, 30 | reuseExistingServer: true, 31 | timeout: 120 * 1000, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /patch/patch_ergogen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Pull @ceoloide and @infused-kim footprint libraries 3 | if [ ! -d node_modules/ergogen ]; then 4 | echo "Installing Ergogen..." 5 | npm install ergogen 6 | fi 7 | if [ -d node_modules/ergogen ]; then 8 | echo "Patching Ergogen..." 9 | if [ -d node_modules/ergogen/src/footprints/ceoloide ]; then 10 | echo "Removing existing @ceoloide's footprint library" 11 | rm -rf node_modules/ergogen/src/footprints/ceoloide 12 | fi 13 | git clone https://github.com/ceoloide/ergogen-footprints.git node_modules/ergogen/src/footprints/ceoloide 14 | if [ -d node_modules/ergogen/src/footprints/infused-kim ]; then 15 | echo "Removing existing @infused-kim's footprint library" 16 | rm -rf node_modules/ergogen/src/footprints/infused-kim 17 | fi 18 | git clone https://github.com/infused-kim/kb_ergogen_fp.git node_modules/ergogen/src/footprints/infused-kim 19 | # Add the footprints to the index 20 | echo "Patching footprints/index.js..." 21 | cp -f patch/footprints_index.js node_modules/ergogen/src/footprints/index.js 22 | else 23 | echo "Directory node_modules/ergogen not found." 24 | fi -------------------------------------------------------------------------------- /src/workers/workerFactory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Factory function for creating the Ergogen worker. 3 | * This is separated to make it easier to mock in tests. 4 | */ 5 | 6 | export const createErgogenWorker = (): Worker | null => { 7 | // Only create worker in browser environment 8 | if (typeof window === 'undefined' || !('Worker' in window)) { 9 | return null; 10 | } 11 | 12 | try { 13 | // Use the new URL syntax to let Webpack bundle the worker 14 | return new Worker(new URL('./ergogen.worker.ts', import.meta.url)); 15 | } catch (e) { 16 | console.error('Failed to create worker:', e); 17 | return null; 18 | } 19 | }; 20 | 21 | /** 22 | * Factory function for creating the JSCAD to STL worker. 23 | */ 24 | export const createJscadWorker = (): Worker | null => { 25 | // Only create worker in browser environment 26 | if (typeof window === 'undefined' || !('Worker' in window)) { 27 | return null; 28 | } 29 | 30 | try { 31 | // Use the new URL syntax to let Webpack bundle the worker 32 | return new Worker(new URL('./jscad.worker.ts', import.meta.url)); 33 | } catch (e) { 34 | console.error('Failed to create JSCAD worker:', e); 35 | return null; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/atoms/GrowButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { theme } from '../theme/theme'; 3 | 4 | /** 5 | * Props for the GrowButton component. 6 | * @typedef {object} Props 7 | * @property {string} [aria-label] - An optional aria-label for the button. 8 | * @property {string} [data-testid] - An optional data-testid for testing purposes. 9 | */ 10 | type Props = { 11 | 'aria-label'?: string; 12 | 'data-testid'?: string; 13 | }; 14 | 15 | /** 16 | * A button that expands to fill the available horizontal space. 17 | */ 18 | const GrowButton = styled.button` 19 | background-color: ${theme.colors.accentSecondary}; 20 | transition: background-color 0.15s ease-in-out; 21 | border: none; 22 | border-radius: 6px; 23 | color: ${theme.colors.white}; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | text-decoration: none; 28 | cursor: pointer; 29 | height: 34px; 30 | font-family: ${theme.fonts.body}; 31 | flex-grow: 1; 32 | 33 | .material-symbols-outlined { 34 | font-size: ${theme.fontSizes.iconMedium} !important; 35 | } 36 | 37 | &:hover { 38 | background-color: ${theme.colors.accentDark}; 39 | } 40 | `; 41 | 42 | export default GrowButton; 43 | -------------------------------------------------------------------------------- /src/atoms/LoadingBar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import LoadingBar from './LoadingBar'; 3 | 4 | describe('LoadingBar', () => { 5 | it('should render when visible is true', () => { 6 | // Arrange & Act 7 | render(); 8 | 9 | // Assert 10 | expect(screen.getByTestId('loading-bar')).toBeInTheDocument(); 11 | }); 12 | 13 | it('should not render when visible is false', () => { 14 | // Arrange & Act 15 | render(); 16 | 17 | // Assert 18 | expect(screen.queryByTestId('loading-bar')).not.toBeInTheDocument(); 19 | }); 20 | 21 | it('should use accent color from theme and be positioned as overlay', () => { 22 | // Arrange & Act 23 | render(); 24 | 25 | const loadingBar = screen.getByTestId('loading-bar'); 26 | 27 | // Assert 28 | expect(loadingBar).toBeInTheDocument(); 29 | // The loading bar container should be positioned fixed as an overlay 30 | expect(loadingBar).toHaveStyle({ 31 | height: '3px', 32 | position: 'fixed', 33 | top: '45px', 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /e2e/utils/screenshots.ts: -------------------------------------------------------------------------------- 1 | import type { Page, TestInfo } from '@playwright/test'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | export const SCREENSHOTS_ROOT = path.join(__dirname, '..', 'screenshots'); 6 | 7 | export function ensureDir(p: string) { 8 | try { 9 | fs.mkdirSync(p, { recursive: true }); 10 | } catch { 11 | // ignore 12 | } 13 | } 14 | 15 | function safeSlug(s: string) { 16 | return s.replace(/[^a-z0-9-_]/gi, '_'); 17 | } 18 | 19 | /** 20 | * Creates a screenshot function for a given test that writes into 21 | * e2e/screenshots// 22 | * 23 | * Filenames follow: --