=> {
104 | const fileName = file.name.toLowerCase();
105 | const extension = fileName.split('.').pop();
106 |
107 | if (extension === 'yaml' || extension === 'yml' || extension === 'json') {
108 | // Load as text file
109 | const config = await loadTextFile(file);
110 | return { config, footprints: [] };
111 | } else if (extension === 'zip' || extension === 'ekb') {
112 | // Load as zip archive
113 | return await loadZipArchive(file);
114 | } else {
115 | throw new Error(
116 | `Unsupported file type. Accepted formats: *.yaml, *.json, *.zip, *.ekb`
117 | );
118 | }
119 | };
120 |
--------------------------------------------------------------------------------
/src/molecules/FilePreview.tsx:
--------------------------------------------------------------------------------
1 | import PcbPreview from '../atoms/PcbPreview';
2 | import StlPreview from '../atoms/StlPreview';
3 | import SvgPreview from '../atoms/SvgPreview';
4 | import TextPreview from '../atoms/TextPreview';
5 |
6 | /**
7 | * Props for the FilePreview component.
8 | * @typedef {object} Props
9 | * @property {string} previewExtension - The file extension of the content to be previewed (e.g., 'svg', 'yaml').
10 | * @property {string} previewKey - A unique key for the preview component, essential for re-rendering.
11 | * @property {string} previewContent - The actual content of the file to be displayed.
12 | * @property {number | string} [width='100%'] - The width of the preview area.
13 | * @property {number | string} [height='100%'] - The height of the preview area.
14 | * @property {string} [className] - An optional CSS class for the container div.
15 | * @property {string} [data-testid] - An optional data-testid for testing purposes.
16 | */
17 | type Props = {
18 | previewExtension: string;
19 | previewKey: string;
20 | previewContent: string;
21 | width?: number | string;
22 | height?: number | string;
23 | className?: string;
24 | 'data-testid'?: string;
25 | 'aria-label'?: string;
26 | };
27 |
28 | /**
29 | * A component that dynamically renders a preview for different file types.
30 | * It selects the appropriate preview component based on the file extension.
31 | *
32 | * @param {Props} props - The props for the component.
33 | * @returns {JSX.Element} A container with the rendered file preview.
34 | */
35 | const FilePreview = ({
36 | previewExtension,
37 | previewContent,
38 | previewKey,
39 | width = '100%',
40 | height = '100%',
41 | className,
42 | 'data-testid': dataTestId,
43 | 'aria-label': ariaLabel,
44 | }: Props) => {
45 | /**
46 | * Renders the correct preview component based on the file extension.
47 | * @param {string} extension - The file extension.
48 | * @returns {JSX.Element | string} The appropriate preview component or a "no preview" message.
49 | */
50 | const renderFilePreview = (extension: string) => {
51 | switch (extension) {
52 | case 'svg':
53 | return (
54 |
61 | );
62 | case 'yaml':
63 | return (
64 |
70 | );
71 | case 'txt':
72 | return (
73 |
79 | );
80 | case 'jscad':
81 | return (
82 |
88 | );
89 | case 'kicad_pcb':
90 | return (
91 |
97 | );
98 | case 'stl':
99 | return (
100 |
105 | );
106 | default:
107 | return 'No preview available';
108 | }
109 | };
110 |
111 | return (
112 |
113 | {renderFilePreview(previewExtension)}
114 |
115 | );
116 | };
117 |
118 | export default FilePreview;
119 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ergogen-gui",
3 | "version": "0.4.0",
4 | "description": "A web-based GUI for Ergogen, the ergonomic keyboard layout generator.",
5 | "license": "MIT",
6 | "private": false,
7 | "dependencies": {
8 | "@jscad/csg": "0.7.0",
9 | "@jscad/stl-serializer": "0.1.3",
10 | "@monaco-editor/react": "^4.6.0",
11 | "@react-three/drei": "^9.92.0",
12 | "@react-three/fiber": "^8.15.0",
13 | "file-saver": "^2.0.5",
14 | "js-yaml": "^4.1.0",
15 | "jszip": "^3.10.1",
16 | "lodash.debounce": "^4.0.8",
17 | "lz-string": "^1.5.0",
18 | "react": "^18.2.0",
19 | "react-dom": "^18.2.0",
20 | "react-easy-panzoom": "^0.4.4",
21 | "react-hotkeys-hook": "^5.1.0",
22 | "react-router-dom": "^7.9.3",
23 | "react-scripts": "5.0.1",
24 | "react-use": "^17.5.0",
25 | "monaco-editor": "0.55.1",
26 | "styled-components": "^6.1.8",
27 | "three": "^0.180.0",
28 | "typescript": "^5.4.5"
29 | },
30 | "scripts": {
31 | "build-ergogen-wasm": "./scripts/build-ergogen-wasm.sh",
32 | "sync-monaco": "./scripts/sync-monaco.sh",
33 | "generate-previews": "ts-node --project scripts/tsconfig.json scripts/generate-previews.js",
34 | "postinstall": "yarn run build-ergogen-wasm && yarn run sync-monaco",
35 | "prestart": "yarn run build-ergogen-wasm && yarn run sync-monaco && yarn run generate-previews",
36 | "start": "react-scripts --openssl-legacy-provider start",
37 | "prebuild": "yarn run build-ergogen-wasm && yarn run sync-monaco && yarn run generate-previews",
38 | "build": "react-scripts --openssl-legacy-provider build",
39 | "test:unit": "CI=true react-scripts --openssl-legacy-provider test --env=jsdom",
40 | "test:e2e": "CI=true playwright test",
41 | "test": "yarn test:unit && yarn test:e2e",
42 | "eject": "react-scripts eject",
43 | "predeploy": "yarn run build",
44 | "deploy": "gh-pages -d build",
45 | "format": "prettier --write \"*.{js,ts,json,md}\" \"{src,e2e,patch}/**/*.{ts,tsx,js,jsx,json,css,md}\"",
46 | "format:check": "prettier --check \"*.{js,ts,json,md}\" \"{src,e2e,patch}/**/*.{ts,tsx,js,jsx,json,css,md}\"",
47 | "lint:js": "eslint --fix \"{src,e2e,patch}/**/*.{ts,tsx,js,jsx}\"",
48 | "lint:js:check": "eslint \"{src,e2e,patch}/**/*.{ts,tsx,js,jsx}\"",
49 | "lint:md": "markdownlint --fix \"**/*.md\"",
50 | "lint:md:check": "markdownlint \"**/*.md\"",
51 | "lint": "yarn lint:md && yarn lint:js && yarn knip",
52 | "lint:check": "yarn lint:md:check && yarn lint:js:check && yarn knip",
53 | "precommit": "yarn format && yarn lint && yarn test"
54 | },
55 | "browserslist": {
56 | "production": [
57 | ">0.2%",
58 | "not dead",
59 | "not op_mini all"
60 | ],
61 | "development": [
62 | "last 1 chrome version",
63 | "last 1 firefox version",
64 | "last 1 safari version"
65 | ]
66 | },
67 | "devDependencies": {
68 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
69 | "@eslint/js": "^9.36.0",
70 | "@playwright/test": "^1.55.1",
71 | "@testing-library/jest-dom": "^6.4.2",
72 | "@testing-library/react": "^15.0.2",
73 | "@testing-library/user-event": "^14.5.2",
74 | "@types/file-saver": "^2.0.7",
75 | "@types/jest": "^29.5.12",
76 | "@types/js-yaml": "^4.0.9",
77 | "@types/lodash-es": "^4.17.12",
78 | "@types/lodash.debounce": "^4.0.9",
79 | "@types/react": "^18.2.0",
80 | "@types/styled-components": "^5.1.34",
81 | "@typescript-eslint/eslint-plugin": "^8.45.0",
82 | "@typescript-eslint/parser": "^8.45.0",
83 | "eslint": "^9.36.0",
84 | "eslint-config-prettier": "^10.1.8",
85 | "eslint-plugin-import": "^2.29.1",
86 | "eslint-plugin-jest": "^29.0.1",
87 | "eslint-plugin-jsx-a11y": "^6.8.0",
88 | "eslint-plugin-prettier": "^5.5.4",
89 | "eslint-plugin-react": "^7.37.5",
90 | "eslint-plugin-react-hooks": "^6.2.0-canary-4fdf7cf2-20251003",
91 | "gh-pages": "^6.1.1",
92 | "globals": "^16.4.0",
93 | "knip": "^5.64.1",
94 | "markdownlint": "^0.38.0",
95 | "markdownlint-cli": "^0.45.0",
96 | "prettier": "^3.6.2",
97 | "three-stdlib": "^2.36.0",
98 | "ts-node": "^10.9.2",
99 | "typescript-eslint": "^8.45.0"
100 | },
101 | "knip": {
102 | "ignore": [
103 | "patch/**",
104 | "public/**"
105 | ]
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/utils/zip.ts:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip';
2 | import { saveAs } from 'file-saver';
3 |
4 | type DemoOutput = {
5 | dxf?: string;
6 | svg?: string;
7 | };
8 |
9 | type OutlineOutput = {
10 | dxf?: string;
11 | svg?: string;
12 | };
13 |
14 | type CaseOutput = {
15 | jscad?: string;
16 | jscad_v2?: string;
17 | stl?: string;
18 | };
19 |
20 | type PcbsOutput = Record;
21 |
22 | type Results = {
23 | canonical?: unknown;
24 | points?: unknown;
25 | units?: unknown;
26 | demo?: DemoOutput;
27 | outlines?: Record;
28 | cases?: Record;
29 | pcbs?: PcbsOutput;
30 | [key: string]: unknown;
31 | };
32 |
33 | export const createZip = async (
34 | results: Results,
35 | config: string,
36 | injections: string[][] | undefined,
37 | debug: boolean,
38 | stlPreview: boolean
39 | ) => {
40 | const zip = new JSZip();
41 |
42 | // Root folder
43 | if (results.demo?.svg) {
44 | zip.file('demo.svg', results.demo.svg);
45 | }
46 | zip.file('config.yaml', config);
47 |
48 | // Outlines folder
49 | if (results.outlines) {
50 | const outlinesFolder = zip.folder('outlines');
51 | if (outlinesFolder) {
52 | for (const [name, outline] of Object.entries(results.outlines)) {
53 | if (debug || !name.startsWith('_')) {
54 | if (outline.dxf) {
55 | outlinesFolder.file(`${name}.dxf`, outline.dxf);
56 | }
57 | if (outline.svg) {
58 | outlinesFolder.file(`${name}.svg`, outline.svg);
59 | }
60 | }
61 | }
62 | }
63 | }
64 |
65 | // PCBs folder
66 | if (results.pcbs) {
67 | const pcbsFolder = zip.folder('pcbs');
68 | if (pcbsFolder) {
69 | for (const [name, pcb] of Object.entries(results.pcbs)) {
70 | pcbsFolder.file(name, pcb);
71 | }
72 | }
73 | }
74 |
75 | // Cases folder
76 | if (results.cases) {
77 | const casesFolder = zip.folder('cases');
78 | if (casesFolder) {
79 | for (const [name, caseData] of Object.entries(results.cases)) {
80 | const caseDataAny = caseData as unknown as {
81 | jscad?: string;
82 | jscad_v2?: string;
83 | jscadV2?: string;
84 | stl?: string;
85 | };
86 | const caseJscad =
87 | caseDataAny.jscad_v2 ?? caseDataAny.jscadV2 ?? caseDataAny.jscad;
88 | if (caseJscad) {
89 | casesFolder.file(`${name}.jscad`, caseJscad);
90 | }
91 | if (
92 | debug &&
93 | caseDataAny.jscad &&
94 | (caseDataAny.jscad_v2 || caseDataAny.jscadV2)
95 | ) {
96 | casesFolder.file(`${name}.legacy.jscad`, caseDataAny.jscad);
97 | }
98 | if (stlPreview && caseDataAny.stl) {
99 | casesFolder.file(`${name}.stl`, caseDataAny.stl);
100 | }
101 | }
102 | }
103 | }
104 |
105 | // Debug folder
106 | if (debug) {
107 | const debugFolder = zip.folder('debug');
108 | if (debugFolder) {
109 | debugFolder.file('raw.txt', config);
110 | for (const [key, value] of Object.entries(results)) {
111 | if (['canonical', 'points', 'units'].includes(key)) {
112 | debugFolder.file(`${key}.yaml`, JSON.stringify(value, null, 2));
113 | }
114 | }
115 | }
116 | }
117 |
118 | // Footprints folder
119 | if (injections && injections.length > 0) {
120 | const footprintsFolder = zip.folder('footprints');
121 | if (footprintsFolder) {
122 | for (const injection of injections) {
123 | const [, name, content] = injection;
124 | const pathParts = name.split('/');
125 | const fileName = pathParts.pop();
126 | let currentFolder = footprintsFolder;
127 | for (const part of pathParts) {
128 | currentFolder = currentFolder.folder(part) || currentFolder;
129 | }
130 | if (fileName) {
131 | currentFolder.file(`${fileName}.js`, content);
132 | }
133 | }
134 | }
135 | }
136 |
137 | // Generate the zip file
138 | const blob = await zip.generateAsync({
139 | type: 'blob',
140 | compression: 'DEFLATE',
141 | compressionOptions: { level: 9 },
142 | });
143 |
144 | // Trigger download
145 | const timestamp = new Date()
146 | .toISOString()
147 | .replace(/[:.]/g, '-')
148 | .split('T')[0];
149 | const filename = `ergogen-${timestamp}.zip`;
150 | saveAs(blob, filename);
151 | };
152 |
--------------------------------------------------------------------------------
/e2e/routing.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 | import { makeShooter } from './utils/screenshots';
3 | import Absolem from '../src/examples/absolem';
4 | import { CONFIG_LOCAL_STORAGE_KEY } from '../src/context/constants';
5 |
6 | test.describe('Routing and Welcome Page', () => {
7 | test('new user is redirected to /new', async ({ page }) => {
8 | const shoot = makeShooter(page, test.info());
9 | await page.goto('/');
10 | await shoot('before-redirect-to-new');
11 | await expect(page).toHaveURL(/.*\/new/);
12 | await expect(page.getByText('Ergogen Web UI')).toBeVisible();
13 | await shoot('after-redirect-to-new');
14 | });
15 |
16 | test('existing user is routed to /', async ({ page }) => {
17 | const shoot = makeShooter(page, test.info());
18 | // Simulate existing user by setting a value in local storage
19 | await page.addInitScript((CONFIG_LOCAL_STORAGE_KEY) => {
20 | localStorage.setItem(
21 | CONFIG_LOCAL_STORAGE_KEY,
22 | JSON.stringify('some config')
23 | );
24 | }, CONFIG_LOCAL_STORAGE_KEY);
25 | await page.goto('/');
26 | await shoot('before-existing-user-routed-home');
27 | await expect(page).toHaveURL(/.*\/$/);
28 | await expect(page.getByTestId('config-editor')).toBeVisible();
29 | await shoot('after-existing-user-routed-home');
30 | });
31 |
32 | test('"Add" (new config) button requires existing config', async ({
33 | page,
34 | }) => {
35 | const shoot = makeShooter(page, test.info());
36 | await page.goto('/');
37 | // With no config, the "New Design" button should not be visible on the main page
38 | const btn = page.getByTestId('new-config-button');
39 | await shoot('before-new-config-btn-not-visible');
40 | await expect(btn).not.toBeVisible();
41 | await shoot('after-new-config-btn-not-visible');
42 | });
43 |
44 | test('"Add" (new config) button navigates to /new', async ({ page }) => {
45 | const shoot = makeShooter(page, test.info());
46 | // The header button that starts a new config on the home page
47 | const newConfigButton = page.getByTestId('new-config-button');
48 |
49 | // 1. Set a valid config in local storage
50 | await page.addInitScript(
51 | ({ config, key }) => {
52 | localStorage.setItem(key, JSON.stringify(config));
53 | },
54 | { config: Absolem.value, key: CONFIG_LOCAL_STORAGE_KEY }
55 | );
56 | await page.goto('/');
57 |
58 | // 2. Now the button should be visible, click it
59 | await shoot('before-new-config-button-visible');
60 | await expect(newConfigButton).toBeVisible();
61 | await shoot('after-new-config-button-visible');
62 | await newConfigButton.click();
63 |
64 | // 3. Assert navigation to the /new page
65 | await shoot('before-url-new-and-welcome');
66 | await expect(page).toHaveURL(/.*\/new/);
67 | await expect(page.getByText('Ergogen Web UI')).toBeVisible();
68 | await shoot('after-url-new-and-welcome');
69 | });
70 |
71 | test('clicking "Empty Configuration" creates an empty config and navigates to /', async ({
72 | page,
73 | }) => {
74 | const shoot = makeShooter(page, test.info());
75 | await page.goto('/new');
76 | await page.getByRole('button', { name: 'Empty Configuration' }).click();
77 | await shoot('before-empty-config-url-and-editor');
78 | await expect(page).toHaveURL(/.*\/$/);
79 | await expect(page.getByTestId('config-editor')).toBeVisible();
80 | await shoot('after-empty-config-url-and-editor');
81 |
82 | await expect(async () => {
83 | const editorContent = await page.locator('.monaco-editor').textContent();
84 | expect(editorContent).toContain('points:');
85 | }).toPass();
86 | });
87 |
88 | test('clicking an example loads the config and navigates to /', async ({
89 | page,
90 | }) => {
91 | const shoot = makeShooter(page, test.info());
92 | await page.goto('/new');
93 | await page.getByText(Absolem.label).click();
94 | await shoot('before-example-url-and-editor');
95 | await expect(page).toHaveURL(/.*\/$/);
96 | await expect(page.getByTestId('config-editor')).toBeVisible();
97 | await shoot('after-example-url-and-editor');
98 |
99 | // Verify the config was stored by checking localStorage rather than Monaco's DOM text,
100 | // which renders whitespace differently and is flaky to assert on.
101 | await expect(async () => {
102 | const stored = await page.evaluate(
103 | (key) => localStorage.getItem(key),
104 | CONFIG_LOCAL_STORAGE_KEY
105 | );
106 | expect(stored).not.toBeNull();
107 | // react-use stores raw strings JSON-encoded in localStorage
108 | const parsed = JSON.parse(stored as string) as string;
109 | expect(parsed).toContain('meta:');
110 | expect(parsed).toContain('points:');
111 | }).toPass();
112 | });
113 | });
114 |
--------------------------------------------------------------------------------
/src/molecules/Injections.tsx:
--------------------------------------------------------------------------------
1 | import InjectionRow from '../atoms/InjectionRow';
2 | import { Injection } from '../atoms/InjectionRow';
3 | import styled from 'styled-components';
4 | import { useConfigContext } from '../context/ConfigContext';
5 | import { Dispatch, SetStateAction } from 'react';
6 | import GrowButton from '../atoms/GrowButton';
7 |
8 | /**
9 | * A styled container for the injections list.
10 | */
11 | const InjectionsContainer = styled.div`
12 | display: flex;
13 | flex-direction: column;
14 | flex-grow: 1;
15 | `;
16 |
17 | // Use the shared Title component from atoms
18 | import Title from '../atoms/Title';
19 |
20 | /**
21 | * Props for the Injections component.
22 | * @typedef {object} Props
23 | * @property {Dispatch>} setInjectionToEdit - Function to set the injection to be edited.
24 | * @property {(injection: Injection) => void} deleteInjection - Function to delete an injection.
25 | * @property {() => void} [onInjectionSelect] - Optional callback when an injection is selected (for mobile).
26 | */
27 | type Props = {
28 | setInjectionToEdit: Dispatch>;
29 | deleteInjection: (injection: Injection) => void;
30 | injectionToEdit: Injection;
31 | onInjectionSelect?: () => void;
32 | 'data-testid'?: string;
33 | };
34 |
35 | /**
36 | * An array of Injection objects.
37 | * @typedef {Injection[]} InjectionArr
38 | */
39 | type InjectionArr = Array;
40 |
41 | /**
42 | * A component that displays and manages lists of custom footprints and templates.
43 | * It reads injection data from the ConfigContext and provides functionality to add new injections.
44 | *
45 | * @param {Props} props - The props for the component.
46 | * @returns {JSX.Element | null} The rendered component or null if context is not available.
47 | */
48 | const Injections = ({
49 | setInjectionToEdit,
50 | deleteInjection,
51 | injectionToEdit,
52 | onInjectionSelect,
53 | 'data-testid': dataTestId,
54 | }: Props) => {
55 | const footprints: InjectionArr = [];
56 | const templates: InjectionArr = [];
57 | const configContext = useConfigContext();
58 | if (!configContext) return null;
59 |
60 | const { injectionInput } = configContext;
61 | if (
62 | injectionInput &&
63 | Array.isArray(injectionInput) &&
64 | injectionInput.length > 0
65 | ) {
66 | for (let i = 0; i < injectionInput.length; i++) {
67 | const injection = injectionInput[i];
68 | if (injection.length === 3) {
69 | const collection =
70 | injection[0] === 'footprint' ? footprints : templates;
71 | collection.push({
72 | key: i,
73 | type: injection[0],
74 | name: injection[1],
75 | content: injection[2],
76 | });
77 | }
78 | }
79 | }
80 |
81 | /**
82 | * Handles the creation of a new footprint.
83 | * It creates a new injection object with a default template and calls `setInjectionToEdit`
84 | * to open it in the editor.
85 | */
86 | const handleNewFootprint = () => {
87 | const nextKey = configContext?.injectionInput?.length || 0;
88 | const newInjection = {
89 | key: nextKey,
90 | type: 'footprint',
91 | name: `custom_footprint_${nextKey + 1}`,
92 | content:
93 | "module.exports = {\n params: {\n designator: '',\n },\n body: p => ``\n}",
94 | };
95 | setInjectionToEdit(newInjection);
96 | // Show editor on mobile when new injection is created
97 | if (onInjectionSelect) {
98 | onInjectionSelect();
99 | }
100 | };
101 |
102 | return (
103 |
104 | Custom Footprints
105 | {footprints.map((footprint) => {
106 | return (
107 | {
111 | setInjectionToEdit(injection);
112 | // Show editor on mobile when injection is selected
113 | if (onInjectionSelect) {
114 | onInjectionSelect();
115 | }
116 | }}
117 | deleteInjection={deleteInjection}
118 | previewKey={injectionToEdit.name}
119 | data-testid={dataTestId && `${dataTestId}-${footprint.name}`}
120 | />
121 | );
122 | })}
123 | {/* Custom Templates
124 | {
125 | templates.map(
126 | (template, i) => {
127 | return ;
128 | }
129 | )
130 | } */}
131 |
136 | add
137 |
138 |
139 | );
140 | };
141 |
142 | export default Injections;
143 |
--------------------------------------------------------------------------------
/src/examples/tiny20.ts:
--------------------------------------------------------------------------------
1 | import { ConfigExample } from './index';
2 |
3 | /**
4 | * An example Ergogen configuration for the Tiny20 keyboard.
5 | * @type {ConfigExample}
6 | */
7 | const Tiny20: ConfigExample = {
8 | label: 'Tiny20',
9 | author: 'enzocoralc',
10 | value: `meta:
11 | engine: 4.1.0
12 | points:
13 | zones:
14 | matrix:
15 | anchor:
16 | rotate: 5
17 | shift: [50,-75] # Fix KiCad placement
18 | columns:
19 | pinky:
20 | key:
21 | spread: 18
22 | rows:
23 | bottom:
24 | column_net: P21
25 | home:
26 | column_net: P20
27 | ring:
28 | key:
29 | spread: 18
30 | splay: -5
31 | origin: [-12, -19]
32 | stagger: 16
33 | rows:
34 | bottom:
35 | column_net: P19
36 | home:
37 | column_net: P18
38 | middle:
39 | key:
40 | spread: 18
41 | stagger: 5
42 | rows:
43 | bottom:
44 | column_net: P15
45 | home:
46 | column_net: P14
47 | index:
48 | key:
49 | spread: 18
50 | stagger: -6
51 | rows:
52 | bottom:
53 | column_net: P26
54 | home:
55 | column_net: P10
56 | rows:
57 | bottom:
58 | padding: 17
59 | home:
60 | padding: 17
61 | thumb:
62 | anchor:
63 | ref: matrix_index_bottom
64 | shift: [2, -20]
65 | rotate: 90
66 | columns:
67 | near:
68 | key:
69 | splay: -90
70 | origin: [0,0]
71 | rows:
72 | home:
73 | rotate: -90
74 | column_net: P8
75 | home:
76 | key:
77 | spread: 17
78 | rotate: 90
79 | origin: [0,0]
80 | rows:
81 | home:
82 | column_net: P9
83 |
84 | outlines:
85 | plate:
86 | - what: rectangle
87 | where: true
88 | asym: source
89 | size: 18
90 | corner: 3
91 | - what: rectangle
92 | where: true
93 | asym: source
94 | size: 14
95 | bound: false
96 | operation: subtract
97 | _pcb_perimeter_raw:
98 | - what: rectangle
99 | where: true
100 | asym: source
101 | size: 18
102 | corner: 1
103 | _polygon:
104 | - what: polygon # all borders
105 | operation: stack
106 | points:
107 | - ref: matrix_pinky_bottom
108 | shift: [-9,-9]
109 | - ref: matrix_pinky_home
110 | shift: [-9,1.3u]
111 | - ref: matrix_middle_home
112 | shift: [-9,9]
113 | - ref: matrix_middle_home
114 | shift: [9,9]
115 | - ref: matrix_index_home
116 | shift: [1.45u,9]
117 | - ref: thumb_home_home
118 | shift: [8,-9]
119 | - ref: thumb_near_home
120 | shift: [9,-9]
121 | pcb_perimeter:
122 | - what: outline # keys
123 | name: _pcb_perimeter_raw
124 | - what: outline
125 | name: _polygon
126 | operation: add
127 |
128 | pcbs:
129 | tiny20:
130 | template: kicad8
131 | outlines:
132 | main:
133 | outline: pcb_perimeter
134 | footprints:
135 | keys:
136 | what: ceoloide/switch_choc_v1_v2
137 | where: true
138 | params:
139 | from: GND
140 | to: "{{column_net}}"
141 | include_keycap: true
142 | keycap_width: 17.5
143 | keycap_height: 16.5
144 | reversible: true
145 | hotswap: false
146 | solder: true
147 | choc_v2_support: false
148 | promicro:
149 | what: ceoloide/mcu_nice_nano
150 | where: matrix_index_home
151 | params:
152 | reverse_mount: true
153 | reversible: true
154 | only_required_jumpers: true
155 | adjust.shift: [0.95u, -0.5u]
156 | trrs:
157 | what: ceoloide/trrs_pj320a
158 | where:
159 | ref: matrix_pinky_home
160 | shift: [0, 1.2u]
161 | rotate: 0
162 | params:
163 | SL: GND
164 | R2: P1
165 | TP: VCC # Tip and Ring 1 are joined togetherue
166 | symmetric: true
167 | reversible: true
168 | reset:
169 | what: ceoloide/reset_switch_tht_top
170 | where: matrix_ring_home
171 | params:
172 | from: RST
173 | to: GND
174 | reversible: true
175 | adjust:
176 | shift: [-0.7u, 0]
177 | rotate: 90
178 | jlcpcb_order_number_text:
179 | what: ceoloide/utility_text
180 | where: matrix_middle_bottom
181 | params:
182 | text: JLCJLCJLCJLC
183 | reversible: true
184 | adjust:
185 | shift: [0,-u/2]
186 | ergogen_logo:
187 | what: ceoloide/utility_ergogen_logo
188 | where: matrix_middle_bottom
189 | params:
190 | scale: 2.5
191 | reversible: true
192 | adjust:
193 | shift: [0,-1.25u]
194 | `,
195 | };
196 |
197 | export default Tiny20;
198 |
--------------------------------------------------------------------------------
/src/molecules/ConflictResolutionDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import styled from 'styled-components';
3 | import { theme } from '../theme/theme';
4 | import Button from '../atoms/Button';
5 |
6 | /**
7 | * Props for the ConflictResolutionDialog component.
8 | */
9 | type ConflictResolutionDialogProps = {
10 | injectionName: string;
11 | injectionType: string;
12 | onResolve: (
13 | action: 'skip' | 'overwrite' | 'keep-both',
14 | applyToAll: boolean
15 | ) => void;
16 | onCancel: () => void;
17 | 'data-testid'?: string;
18 | };
19 |
20 | /**
21 | * Capitalizes the first letter of a string.
22 | */
23 | const capitalize = (str: string): string => {
24 | return str.charAt(0).toUpperCase() + str.slice(1);
25 | };
26 |
27 | /**
28 | * A dialog component that prompts the user to resolve injection name conflicts.
29 | * Provides options to skip, overwrite, or keep both injections.
30 | */
31 | const ConflictResolutionDialog: React.FC = ({
32 | injectionName,
33 | injectionType,
34 | onResolve,
35 | onCancel,
36 | 'data-testid': dataTestId,
37 | }) => {
38 | const [applyToAll, setApplyToAll] = useState(false);
39 | const typeLabel = capitalize(injectionType);
40 |
41 | return (
42 |
43 |
44 | {typeLabel} Conflict
45 |
46 | A {injectionType} with the name {injectionName}{' '}
47 | already exists.
48 |
49 | How would you like to resolve this conflict?
50 |
51 |
52 |
53 | setApplyToAll(e.target.checked)}
58 | data-testid={dataTestId && `${dataTestId}-apply-to-all`}
59 | aria-label="Apply this choice to all conflicts"
60 | />
61 |
62 |
63 |
64 |
65 |
73 |
81 |
89 |
90 |
96 | Cancel
97 |
98 |
99 |
100 | );
101 | };
102 |
103 | const Overlay = styled.div`
104 | position: fixed;
105 | top: 0;
106 | left: 0;
107 | right: 0;
108 | bottom: 0;
109 | background-color: rgba(0, 0, 0, 0.7);
110 | display: flex;
111 | align-items: center;
112 | justify-content: center;
113 | z-index: 1000;
114 | `;
115 |
116 | const DialogBox = styled.div`
117 | background-color: ${theme.colors.backgroundLight};
118 | border: 1px solid ${theme.colors.border};
119 | border-radius: 8px;
120 | padding: 2rem;
121 | max-width: 500px;
122 | width: 90%;
123 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
124 | `;
125 |
126 | const Title = styled.h2`
127 | margin: 0 0 1rem 0;
128 | font-size: ${theme.fontSizes.h3};
129 | color: ${theme.colors.text};
130 | `;
131 |
132 | const Message = styled.p`
133 | margin: 0 0 1.5rem 0;
134 | font-size: ${theme.fontSizes.base};
135 | color: ${theme.colors.textDark};
136 | line-height: 1.5;
137 |
138 | strong {
139 | color: ${theme.colors.accent};
140 | }
141 | `;
142 |
143 | const CheckboxContainer = styled.div`
144 | display: flex;
145 | align-items: center;
146 | margin-bottom: 1.5rem;
147 | gap: 0.5rem;
148 |
149 | input[type='checkbox'] {
150 | cursor: pointer;
151 | }
152 |
153 | label {
154 | cursor: pointer;
155 | font-size: ${theme.fontSizes.base};
156 | color: ${theme.colors.textDark};
157 | }
158 | `;
159 |
160 | const ButtonGroup = styled.div`
161 | display: flex;
162 | gap: 0.75rem;
163 | justify-content: space-between;
164 | margin-bottom: 1rem;
165 | `;
166 |
167 | const CancelButton = styled(Button)`
168 | width: 100%;
169 | background-color: ${theme.colors.backgroundLighter};
170 | color: ${theme.colors.textDark};
171 |
172 | &:hover {
173 | background-color: ${theme.colors.buttonHover};
174 | }
175 | `;
176 |
177 | export default ConflictResolutionDialog;
178 |
--------------------------------------------------------------------------------
/src/examples/plank.ts:
--------------------------------------------------------------------------------
1 | import { ConfigExample } from './index';
2 |
3 | /**
4 | * An example Ergogen configuration for a Plank-like ortholinear keyboard.
5 | * This example features a 2u spacebar.
6 | * @type {ConfigExample}
7 | */
8 | const Plank: ConfigExample = {
9 | label: 'Plank',
10 | author: 'cache.works',
11 | value: `meta:
12 | engine: 4.1.0
13 | units:
14 | visual_x: 17.5
15 | visual_y: 16.5
16 | points:
17 | zones:
18 | matrix:
19 | anchor:
20 | shift: [50, -100] # Fix KiCad placement
21 | columns:
22 | one:
23 | key:
24 | column_net: P1
25 | column_mark: 1
26 | two:
27 | key:
28 | spread: 1cx
29 | column_net: P0
30 | column_mark: 2
31 | three:
32 | key:
33 | spread: 1cx
34 | column_net: P14
35 | column_mark: 3
36 | four:
37 | key:
38 | spread: 1cx
39 | column_net: P20
40 | column_mark: 4
41 | five:
42 | key:
43 | spread: 1cx
44 | column_net: P2
45 | column_mark: 5
46 | six:
47 | key:
48 | spread: 1cx
49 | column_net: P3
50 | column_mark: 6
51 | seven:
52 | key:
53 | spread: 1cx
54 | column_net: P4
55 | column_mark: 7
56 | rows:
57 | 2uspacebar:
58 | skip: false
59 | shift: [-0.5cx, 1cy]
60 | rotate: 180
61 | modrow:
62 | shift: [-0.5cx, -1cy]
63 | rotate: 180
64 | eight:
65 | key:
66 | spread: 1cx
67 | column_net: P5
68 | column_mark: 8
69 | nine:
70 | key:
71 | spread: 1cx
72 | column_net: P6
73 | column_mark: 9
74 | ten:
75 | key:
76 | spread: 1cx
77 | column_net: P7
78 | column_mark: 10
79 | eleven:
80 | key:
81 | spread: 1cx
82 | column_net: P8
83 | column_mark: 11
84 | twelve:
85 | key:
86 | spread: 1cx
87 | column_net: P9
88 | column_mark: 12
89 | rows:
90 | 2uspacebar:
91 | padding: 1cy
92 | row_net: P19
93 | skip: true
94 | modrow:
95 | padding: 1cy
96 | row_net: P19
97 | bottom:
98 | padding: 1cy
99 | row_net: P18
100 | home:
101 | padding: 1cy
102 | row_net: P15
103 | top:
104 | padding: 1cy
105 | row_net: P21
106 | key:
107 | bind: 2
108 | outlines:
109 | _raw:
110 | - what: rectangle
111 | where: true
112 | asym: left
113 | size: [1cx,1cy]
114 | panel:
115 | - what: outline
116 | name: _raw
117 | expand: 1.6
118 | _switch_cutouts:
119 | - what: rectangle
120 | where: true
121 | asym: left
122 | size: 14
123 | bound: false
124 | switch_plate:
125 | main:
126 | what: outline
127 | name: panel
128 | keyholes:
129 | what: outline
130 | name: _switch_cutouts
131 | operation: subtract
132 | pcbs:
133 | plank:
134 | template: kicad8
135 | outlines:
136 | main:
137 | outline: panel
138 | footprints:
139 | choc:
140 | what: ceoloide/switch_choc_v1_v2
141 | where: true
142 | params:
143 | from: "{{colrow}}"
144 | to: "{{column_net}}"
145 | include_keycap: true
146 | keycap_width: 17.5
147 | keycap_height: 16.5
148 | choc_v2_support: false
149 | solder: true
150 | hotswap: false
151 | diode:
152 | what: ceoloide/diode_tht_sod123
153 | where: true
154 | adjust:
155 | rotate: 0
156 | shift: [ 0, -4.5 ]
157 | params:
158 | from: "{{colrow}}"
159 | to: "{{row_net}}"
160 | promicro:
161 | what: ceoloide/mcu_nice_nano
162 | where:
163 | ref: matrix_seven_top
164 | shift: [-0.5cx, 1]
165 | rotate: 90
166 | params:
167 | reverse_mount: true
168 | powerswitch:
169 | what: ceoloide/power_switch_smd_side
170 | where:
171 | ref: matrix_four_top
172 | shift: [0.5cx+2, cy/2 - 1.8 + 1.6]
173 | rotate: 90
174 | params:
175 | from: RAW
176 | to: BAT_P
177 | side: B
178 | jstph:
179 | what: ceoloide/battery_connector_jst_ph_2
180 | where:
181 | ref: matrix_four_top
182 | shift: [0.5cx, -1.5cy]
183 | rotate: 180
184 | params:
185 | BAT_P: BAT
186 | BAT_N: GND
187 | side: B
188 | jlcpcb_order_number_text:
189 | what: ceoloide/utility_text
190 | where: matrix_seven_2uspacebar
191 | params:
192 | text: JLCJLCJLCJLC
193 | reversible: true
194 | adjust:
195 | shift: [0,-u/2]
196 | ergogen_logo:
197 | what: ceoloide/utility_ergogen_logo
198 | where: matrix_seven_2uspacebar
199 | params:
200 | scale: 1.75
201 | reversible: true
202 | adjust:
203 | shift: [0,-1.5cy-2]
204 | `,
205 | };
206 |
207 | export default Plank;
208 |
--------------------------------------------------------------------------------
/src/atoms/InjectionRow.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components';
2 | import { theme } from '../theme/theme';
3 |
4 | /**
5 | * Interface representing a code injection.
6 | * @interface Injection
7 | * @property {number} key - A unique identifier for the injection.
8 | * @property {string} type - The type of the injection (e.g., 'pcb', 'points').
9 | * @property {string} name - The name of the injection.
10 | * @property {string} content - The code content of the injection.
11 | */
12 | export interface Injection {
13 | key: number;
14 | type: string;
15 | name: string;
16 | content: string;
17 | }
18 |
19 | /**
20 | * Props for the InjectionRow component.
21 | * @typedef {object} Props
22 | * @property {Injection} injection - The injection object to display.
23 | * @property {(injection: Injection) => void} setInjectionToEdit - Function to set the injection to be edited.
24 | * @property {(injection: Injection) => void} deleteInjection - Function to delete the injection.
25 | * @property {string} previewKey - The key of the currently active preview.
26 | */
27 | type Props = {
28 | injection: Injection;
29 | setInjectionToEdit: (injection: Injection) => void;
30 | deleteInjection: (injection: Injection) => void;
31 | previewKey: string;
32 | 'data-testid'?: string;
33 | };
34 |
35 | /**
36 | * A styled div for the row layout.
37 | */
38 | const Row = styled.div`
39 | display: flex;
40 | justify-content: space-between;
41 | align-items: center;
42 | padding-bottom: 0.75rem;
43 | `;
44 |
45 | /**
46 | * A styled div for displaying the injection name, with ellipsis for overflow.
47 | */
48 | const InjectionName = styled.div<{ $active: boolean }>`
49 | overflow: hidden;
50 | text-overflow: ellipsis;
51 | font-size: ${theme.fontSizes.bodySmall};
52 | cursor: pointer;
53 | border-bottom: ${(props) =>
54 | props.$active
55 | ? `2px solid ${theme.colors.accent}`
56 | : '2px solid transparent'};
57 | border-top: 2px solid transparent;
58 | `;
59 |
60 | /**
61 | * A styled div to contain the action buttons.
62 | */
63 | const Buttons = styled.div`
64 | white-space: nowrap;
65 | display: flex;
66 | gap: 6px;
67 | align-items: center;
68 | `;
69 |
70 | const buttonStyles = css`
71 | background-color: ${theme.colors.background};
72 | border: none;
73 | border-radius: 6px;
74 | color: ${theme.colors.white};
75 | display: flex;
76 | align-items: center;
77 | padding: 4px 6px;
78 | text-decoration: none;
79 | cursor: pointer;
80 | font-size: ${theme.fontSizes.bodySmall};
81 | line-height: 16px;
82 | gap: 6px;
83 |
84 | .material-symbols-outlined {
85 | font-size: ${theme.fontSizes.iconMedium} !important;
86 | }
87 |
88 | &:hover {
89 | background-color: ${theme.colors.buttonHover};
90 | }
91 | `;
92 |
93 | /**
94 | * A styled button with a dark background.
95 | */
96 | const StyledLinkButton = styled.a`
97 | ${buttonStyles}
98 | `;
99 |
100 | /**
101 | * A styled button for editing on mobile, only visible on screens <=639px.
102 | */
103 | const MobileEditButton = styled.a`
104 | ${buttonStyles}
105 |
106 | @media (min-width: 640px) {
107 | display: none;
108 | }
109 | `;
110 |
111 | /**
112 | * A component that displays a single injection with buttons to edit, delete, and download.
113 | *
114 | * @param {Props} props - The props for the component.
115 | * @returns {JSX.Element} A row displaying the injection name and action buttons.
116 | */
117 | const InjectionRow = ({
118 | injection,
119 | setInjectionToEdit,
120 | deleteInjection,
121 | previewKey,
122 | 'data-testid': dataTestId,
123 | }: Props): JSX.Element => {
124 | return (
125 |
126 | setInjectionToEdit(injection)}
130 | >
131 | {injection.name}
132 |
133 |
134 | {
137 | e.preventDefault();
138 | setInjectionToEdit(injection);
139 | }}
140 | aria-label={`edit injection ${injection.name}`}
141 | data-testid={dataTestId && `${dataTestId}-edit`}
142 | >
143 | edit
144 |
145 | {
148 | e.preventDefault();
149 | deleteInjection(injection);
150 | }}
151 | aria-label={`delete injection ${injection.name}`}
152 | data-testid={dataTestId && `${dataTestId}-delete`}
153 | >
154 | delete
155 |
156 |
166 | download
167 |
168 |
169 |
170 | );
171 | };
172 |
173 | export default InjectionRow;
174 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## Faster, More Consistent Ergogen Outputs
4 |
5 | December 20, 2025
6 |
7 | 
8 |
9 | Generating files for a keyboard design used to rely on the JavaScript runtime in your browser. It worked, but on complex designs it could be slower and occasionally inconsistent between environments, which made debugging harder.
10 |
11 | The app now runs Ergogen through a Rust-powered WebAssembly engine. You still get the same familiar outputs and previews, but generation is steadier and better aligned with the command-line experience. Custom footprint injections continue to work the same way, so your existing configurations stay compatible.
12 |
13 | **What changed:**
14 |
15 | - **WASM-based generation**: The core engine now runs in Rust for more reliable output
16 | - **Parity with CLI**: Outputs match the command-line Ergogen behavior more closely
17 | - **Better injection compatibility**: Custom footprint injections can now include YAML/spec sources (not just JS)
18 | - **No workflow changes**: Your configs and custom footprints continue to work as before
19 |
20 | ## Share A Link To Your Keyboard Configuration
21 |
22 | November 3, 2025
23 |
24 | 
25 |
26 | Sharing your keyboard design with others used to be a hassle. You'd have to export your configuration file, package up all your custom footprints separately, and send multiple files or links. The recipient would then need to load everything manually, making collaboration tedious and error-prone.
27 |
28 | Now you can share your entire keyboard configuration – including all custom footprints – with a single link. Click the share button and a dialog appears with your personalized shareable URL. The link is automatically copied to your clipboard, ready to paste anywhere. Recipients can simply click the link to load your complete configuration with all custom components intact.
29 |
30 | **What changed:**
31 |
32 | - **Shareable links**: Generate a single URL that contains your keyboard configuration
33 | - **Complete configuration sharing**: All custom footprints and templates are included in the shared link
34 |
35 | ## Load Configurations from Local Files
36 |
37 | November 2, 2025
38 |
39 | 
40 |
41 | Working with keyboard configurations used to mean you could only start from scratch or load from GitHub. If you had a configuration file saved on your computer, you'd need to copy and paste it manually – and forget about loading custom footprints that way!
42 |
43 | Now you can load entire keyboard configurations directly from your computer. Simply click the "Choose File" button or drag and drop any supported file onto the page. The app accepts YAML and JSON configuration files, as well as ZIP and EKB archives that include both the configuration and custom footprints.
44 |
45 | When loading archives, the app automatically extracts custom footprints from the `footprints` folder, just like when loading from GitHub. If you already have footprints with the same names, you'll see the same friendly conflict resolution dialog to choose how to handle duplicates.
46 |
47 | **What changed:**
48 |
49 | - **Local file loading**: Load configurations directly from your computer using YAML, JSON, ZIP, or EKB files
50 | - **Drag and drop support**: Drop files anywhere on the welcome page for quick loading
51 | - **Archive support**: ZIP and EKB archives automatically extract configurations and footprints
52 | - **Conflict resolution**: Same interactive dialog for handling duplicate footprints as GitHub loading
53 |
54 | ## Load Keyboards Directly from GitHub
55 |
56 | October 13, 2025
57 |
58 | 
59 |
60 | Ever wanted to share your keyboard design with a friend or try out someone else's layout? You can now load complete keyboard configurations directly from GitHub, including all the custom footprints!
61 |
62 | Previously, loading a configuration from GitHub only brought in the basic layout file. You'd have to manually recreate any custom components (like special switches or connectors) that the design depended on. This was time-consuming and error-prone, often leading to confusing errors about missing parts.
63 |
64 | Now, when you load a keyboard from GitHub, the app automatically discovers and loads all custom footprints from the repository – even those stored in separate libraries using Git submodules. If you already have a footprint with the same name, you'll get a friendly dialog asking whether to skip, overwrite, or keep both versions.
65 |
66 | The app also got smarter about finding configurations. It can now search through entire repositories to locate the right files, and it'll warn you if you're running low on your hourly request allowance so you know to take a break before trying again.
67 |
68 | **What changed:**
69 |
70 | - **Automatic footprint loading**: Custom components are now loaded alongside configurations from GitHub repositories
71 | - **Smart conflict resolution**: Interactive dialog lets you choose how to handle duplicate footprint names
72 | - **Git submodule support**: Loads footprints from external libraries referenced in the repository
73 | - **Intelligent file discovery**: Searches entire repositories to find configuration files in any location
74 | - **Usage monitoring**: Proactive warnings when approaching GitHub's request limits, with clear guidance
75 | - **Better feedback**: Loading progress bar now appears when fetching from GitHub
76 |
--------------------------------------------------------------------------------
/src/molecules/ConflictResolutionDialog.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, fireEvent } from '@testing-library/react';
3 | import ConflictResolutionDialog from './ConflictResolutionDialog';
4 |
5 | describe('ConflictResolutionDialog', () => {
6 | const mockOnResolve = jest.fn();
7 | const mockOnCancel = jest.fn();
8 | const injectionName = 'test/footprint';
9 | const injectionType = 'footprint';
10 |
11 | beforeEach(() => {
12 | mockOnResolve.mockClear();
13 | mockOnCancel.mockClear();
14 | });
15 |
16 | it('renders with the correct injection name and type', () => {
17 | // Arrange & Act
18 | render(
19 |
26 | );
27 |
28 | // Assert
29 | expect(screen.getByText('Footprint Conflict')).toBeInTheDocument();
30 | expect(screen.getByText(injectionName)).toBeInTheDocument();
31 | expect(
32 | screen.getByText(/A footprint with the name/, { exact: false })
33 | ).toBeInTheDocument();
34 | });
35 |
36 | it('renders with template type correctly', () => {
37 | // Arrange & Act
38 | render(
39 |
46 | );
47 |
48 | // Assert
49 | expect(screen.getByText('Template Conflict')).toBeInTheDocument();
50 | expect(
51 | screen.getByText(/A template with the name/, { exact: false })
52 | ).toBeInTheDocument();
53 | });
54 |
55 | it('calls onResolve with "skip" when Skip button is clicked', () => {
56 | // Arrange
57 | render(
58 |
65 | );
66 |
67 | // Act
68 | fireEvent.click(screen.getByTestId('conflict-dialog-skip'));
69 |
70 | // Assert
71 | expect(mockOnResolve).toHaveBeenCalledWith('skip', false);
72 | });
73 |
74 | it('calls onResolve with "overwrite" when Overwrite button is clicked', () => {
75 | // Arrange
76 | render(
77 |
84 | );
85 |
86 | // Act
87 | fireEvent.click(screen.getByTestId('conflict-dialog-overwrite'));
88 |
89 | // Assert
90 | expect(mockOnResolve).toHaveBeenCalledWith('overwrite', false);
91 | });
92 |
93 | it('calls onResolve with "keep-both" when Keep Both button is clicked', () => {
94 | // Arrange
95 | render(
96 |
103 | );
104 |
105 | // Act
106 | fireEvent.click(screen.getByTestId('conflict-dialog-keep-both'));
107 |
108 | // Assert
109 | expect(mockOnResolve).toHaveBeenCalledWith('keep-both', false);
110 | });
111 |
112 | it('calls onResolve with applyToAll=true when checkbox is checked', () => {
113 | // Arrange
114 | render(
115 |
122 | );
123 |
124 | // Act
125 | const checkbox = screen.getByTestId('conflict-dialog-apply-to-all');
126 | fireEvent.click(checkbox);
127 | fireEvent.click(screen.getByTestId('conflict-dialog-skip'));
128 |
129 | // Assert
130 | expect(mockOnResolve).toHaveBeenCalledWith('skip', true);
131 | });
132 |
133 | it('calls onCancel when Cancel button is clicked', () => {
134 | // Arrange
135 | render(
136 |
143 | );
144 |
145 | // Act
146 | fireEvent.click(screen.getByTestId('conflict-dialog-cancel'));
147 |
148 | // Assert
149 | expect(mockOnCancel).toHaveBeenCalled();
150 | });
151 |
152 | it('has accessible labels for all interactive elements', () => {
153 | // Arrange
154 | render(
155 |
162 | );
163 |
164 | // Assert
165 | expect(
166 | screen.getByLabelText('Apply this choice to all conflicts')
167 | ).toBeInTheDocument();
168 | expect(screen.getByLabelText('Skip this footprint')).toBeInTheDocument();
169 | expect(
170 | screen.getByLabelText('Overwrite existing footprint')
171 | ).toBeInTheDocument();
172 | expect(screen.getByLabelText('Keep both footprints')).toBeInTheDocument();
173 | expect(screen.getByLabelText('Cancel loading')).toBeInTheDocument();
174 | });
175 | });
176 |
--------------------------------------------------------------------------------
/src/molecules/Downloads.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 |
3 | // Mock the worker factory to prevent worker creation in tests
4 | jest.mock('../workers/workerFactory', () => ({
5 | createErgogenWorker: () => ({
6 | postMessage: jest.fn(),
7 | terminate: jest.fn(),
8 | onmessage: (_e: any) => {},
9 | }),
10 | createJscadWorker: () => ({
11 | postMessage: jest.fn(),
12 | terminate: jest.fn(),
13 | onmessage: (_e: any) => {},
14 | }),
15 | }));
16 |
17 | // Mock the DownloadRow component
18 | jest.mock('../atoms/DownloadRow', () => {
19 | return function MockDownloadRow({
20 | fileName,
21 | extension,
22 | 'data-testid': dataTestId,
23 | }: {
24 | fileName: string;
25 | extension: string;
26 | 'data-testid'?: string;
27 | }) {
28 | return (
29 |
30 | {fileName}.{extension}
31 |
32 | );
33 | };
34 | });
35 |
36 | // Mock useConfigContext
37 | let mockContext: any = null;
38 | jest.mock('../context/ConfigContext', () => {
39 | const original = jest.requireActual('../context/ConfigContext');
40 | return {
41 | ...original,
42 | useConfigContext: () => mockContext,
43 | };
44 | });
45 |
46 | import Downloads from './Downloads';
47 |
48 | describe('Downloads', () => {
49 | const mockSetPreview = jest.fn();
50 | const mockResults = {
51 | demo: undefined,
52 | canonical: {},
53 | points: {},
54 | units: {},
55 | outlines: {},
56 | cases: {
57 | testCase: {
58 | jscad: 'mock jscad code',
59 | stl: 'mock stl content',
60 | },
61 | },
62 | pcbs: {},
63 | };
64 |
65 | const createMockContext = (
66 | debug: boolean,
67 | stlPreview: boolean,
68 | results: any = mockResults
69 | ) => ({
70 | configInput: '',
71 | setConfigInput: jest.fn(),
72 | injectionInput: undefined,
73 | setInjectionInput: jest.fn(),
74 | processInput: jest.fn(),
75 | generateNow: jest.fn(),
76 | error: null,
77 | setError: jest.fn(),
78 | clearError: jest.fn(),
79 | deprecationWarning: null,
80 | clearWarning: jest.fn(),
81 | results,
82 | resultsVersion: 1,
83 | setResultsVersion: jest.fn(),
84 | showSettings: false,
85 | setShowSettings: jest.fn(),
86 | showConfig: true,
87 | setShowConfig: jest.fn(),
88 | showDownloads: true,
89 | setShowDownloads: jest.fn(),
90 | debug,
91 | setDebug: jest.fn(),
92 | autoGen: false,
93 | setAutoGen: jest.fn(),
94 | autoGen3D: false,
95 | setAutoGen3D: jest.fn(),
96 | kicanvasPreview: false,
97 | setKicanvasPreview: jest.fn(),
98 | stlPreview,
99 | setStlPreview: jest.fn(),
100 | experiment: null,
101 | isGenerating: false,
102 | });
103 |
104 | describe('JSCAD filtering based on stlPreview and debug', () => {
105 | beforeEach(() => {
106 | mockSetPreview.mockClear();
107 | });
108 |
109 | it('should hide JSCAD files when stlPreview is true and debug is false', () => {
110 | // Arrange
111 | mockContext = createMockContext(false, true);
112 |
113 | // Act
114 | render(
115 |
120 | );
121 |
122 | // Assert
123 | const allElements = screen.queryAllByTestId('downloads-testCase');
124 |
125 | // Only STL should be present, JSCAD should be filtered out
126 | expect(allElements).toHaveLength(1);
127 | expect(allElements[0]?.getAttribute('data-extension')).toBe('stl');
128 | });
129 |
130 | it('should show JSCAD files when stlPreview is false and debug is false', () => {
131 | // Arrange
132 | mockContext = createMockContext(false, false);
133 |
134 | // Act
135 | render(
136 |
141 | );
142 |
143 | // Assert
144 | const jscadElement = screen.getByTestId('downloads-testCase');
145 |
146 | // JSCAD should be present
147 | expect(jscadElement).toBeInTheDocument();
148 | expect(jscadElement?.getAttribute('data-extension')).toBe('jscad');
149 |
150 | // STL should not be present (stlPreview is false)
151 | const allElements = screen.queryAllByTestId('downloads-testCase');
152 | expect(allElements).toHaveLength(1);
153 | });
154 |
155 | it('should show JSCAD files when stlPreview is true and debug is true', () => {
156 | // Arrange
157 | mockContext = createMockContext(true, true);
158 |
159 | // Act
160 | render(
161 |
166 | );
167 |
168 | // Assert
169 | const allElements = screen.getAllByTestId('downloads-testCase');
170 |
171 | // Both JSCAD and STL should be present
172 | expect(allElements).toHaveLength(2);
173 | expect(allElements[0]?.getAttribute('data-extension')).toBe('jscad');
174 | expect(allElements[1]?.getAttribute('data-extension')).toBe('stl');
175 | });
176 |
177 | it('should show JSCAD files when stlPreview is false and debug is true', () => {
178 | // Arrange
179 | mockContext = createMockContext(true, false);
180 |
181 | // Act
182 | render(
183 |
188 | );
189 |
190 | // Assert
191 | const jscadElement = screen.getByTestId('downloads-testCase');
192 |
193 | // JSCAD should be present
194 | expect(jscadElement).toBeInTheDocument();
195 | expect(jscadElement?.getAttribute('data-extension')).toBe('jscad');
196 |
197 | // STL should not be present (stlPreview is false)
198 | const allElements = screen.queryAllByTestId('downloads-testCase');
199 | expect(allElements).toHaveLength(1);
200 | });
201 | });
202 | });
203 |
--------------------------------------------------------------------------------
/src/examples/wubbo.ts:
--------------------------------------------------------------------------------
1 | import { ConfigExample } from './index';
2 |
3 | /**
4 | * An example Ergogen configuration for the Wubbo keyboard.
5 | * This example demonstrates the use of outlines and switchplate generation.
6 | * @type {ConfigExample}
7 | */
8 | const Wubbo: ConfigExample = {
9 | label: 'Wubbo',
10 | author: 'cache.works',
11 | value: `meta:
12 | engine: 4.1.0
13 | units:
14 | # Parameters
15 | row_spacing: 1cy
16 |
17 | pinky_rotation: 5 # degrees rotation relative to zone rotation
18 | pinky_stagger: 0 # mm, relative to previous column
19 | pinky_spread: 1cx # mm, relative to previous column
20 |
21 | ring_rotation: 3
22 | ring_stagger: 0.45cy
23 | ring_spread: 1.05cx
24 |
25 | middle_rotation: 0
26 | middle_stagger: 1
27 | middle_spread: 1.1cx
28 |
29 | index_rotation: -1
30 | index_stagger: -3
31 | index_spread: 1cx
32 |
33 | inner_rotation: -2
34 | inner_stagger: -5
35 | inner_spread: 1cx
36 |
37 | usb_cutout_x: 51.64
38 | usb_cutout_y: 2.10
39 | usb_cutout_r: -15.5
40 |
41 | # Constants
42 | choc_cap_x: 17.5
43 | choc_cap_y: 16.5
44 |
45 | choc_plate_thickness: 1.2
46 | mx_plate_thickness: 1.5
47 |
48 | points:
49 | rotate: 0
50 | key: # each key across all zones will have these properties
51 | bind: 5
52 | width: choc_cap_x
53 | height: choc_cap_y
54 | tags:
55 | 1u: true
56 | footprints: # These footprints will be added for each of the points
57 | choc_hotswap:
58 | type: choc
59 | nets:
60 | to: "{{key_net}}"
61 | from: GND
62 | params:
63 | reverse: false
64 | hotswap: true
65 | # Don't show a model for this since 'choc' already loads the model
66 | model: false
67 | keycaps: false
68 | choc:
69 | type: choc
70 | anchor:
71 | rotate: 180
72 | nets:
73 | to: "{{key_net}}"
74 | from: GND
75 | params:
76 | keycaps: true
77 | reverse: false
78 | zones:
79 | alphas:
80 | rows:
81 | bottom.padding: row_spacing
82 | home.padding: row_spacing
83 | top.padding: row_spacing
84 | columns:
85 | pinkycluster:
86 | key:
87 | splay: pinky_rotation
88 | rows:
89 | bottom.skip: true
90 | home.key_net: P106
91 | top.skip: true
92 | pinky:
93 | key:
94 | splay: pinky_rotation - pinky_rotation
95 | stagger: pinky_stagger
96 | spread: pinky_spread
97 | rows:
98 | bottom.key_net: P104
99 | home.key_net: P102
100 | top.skip: true
101 | ring:
102 | key:
103 | splay: ring_rotation - pinky_rotation
104 | stagger: ring_stagger
105 | spread: ring_spread
106 | rows:
107 | bottom.key_net: P101
108 | home.key_net: P103
109 | top.key_net: P100
110 | middle:
111 | key:
112 | splay: middle_rotation - ring_rotation
113 | stagger: middle_stagger
114 | spread: middle_spread
115 | rows:
116 | bottom.key_net: P022
117 | home.key_net: P029
118 | top.key_net: P030
119 | index:
120 | key:
121 | splay: index_rotation - middle_rotation
122 | stagger: index_stagger
123 | spread: index_spread
124 | rows:
125 | bottom.key_net: P031
126 | home.key_net: P004
127 | top.key_net: P005
128 | inner:
129 | key:
130 | splay: inner_rotation - index_rotation
131 | stagger: inner_stagger
132 | spread: inner_spread
133 | rows:
134 | bottom.key_net: P007
135 | home.key_net: P109
136 | top.key_net: P012
137 | thumbkeys:
138 | anchor:
139 | ref: alphas_index_bottom
140 | shift: [ 0.5cx, -1cy - 2]
141 | columns:
142 | near:
143 | key:
144 | splay: -10
145 | stagger: -5
146 | origin: [ 0, -0.5cy ]
147 | key_net: P009
148 | home:
149 | key:
150 | spread: 19
151 | stagger: 0.25cy # Move up by 0.25cy so a 1.5cy keycap lines up with the bottom
152 | splay: -15 # -25 degrees cumulative
153 | origin: [-0.5choc_cap_y, -0.75choc_cap_x] # Pivot at the lower left corner of a 1.5u choc key
154 | height: choc_cap_x
155 | width: 1.5choc_cap_y
156 | rotate: 90
157 | tags:
158 | 15u: true
159 | 1u: false
160 | key_net: P010
161 | rows:
162 | thumb:
163 | padding: 0
164 | outlines:
165 | _bottom_arch_circle:
166 | - what: circle
167 | radius: 500
168 | where:
169 | ref: alphas_middle_bottom
170 | shift: [-95, -525]
171 | _top_arch_circle:
172 | - what: circle
173 | radius: 200
174 | where:
175 | ref: alphas_middle_bottom
176 | shift: [0, -155]
177 | _main_body_circle:
178 | - what: circle
179 | radius: 70
180 | where:
181 | ref: alphas_middle_bottom
182 | shift: [0, 0]
183 | _usb_c_cutout:
184 | - what: rectangle
185 | size: [9.28, 6.67]
186 | where: &usbanchor
187 | ref: alphas_middle_top
188 | shift: [ usb_cutout_x, usb_cutout_y ]
189 | rotate: usb_cutout_r
190 | # Make a crescent by overlapping two circles then cut the main body with a third circle
191 | _main: [
192 | +_top_arch_circle,
193 | -_bottom_arch_circle,
194 | ~_main_body_circle
195 | ]
196 | _fillet:
197 | - what: outline
198 | name: _main
199 | fillet: 6
200 | combined: [
201 | _fillet,
202 | -_usb_c_cutout
203 | ]
204 | _switch_cutouts:
205 | - what: rectangle
206 | where: true
207 | asym: source
208 | size: 14 # Plate cutouts are 14mm * 14mm for both MX and Choc
209 | bound: false
210 | switch_plate:
211 | [ combined, -_switch_cutouts]
212 | cases:
213 | switchplate:
214 | - what: outline
215 | name: switch_plate
216 | extrude: choc_plate_thickness
217 | bottom:
218 | - what: outline
219 | name: combined
220 | extrude: choc_plate_thickness
221 | `,
222 | };
223 |
224 | export default Wubbo;
225 |
--------------------------------------------------------------------------------
/src/atoms/DownloadRow.tsx:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 | import { theme } from '../theme/theme';
3 | import { trackEvent } from '../utils/analytics';
4 |
5 | const spin = keyframes`
6 | from {
7 | transform: rotate(0deg);
8 | }
9 | to {
10 | transform: rotate(360deg);
11 | }
12 | `;
13 |
14 | /**
15 | * Interface for a preview object.
16 | * @interface Preview
17 | * @property {string} extension - The file extension of the preview content.
18 | * @property {string} key - A unique key for the preview.
19 | * @property {string} content - The content of the preview.
20 | */
21 | export interface Preview {
22 | extension: string;
23 | key: string;
24 | content: string;
25 | }
26 |
27 | /**
28 | * Props for the DownloadRow component.
29 | * @typedef {object} Props
30 | * @property {string} fileName - The name of the file to be downloaded.
31 | * @property {string} extension - The file extension.
32 | * @property {string} content - The content of the file.
33 | * @property {Preview} [preview] - An optional preview object. If provided, a preview button is shown.
34 | * @property {(preview: Preview) => void} setPreview - Function to set the active preview.
35 | */
36 | type Props = {
37 | fileName: string;
38 | extension: string;
39 | content: string;
40 | preview?: Preview;
41 | setPreview: (preview: Preview) => void;
42 | previewKey: string;
43 | 'data-testid'?: string;
44 | };
45 |
46 | /**
47 | * A styled div for the row layout.
48 | */
49 | const Row = styled.div`
50 | display: flex;
51 | justify-content: space-between;
52 | align-items: center;
53 | padding-bottom: 0.75rem;
54 |
55 | @media (max-width: 639px) {
56 | padding-bottom: 0.75rem;
57 | }
58 | `;
59 |
60 | /**
61 | * A styled div for displaying the file name, with ellipsis for overflow.
62 | */
63 | const FileName = styled.div<{
64 | $active: boolean;
65 | $hasPreview: boolean;
66 | $disabled: boolean;
67 | }>`
68 | overflow: hidden;
69 | text-overflow: ellipsis;
70 | font-size: ${theme.fontSizes.bodySmall};
71 | cursor: ${(props) => (props.$hasPreview ? 'pointer' : 'default')};
72 | border-bottom: ${(props) =>
73 | props.$active
74 | ? `2px solid ${theme.colors.accent}`
75 | : '2px solid transparent'};
76 | border-top: 2px solid transparent;
77 | opacity: ${(props) => (props.$disabled ? 0.5 : 1)};
78 | color: ${(props) =>
79 | props.$disabled ? theme.colors.textDarker : theme.colors.white};
80 | `;
81 |
82 | /**
83 | * A styled div to contain the action buttons.
84 | */
85 | const Buttons = styled.div`
86 | white-space: nowrap;
87 | display: flex;
88 | gap: 10px;
89 | align-items: center;
90 | `;
91 |
92 | /**
93 | * A styled anchor tag that looks like a button.
94 | * Used for preview and download actions.
95 | */
96 | const StyledLinkButton = styled.a`
97 | background-color: ${theme.colors.background};
98 | border: none;
99 | border-radius: 6px;
100 | color: ${theme.colors.white};
101 | display: flex;
102 | align-items: center;
103 | padding: 4px 6px;
104 | text-decoration: none;
105 | cursor: pointer;
106 | font-size: ${theme.fontSizes.bodySmall};
107 | line-height: 16px;
108 | height: 28px;
109 |
110 | .material-symbols-outlined {
111 | font-size: ${theme.fontSizes.iconMedium} !important;
112 | }
113 |
114 | &:hover {
115 | background-color: ${theme.colors.buttonHover};
116 | }
117 | `;
118 |
119 | const LoadingButton = styled.a`
120 | background-color: ${theme.colors.background};
121 | border: none;
122 | border-radius: 6px;
123 | color: ${theme.colors.white};
124 | display: flex;
125 | align-items: center;
126 | padding: 4px 6px;
127 | text-decoration: none;
128 | cursor: not-allowed;
129 | font-size: ${theme.fontSizes.bodySmall};
130 | line-height: 16px;
131 | height: 28px;
132 | opacity: 0.5;
133 |
134 | .material-symbols-outlined {
135 | font-size: ${theme.fontSizes.iconMedium} !important;
136 | animation: ${spin} 1s linear infinite;
137 | }
138 | `;
139 |
140 | /**
141 | * A component that displays a file name and provides buttons for previewing and downloading.
142 | *
143 | * @param {Props} props - The props for the component.
144 | * @returns {JSX.Element} A row with the file name and action buttons.
145 | */
146 | const DownloadRow = ({
147 | fileName,
148 | extension,
149 | content,
150 | preview,
151 | setPreview,
152 | previewKey,
153 | 'data-testid': dataTestId,
154 | }: Props) => {
155 | // Determine if this row is disabled (pending STL generation or KiCad Preview disabled)
156 | const isDisabled =
157 | (extension === 'stl' && !content) ||
158 | (extension === 'kicad_pcb' && !preview);
159 | // STL files without content show loading button, kicad_pcb files without preview still show download button
160 | const showLoadingButton = extension === 'stl' && !content;
161 |
162 | const handleDownload = () => {
163 | if (showLoadingButton) return;
164 | trackEvent('download_button_clicked', {
165 | download_type: extension,
166 | file_name: fileName,
167 | });
168 | const element = document.createElement('a');
169 | const file = new Blob([content], { type: 'octet/stream' });
170 | element.href = URL.createObjectURL(file);
171 | element.download = `${fileName}.${extension}`;
172 | document.body.appendChild(element);
173 | element.click();
174 | };
175 |
176 | const handlePreview = () => {
177 | if (preview && !isDisabled) {
178 | setPreview(preview);
179 | }
180 | };
181 |
182 | const testId = dataTestId ? `${dataTestId}-${extension}` : undefined;
183 |
184 | return (
185 |
186 |
193 | {fileName}.{extension}
194 |
195 |
196 | {showLoadingButton ? (
197 |
201 | progress_activity
202 |
203 | ) : (
204 |
209 | download
210 |
211 | )}
212 |
213 |
214 | );
215 | };
216 |
217 | export default DownloadRow;
218 |
--------------------------------------------------------------------------------
/public/images/previews/absolem.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/images/previews/corney_island.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------