Skip to content
Merged
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## SVG Outlines & Custom PWA Install Integration

July 16, 2026

![Visual preview of SVG outlines rendering and PWA install sidebar integration.](./public/images/changelog/placeholder.png)

Previously, custom outline injections were restricted only to JavaScript files, preventing designers from dropping vector SVG files directly into their keyboard configuration folder. Additionally, discovering and trigger-promoting PWA installation on Android and desktop devices depended on browser-specific heuristics, resulting in poor user engagement.

To improve custom drawing support and PWA usability, we implemented native SVG file outline parsing that dynamically wraps vector path definitions in a JS Maker.js model, allowing `.svg` outlines to load seamlessly alongside other assets. We also added an "Install App" button in the sidebar header to the left of the close button, utilizing the captured `beforeinstallprompt` event to enable on-demand installation.

**What changed:**

- **Native SVG Outlines**: Dropping or loading ZIPs containing `.svg` outline files now parses them dynamically and integrates them into standard Maker.js outlines.
- **Sidebar PWA Install Button**: A subtle install chip appears in the sidebar header to trigger PWA installation, backed by GA4 analytics events.
- **Dynamic Require Resolvers**: The worker's custom require resolution now handles relative template requires like `../../packages.json` to prevent runtime crashes.
- **Thematic Warning Badges**: The share compatibility warning dialog has been enhanced with warnings badges, and floating alerts notify users if outline features are gated.

## Build Stack Modernization Benchmark Report

July 16, 2026
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ergogen-gui",
"version": "0.18.0",
"version": "0.19.0",
"description": "A web-based GUI for Ergogen, the ergonomic keyboard layout generator.",
"license": "MIT",
"private": false,
Expand All @@ -10,7 +10,7 @@
"@monaco-editor/react": "^4.6.0",
"@react-three/drei": "^9.92.0",
"@react-three/fiber": "^8.15.0",
"ergogen": "^4.2.1",
"ergogen": "github:ceoloide/ergogen#v4.3.0",
"file-saver": "^2.0.5",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
Expand Down
64 changes: 56 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,16 +207,38 @@ interface BeforeInstallPromptEvent extends Event {
* Captures the 'beforeinstallprompt' event and exposes an install trigger.
* Gated behind the `?force_install` URL query parameter.
*/
function usePwaInstallPrompt(): (() => void) | undefined {
export interface PwaState {
onInstall: () => void;
isAvailable: boolean;
isInstalled: boolean;
isInstalling: boolean;
}

function usePwaInstallPrompt(): PwaState {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);

// Check if ?force_install is present in the URL query string
const isForceInstall = new URLSearchParams(window.location.search).has(
'force_install'
);

useEffect(() => {
const checkIsInstalled = () => {
const isStandalone =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(display-mode: standalone)').matches;
const isIOSStandalone =
(window.navigator as unknown as { standalone?: boolean }).standalone ===
true;
setIsInstalled(isStandalone || isIOSStandalone);
};

checkIsInstalled();

const handleBeforeInstallPrompt = (e: Event) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
Expand All @@ -225,20 +247,29 @@ function usePwaInstallPrompt(): (() => void) | undefined {
console.log('[PWA] beforeinstallprompt event fired and captured.');
};

const handleAppInstalled = () => {
console.log('[PWA] appinstalled event fired.');
setIsInstalled(true);
setIsInstalling(false);
};

window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);

return () => {
window.removeEventListener(
'beforeinstallprompt',
handleBeforeInstallPrompt
);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);

// Show the chip ONLY if the ?force_install query parameter is provided
if (!isForceInstall) return undefined;
const isAvailable = !!deferredPrompt || isForceInstall;

return () => {
const onInstall = () => {
trackEvent('pwa_install_click');
setIsInstalling(true);
if (deferredPrompt) {
// Show the install prompt
void deferredPrompt.prompt();
Expand All @@ -247,10 +278,13 @@ function usePwaInstallPrompt(): (() => void) | undefined {
(choiceResult: { outcome: string }) => {
if (choiceResult.outcome === 'accepted') {
console.log('[PWA] User accepted the install prompt');
trackEvent('pwa_install_accepted');
setIsInstalled(true);
} else {
console.log('[PWA] User dismissed the install prompt');
trackEvent('pwa_install_dismissed');
}
// Clear the saved prompt since it can only be used once
setIsInstalling(false);
setDeferredPrompt(null);
}
);
Expand All @@ -261,8 +295,16 @@ function usePwaInstallPrompt(): (() => void) | undefined {
alert(
'PWA installation is not supported by this browser or is restricted on this device. If you are on iOS/iPadOS, please open this site in Safari and select "Add to Home Screen" from the Share menu.'
);
setIsInstalling(false);
}
};

return {
onInstall,
isAvailable,
isInstalled,
isInstalling,
};
}

/**
Expand All @@ -283,7 +325,7 @@ const AppContent = ({
const configInput = configContext?.configInput;
const location = useLocation();
const onUpdate = useServiceWorkerUpdate();
const onInstall = usePwaInstallPrompt();
const pwaState = usePwaInstallPrompt();

// Store configs count in a ref to safely read it in useEffect without lint dependencies
const configsCountRef = useRef(0);
Expand Down Expand Up @@ -586,7 +628,7 @@ const AppContent = ({
data-testid="bulk-download-dialog"
/>
)}
<Header onUpdate={onUpdate} onInstall={onInstall} />
<Header onUpdate={onUpdate} />
<LoadingBar
visible={configContext?.isGenerating ?? false}
data-testid="loading-bar"
Expand All @@ -602,7 +644,13 @@ const AppContent = ({
<Route
path="/"
// The routing decision is now based on the reactive `configInput` state.
element={configInput ? <Ergogen /> : <Navigate to="/new" replace />}
element={
configInput ? (
<Ergogen pwaState={pwaState} />
) : (
<Navigate to="/new" replace />
)
}
/>
<Route path="/new" element={<Welcome />} />
<Route path="*" element={<Navigate to="/" replace />} />
Expand Down
22 changes: 21 additions & 1 deletion src/Ergogen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import GrowButton from './atoms/GrowButton';
import Title from './atoms/Title';
import { theme } from './theme/theme';
import { SettingsCard, SettingsGroupTitle } from './atoms/SettingsLayout';
import OfflineOption from './atoms/OfflineOption';
import { PwaState } from './App';

import { trackEvent } from './utils/analytics';
import ShareDialog from './molecules/ShareDialog';
Expand Down Expand Up @@ -278,7 +280,11 @@ const FlexContainer = styled.div`
*
* @returns {JSX.Element | null} The rendered Ergogen application UI, or null if the config context is not available.
*/
const Ergogen = () => {
type ErgogenProps = {
pwaState?: PwaState;
};

const Ergogen = ({ pwaState }: ErgogenProps) => {
// Calculate initial widths based on viewport
const getInitialLeftWidth = () => Math.max(200, window.innerWidth * 0.33);
const getInitialRightWidth = () => Math.max(150, window.innerWidth * 0.15);
Expand Down Expand Up @@ -817,6 +823,20 @@ const Ergogen = () => {
aria-label="Send usage metrics"
/>
</SettingsCard>

{pwaState && (
<>
<SettingsGroupTitle>Offline</SettingsGroupTitle>
<SettingsCard>
<OfflineOption
isAvailable={pwaState.isAvailable}
isInstalled={pwaState.isInstalled}
isInstalling={pwaState.isInstalling}
onInstall={pwaState.onInstall}
/>
</SettingsCard>
</>
)}
</OptionContainer>
<Injections
setInjectionToEdit={setInjectionToEdit}
Expand Down
12 changes: 3 additions & 9 deletions src/atoms/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useConfigContext } from '../context/ConfigContext';
import { getErgogenVersionInfo } from '../utils/version';
import { DevChip } from './DevChip';
import UpdateChip from './UpdateChip';
import InstallChip from './InstallChip';

import { theme } from '../theme/theme';
import { createZip } from '../utils/zip';
import { trackEvent } from '../utils/analytics';
Expand Down Expand Up @@ -174,10 +174,9 @@ const LogoImage = styled.img`
*/
type HeaderProps = {
onUpdate?: () => void;
onInstall?: () => void;
};

const Header = ({ onUpdate, onInstall }: HeaderProps): JSX.Element => {
const Header = ({ onUpdate }: HeaderProps): JSX.Element => {
const configContext = useConfigContext();
const navigate = useNavigate();
const location = useLocation();
Expand Down Expand Up @@ -472,12 +471,7 @@ const Header = ({ onUpdate, onInstall }: HeaderProps): JSX.Element => {
{onUpdate && (
<UpdateChip onClick={onUpdate} data-testid="header-update-chip" />
)}
{onInstall && (
<InstallChip
onClick={onInstall}
data-testid="header-install-chip"
/>
)}

{location.pathname === '/' && (
<>
<AccentIconButton
Expand Down
105 changes: 105 additions & 0 deletions src/atoms/OfflineOption.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import OfflineOption from './OfflineOption';

describe('OfflineOption', () => {
const mockOnInstall = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it('renders "Offline App" title', () => {
render(
<OfflineOption
isAvailable={true}
isInstalled={false}
isInstalling={false}
onInstall={mockOnInstall}
/>
);
expect(screen.getByText('Offline App')).toBeInTheDocument();
});

it('renders installation instructions when PWA is available to install', () => {
render(
<OfflineOption
isAvailable={true}
isInstalled={false}
isInstalling={false}
onInstall={mockOnInstall}
/>
);
expect(
screen.getByText(
'Install the app locally to access it offline without network connectivity.'
)
).toBeInTheDocument();

const button = screen.getByRole('button', { name: /install app/i });
expect(button).toBeEnabled();
});

it('calls onInstall when install button is clicked', () => {
render(
<OfflineOption
isAvailable={true}
isInstalled={false}
isInstalling={false}
onInstall={mockOnInstall}
/>
);
const button = screen.getByRole('button', { name: /install app/i });
fireEvent.click(button);
expect(mockOnInstall).toHaveBeenCalledTimes(1);
});

it('shows "Installing..." and is disabled when installation is in progress', () => {
render(
<OfflineOption
isAvailable={true}
isInstalled={false}
isInstalling={true}
onInstall={mockOnInstall}
/>
);
const button = screen.getByRole('button', { name: /installing\.\.\./i });
expect(button).toBeDisabled();
});

it('shows "Installed" and is disabled when already installed', () => {
render(
<OfflineOption
isAvailable={true}
isInstalled={true}
isInstalling={false}
onInstall={mockOnInstall}
/>
);
expect(
screen.getByText('The application is installed and available offline.')
).toBeInTheDocument();

const button = screen.getByRole('button', { name: /installed/i });
expect(button).toBeDisabled();
});

it('shows warning text and "Unavailable" button when installation is unsupported', () => {
render(
<OfflineOption
isAvailable={false}
isInstalled={false}
isInstalling={false}
onInstall={mockOnInstall}
/>
);
expect(
screen.getByText(
'PWA installation is not supported by your browser or environment.'
)
).toBeInTheDocument();

const button = screen.getByRole('button', { name: /unavailable/i });
expect(button).toBeDisabled();
});
});
Loading
Loading